Skip to content

Commit a687089

Browse files
committed
refactor: expand a library's imports into manifest membership
Imports are followed when a library is added rather than resolved again on every validation, so a manifest is an explicit and complete list: what library_names shows is exactly what the model is compiled and validated against, with no resolution step in between that could add or drop a graph. model.manifest.add(shapes_lib) # owl:imports Brick model.manifest.library_names # ['https://brickschema.org/schema/1.4/Brick', 'urn:my/shapes'] add() takes import_depth with OntoEnv's own meaning -- -1 (default) for the full closure, 0 for the named library alone, 1 for it and its direct imports. For a library OntoEnv knows the names come from list_closure, a name lookup rather than a graph build; for one it does not know (a directory-loaded library, named after its directory) the same walk runs over that library's own shape collection, which is where its owl:imports live. Removal does not cascade: dropping the shapes library leaves Brick a member, because the manifest is a flat set that reads as what it is. This replaces the manifest-rooted closure from the previous commit. Manifest.register() and imports_closure() are gone, and with them the write-on-a-read-path -- validating no longer registers the manifest graph with OntoEnv. Manifest.shapes_graph() is now the union of the members' shape collections, so validation sees each library's *own* stored graph, including whatever SHACL inference added when it was loaded, rather than OntoEnv's separately stored copy of the same ontology. That divergence measured zero triples on every library at hand, but it is now structurally impossible rather than merely unobserved. shapes_graph() still asks OntoEnv the one question resolve_imports always asked -- is anything imported here unaccounted for? -- once over the union instead of once per collection. Expansion normally makes the answer no; it catches a library reloaded with new imports since, or one added with import_depth=0. It raises OntologyImportsNotFound unless error_on_missing_imports=False. Consequence worth knowing: compile() now infers against everything the members import, because those are members. The previous commit deliberately kept them out of the inference input; complete membership makes that distinction unavailable, which is the point of the design. Full unit suite: 572 passed, 1 skipped.
1 parent fcb5627 commit a687089

9 files changed

Lines changed: 272 additions & 194 deletions

File tree

.agents/skills/buildingmotif/references/validation.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,11 @@ Two things in this script that trip people up:
394394
validate against the manifest plus extras, spread `model.manifest.shape_collections()`
395395
into the list. `model.validate()` with no list validates against the manifest alone —
396396
that is, against the shape collections of every library the manifest names.
397-
- **The no-argument form is also the faster one.** A manifest is an ontology whose
398-
`owl:imports` name its members, so `model.validate()` resolves them as a single OntoEnv
399-
closure rooted at the manifest instead of resolving each collection's imports separately
400-
— flat in the number of members rather than linear. Passing an explicit list gives up
401-
that path, since a bare list has no manifest to root a closure at.
397+
- **The no-argument form does no import resolution at all.** A manifest expands a
398+
library's `owl:imports` into members when you add it, so `model.validate()` just unions
399+
the members' shape collections. Passing an explicit list carries no such guarantee, so
400+
that path still resolves each collection's imports. `model.manifest.library_names` is
401+
therefore the complete list of what a no-argument `validate()` checks against.
402402
- **`error_on_missing_imports=False` is the notebooks' default for real models.** A real
403403
model's shapes usually `owl:imports` something you haven't loaded; with `True` (the
404404
default) validation raises and stops. `False` gets you a report now — but always check

.agents/skills/buildingmotif/references/writing_shapes.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,13 @@ environment (cache first, then a fetch if it is a URL), so
238238
`model.manifest.add("https://brickschema.org/schema/1.4/Brick")` loads Brick if it has
239239
to; a name that resolves nowhere raises rather than failing later at validation time.
240240

241+
Adding a library also adds what it **imports**, transitively, as members of their own, so
242+
`library_names` is the complete list of what the model is validated and compiled against —
243+
there is no resolution step at validation time that could add or drop a graph.
244+
`add(lib, import_depth=0)` records just the library named; `import_depth` otherwise takes
245+
OntoEnv's meaning (`-1` full closure, `1` direct imports). Removal does not cascade: drop
246+
the shapes library and Brick stays a member until you remove it too.
247+
241248
Storage-wise the manifest is a graph of `owl:imports` and nothing else —
242249
`model.manifest.graph` hands you a copy to serialize or diff.
243250

buildingmotif/dataclasses/compiled_model.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ def __init__(
5151
``Model.validate`` both already take ``Optional[str]``.
5252
:type shacl_engine: Optional[str]
5353
:param manifest: the manifest ``shape_collections`` came from, when it
54-
came from one. :py:meth:`validate` then resolves imports as a
55-
single OntoEnv closure rooted at the manifest rather than once per
56-
collection. It is held rather than resolved here because compiling
57-
does not need the imports at all -- only validating does.
54+
came from one. :py:meth:`validate` then takes the shapes graph
55+
straight from its members, since a manifest already lists
56+
everything they import. It is held rather than resolved here
57+
because the graph is only needed to validate, not to compile.
5858
:type manifest: Optional[Manifest]
5959
"""
6060
self.model = model
@@ -214,12 +214,11 @@ def validate(
214214
)
215215
backend = get_shacl_backend(shacl_engine)
216216

217-
# One closure rooted at the manifest, rather than one resolve_imports
218-
# per collection: the manifest names every collection as an
219-
# owl:imports, so OntoEnv can do the whole transitive resolution in a
220-
# single call and deduplicate it on the way.
217+
# A manifest's members already include everything they import --
218+
# add() followed the imports when they were added -- so the shapes
219+
# graph is their union, with no import resolution at validation time.
221220
resolved_shapes = (
222-
self.manifest.imports_closure(error_on_missing=error_on_missing_imports)
221+
self.manifest.shapes_graph(error_on_missing=error_on_missing_imports)
223222
if self.manifest is not None
224223
else None
225224
)

buildingmotif/dataclasses/manifest.py

Lines changed: 116 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,20 @@ def __repr__(self) -> str:
254254
return f"Manifest({self.uri}, {self.library_names})"
255255

256256
def add(
257-
self, *libraries: Union[LibraryRef, Iterable[LibraryRef]], resolve: bool = True
257+
self,
258+
*libraries: Union[LibraryRef, Iterable[LibraryRef]],
259+
resolve: bool = True,
260+
import_depth: int = -1,
258261
) -> None:
259262
"""Add libraries to this manifest. Adding a member twice is a no-op.
260263
264+
What a library ``owl:imports`` is pulled in **here**, as members of its
265+
own, rather than resolved again on every validation. A manifest is
266+
therefore an explicit and complete list: what
267+
:py:attr:`library_names` shows is exactly what the model is compiled
268+
and validated against, with no resolution step in between that could
269+
quietly add or drop a graph.
270+
261271
:param libraries: :py:class:`Library` objects, library names, or
262272
iterables of either
263273
:param resolve: if True (default), a name that is not already a loaded
@@ -266,8 +276,14 @@ def add(
266276
the active BuildingMOTIF permits fetching -- and loaded as a
267277
library. A name that resolves nowhere raises rather than being
268278
recorded as an import that will fail later. Pass False to record
269-
the import without resolving it.
279+
the import without resolving it; no expansion happens either, since
280+
nothing was loaded to read imports from.
270281
:type resolve: bool
282+
:param import_depth: how far to follow each library's ``owl:imports``,
283+
with OntoEnv's own meaning: ``-1`` (default) for the full closure,
284+
``0`` for the named libraries alone, ``1`` for them and what they
285+
import directly, and so on.
286+
:type import_depth: int
271287
:raises ManifestLibraryNotFound: if ``resolve`` and a name resolves
272288
nowhere
273289
:raises TypeError: if handed something that is not a library or a name
@@ -276,9 +292,12 @@ def add(
276292
self._ensure_declared()
277293
for item in _flatten(libraries):
278294
name = _name_of(item)
279-
if resolve and not isinstance(item, Library):
295+
names = [name]
296+
if resolve:
280297
self._resolve_one(name)
281-
graph.add((self.uri, OWL.imports, library_iri(name)))
298+
names = self._expand(name, import_depth)
299+
for member in names:
300+
graph.add((self.uri, OWL.imports, library_iri(member)))
282301

283302
def remove(self, *libraries: Union[LibraryRef, Iterable[LibraryRef]]) -> None:
284303
"""Remove libraries from this manifest.
@@ -355,89 +374,110 @@ def shape_collections(self, error_on_missing: bool = True) -> List[ShapeCollecti
355374
for library in self.resolve(error_on_missing=error_on_missing)
356375
]
357376

358-
def register(self) -> str:
359-
"""Register this manifest's graph with the ontology environment.
360-
361-
The manifest *is* an ontology -- a declaration and a list of
362-
``owl:imports`` -- so OntoEnv can resolve it like any other, which is
363-
what makes :py:meth:`imports_closure` a single call. Registering is
364-
idempotent and overwrites, so it is cheap to redo rather than track
365-
whether the manifest changed since last time (the graph is one triple
366-
per member).
367-
368-
:return: the name OntoEnv registered it under, i.e. :py:attr:`uri`
369-
:rtype: str
370-
"""
371-
# OntoEnv names a graph by its owl:Ontology declaration, so a manifest
372-
# that has never been added to has nothing to register *as*.
373-
self._ensure_declared()
374-
return self._model._bm.ontology_environment.add(
375-
self.graph, fetch_imports=False, overwrite=True
376-
)
377-
378-
def imports_closure(self, error_on_missing: bool = True) -> Graph:
379-
"""The merged shapes graph this model is validated against.
380-
381-
One OntoEnv closure rooted at this manifest, rather than resolving each
382-
member's imports separately: the manifest declares every member as an
383-
``owl:imports``, so the closure is exactly "every graph this model is
384-
checked against", already transitive and already deduplicated.
385-
386-
Members OntoEnv cannot resolve -- a directory-loaded library, whose
387-
name is not an ontology URI, or one built with :py:meth:`Library.create`
388-
-- come from their own shape collections instead. Each member is taken
389-
from **one** source, never both: OntoEnv's copy of an ontology and the
390-
library's shape collection hold the same triples but relabel blank
391-
nodes, so unioning them would duplicate every SHACL property shape and
392-
report each violation twice.
393-
394-
:param error_on_missing: if True (default), an import that resolves
395-
neither through OntoEnv nor as a library raises, as
396-
:py:meth:`ShapeCollection.resolve_imports` has always done. If
397-
False they are logged and skipped.
377+
def shapes_graph(self, error_on_missing: bool = True) -> Graph:
378+
"""The merged shapes graph this model is validated and compiled against.
379+
380+
Simply the union of the member libraries' shape collections. There is
381+
no import resolution here, and no round trip through OntoEnv: a
382+
library's ``owl:imports`` were followed when it was added
383+
(:py:meth:`add`), so every graph that matters is already a member.
384+
That also means validation sees each library's **own** stored graph --
385+
including whatever SHACL inference added when the library was loaded --
386+
rather than a separately stored copy of the same ontology.
387+
388+
:param error_on_missing: if True (default), raise when a member's
389+
``owl:imports`` names something neither loaded nor resolvable,
390+
which is the check :py:meth:`ShapeCollection.resolve_imports` has
391+
always made. If False they are logged and skipped.
398392
:type error_on_missing: bool
399393
:raises OntologyImportsNotFound: per ``error_on_missing``
400394
:return: the shapes graph
401395
:rtype: Graph
402396
"""
403-
if not self.imports:
404-
# An empty manifest asks nothing of the model. Short-circuit rather
405-
# than round-tripping through OntoEnv, which would have no graph to
406-
# root a closure at anyway.
407-
return Graph()
397+
graph = Graph()
398+
for shape_collection in self.shape_collections(
399+
error_on_missing=error_on_missing
400+
):
401+
graph += shape_collection.graph
402+
if not len(graph):
403+
return graph
404+
405+
# Ask OntoEnv the same question resolve_imports asks, once over the
406+
# union rather than once per collection: is anything imported here
407+
# unaccounted for? Expansion at add() time means the answer is normally
408+
# no -- this catches a library reloaded with new imports since, or one
409+
# added with import_depth=0.
410+
missing = self._model._bm.ontology_environment.missing_imports(graph)
411+
if missing:
412+
if error_on_missing:
413+
raise OntologyImportsNotFound(missing)
414+
logger.warning(
415+
"Manifest of %s: %s is imported but not a member and could not "
416+
"be resolved; shapes it defines will not be applied.",
417+
self._model.name,
418+
", ".join(sorted(missing)),
419+
)
420+
return graph
408421

409-
env = self._model._bm.ontology_environment
410-
self.register()
422+
def _expand(self, name: str, import_depth: int) -> List[str]:
423+
"""``name`` plus the libraries it imports, to ``import_depth``.
411424
412-
closure, _ = env.closure_copy(str(self.uri))
425+
OntoEnv answers this for free when it knows the ontology
426+
(``list_closure`` is a name lookup, not a graph build). For a member it
427+
does not know -- a directory-loaded library, named after its directory
428+
-- the same walk is done over the library's own shape collection, which
429+
is where its ``owl:imports`` live.
430+
"""
431+
if import_depth == 0:
432+
return [name]
413433

414-
# Whatever the closure could not reach: manifest members OntoEnv does
415-
# not know, plus anything transitively imported and missing (which
416-
# resolve_imports has always treated as an error).
417-
unresolved = []
418-
for iri in env.missing_imports(str(self.uri)):
419-
name = library_name(URIRef(iri))
434+
env = self._model._bm.ontology_environment
435+
if env.knows(name):
436+
candidates = env.closure_names(name, recursion_depth=import_depth)
437+
else:
438+
candidates = self._imported_names(name, import_depth)
439+
440+
names = []
441+
for candidate in candidates:
420442
try:
421-
library = self._resolve_one(name)
443+
self._resolve_one(candidate)
422444
except ManifestLibraryNotFound:
423-
unresolved.append(iri)
445+
# An import that resolves nowhere is not a reason to reject the
446+
# library that named it; shapes_graph() reports it if it still
447+
# matters at validation time.
448+
logger.warning(
449+
"Manifest of %s: %r imports %r, which could not be loaded "
450+
"as a library and is not a member.",
451+
self._model.name,
452+
name,
453+
candidate,
454+
)
424455
continue
425-
closure += (
426-
library.get_shape_collection()
427-
.resolve_imports(error_on_missing_imports=error_on_missing)
428-
.graph
429-
)
430-
431-
if unresolved:
432-
if error_on_missing:
433-
raise OntologyImportsNotFound(unresolved)
434-
logger.warning(
435-
"Manifest of %s could not resolve: %s. Validation will not see "
436-
"any shapes those graphs define.",
437-
self._model.name,
438-
", ".join(sorted(unresolved)),
439-
)
440-
return closure
456+
names.append(candidate)
457+
return names
458+
459+
def _imported_names(self, name: str, import_depth: int) -> List[str]:
460+
""":py:meth:`_expand`'s walk for libraries OntoEnv does not know."""
461+
found: List[str] = []
462+
seen = set()
463+
queue = [(name, import_depth)]
464+
while queue:
465+
current, depth = queue.pop(0)
466+
if current in seen:
467+
continue
468+
seen.add(current)
469+
found.append(current)
470+
if depth == 0:
471+
continue
472+
try:
473+
library = self._resolve_one(current)
474+
except ManifestLibraryNotFound:
475+
continue
476+
for imported in library.get_shape_collection().graph.objects(
477+
None, OWL.imports
478+
):
479+
queue.append((str(imported), -1 if depth < 0 else depth - 1))
480+
return found
441481

442482
def _ensure_declared(self) -> None:
443483
"""Give the manifest graph an ``owl:Ontology`` declaration if it has none."""

buildingmotif/dataclasses/model.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,10 @@ def compile(
330330
manifest = self.manifest
331331
shape_collections = manifest.shape_collections()
332332
backend = get_shacl_backend(shacl_engine or self._bm.shacl_engine)
333-
# NB: inference compiles against the member shape collections
334-
# themselves, *not* the manifest's imports closure -- what a model
335-
# infers from should be the shapes it was compiled against, and pulling
336-
# every transitively imported ontology into the inference input would
337-
# change what lands in every compiled model.
333+
# Compiling against the members is compiling against everything they
334+
# import, because a manifest expands imports when a library is added
335+
# rather than resolving them here. There is deliberately no separate
336+
# resolution step: the explicit membership is the whole story.
338337
compiled_graph = backend.compile_model_graph(self.graph, shape_collections)
339338
return CompiledModel(
340339
self,

buildingmotif/shacl.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ def _resolved_shape_graph(
114114
"""The shapes graph, with ``owl:imports`` resolved.
115115
116116
``resolved_shapes`` is the whole answer when it is given: validating
117-
against a manifest computes one OntoEnv closure rooted at the manifest
118-
(:py:meth:`Manifest.imports_closure`), which is transitive, deduplicated,
119-
and one call rather than one per collection. The per-collection path
120-
remains for an explicit list of shape collections, which has no manifest to
121-
root a closure at.
117+
against a manifest takes the shapes graph from
118+
:py:meth:`Manifest.shapes_graph`, whose members already include everything
119+
they import. The per-collection path remains for an explicit list of shape
120+
collections, which carries no such guarantee and so still has to resolve
121+
each collection's imports.
122122
"""
123123
if resolved_shapes is not None:
124124
return resolved_shapes
@@ -173,9 +173,9 @@ def validation_graphs(
173173
resolved_shapes: Optional[Graph] = None,
174174
) -> ValidationGraphs:
175175
"""
176-
:param resolved_shapes: the shapes graph with its ``owl:imports``
177-
already resolved -- see :func:`_resolved_shape_graph`. Defaults to
178-
resolving each shape collection's imports separately.
176+
:param resolved_shapes: a shapes graph that needs no import resolution
177+
-- see :func:`_resolved_shape_graph`. Defaults to resolving each
178+
shape collection's imports separately.
179179
:type resolved_shapes: Optional[Graph]
180180
"""
181181
from buildingmotif.utils import (

0 commit comments

Comments
 (0)