Skip to content

Commit fcb5627

Browse files
committed
perf: resolve a manifest's imports as one closure rooted at the manifest
A manifest is an ontology whose owl:imports name every member, so OntoEnv can resolve the whole thing in one call instead of BuildingMOTIF resolving each member's imports separately. Manifest.register() adds the manifest graph to the ontology environment (idempotent, overwriting, ~4ms for a graph of one triple per member) and Manifest.imports_closure() returns the closure rooted at it: transitive and deduplicated, and flat in the number of members where the old path was linear. Measured on a Brick fixture + shape1.ttl (4,832-triple closure, 5 reps): members one closure per-collection speedup 1 0.080s 0.146s 1.8x 2 0.077s 0.248s 3.2x 4 0.082s 0.390s 4.8x End to end through validate(), where SHACL dominates: 1.24x (1 member) and 1.77x (2). Results are identical -- a test asserts the manifest path and the per-collection path report the same failures for the same focus nodes on every engine. Each member is taken from exactly one source. The closure supplies the members OntoEnv knows; a member it does not know -- a directory-loaded library, whose name is not an ontology URI, or one built with Library.create -- contributes its own shape collection instead. Never both: OntoEnv's copy and the library's shape collection hold the same triples with different blank-node labels, so unioning them would duplicate every SHACL property shape and report each violation twice. Anything that resolves neither way raises OntologyImportsNotFound, the same exception resolve_imports has always raised, honoring error_on_missing_imports. CompiledModel now carries the Manifest its shape collections came from and resolves the closure lazily in validate(), because compiling does not need the imports at all. Passing an explicit list of shape collections keeps the per-collection path -- a bare list has no manifest to root a closure at. Inference input is deliberately unchanged: compile() still runs inference against the member shape collections, not the closure. What a model infers from should be the shapes it was compiled against, and pulling every transitively imported ontology into the inference input would change what lands in every compiled model. Full unit suite: 570 passed, 1 skipped.
1 parent d9c0610 commit fcb5627

7 files changed

Lines changed: 296 additions & 16 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +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.
397402
- **`error_on_missing_imports=False` is the notebooks' default for real models.** A real
398403
model's shapes usually `owl:imports` something you haven't loaded; with `True` (the
399404
default) validation raises and stops. `False` gets you a report now — but always check

buildingmotif/dataclasses/compiled_model.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
if TYPE_CHECKING:
2323
from buildingmotif.dataclasses.algebraic_validation import RepairConfig
2424
from buildingmotif.dataclasses.library import Library
25+
from buildingmotif.dataclasses.manifest import Manifest
2526

2627

2728
@dataclass
@@ -40,6 +41,7 @@ def __init__(
4041
shape_collections: List[ShapeCollection],
4142
compiled_graph: rdflib.Graph,
4243
shacl_engine: Optional[str] = None,
44+
manifest: Optional["Manifest"] = None,
4345
):
4446
"""
4547
:param shacl_engine: the engine this model was compiled with. None
@@ -48,9 +50,16 @@ def __init__(
4850
sentinel the rest of the codebase uses -- ``Model.compile`` and
4951
``Model.validate`` both already take ``Optional[str]``.
5052
:type shacl_engine: Optional[str]
53+
: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.
58+
:type manifest: Optional[Manifest]
5159
"""
5260
self.model = model
5361
self.shape_collections = shape_collections
62+
self.manifest = manifest
5463
self.shacl_engine = (
5564
self.model._bm.shacl_engine
5665
# "default" is the legacy spelling of "inherit from the singleton"
@@ -205,6 +214,16 @@ def validate(
205214
)
206215
backend = get_shacl_backend(shacl_engine)
207216

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.
221+
resolved_shapes = (
222+
self.manifest.imports_closure(error_on_missing=error_on_missing_imports)
223+
if self.manifest is not None
224+
else None
225+
)
226+
208227
# The pyshifty engine exposes a native algebraic + symbolic-repair API.
209228
# Auto-route it to the AlgebraicValidationContext, which computes repairs
210229
# by abduction over the algebra and gates every one for soundness, rather
@@ -219,6 +238,7 @@ def validate(
219238
self._compiled_graph,
220239
self.shape_collections,
221240
error_on_missing_imports=error_on_missing_imports,
241+
resolved_shapes=resolved_shapes,
222242
)
223243
return AlgebraicValidationContext.from_compiled(
224244
self.shape_collections,
@@ -235,6 +255,7 @@ def validate(
235255
self._compiled_graph,
236256
self.shape_collections,
237257
error_on_missing_imports=error_on_missing_imports,
258+
resolved_shapes=resolved_shapes,
238259
)
239260
return ValidationContext(
240261
self.shape_collections,

buildingmotif/dataclasses/manifest.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from buildingmotif.database.errors import LibraryNotFound
2727
from buildingmotif.dataclasses.library import Library
2828
from buildingmotif.dataclasses.shape_collection import ShapeCollection
29+
from buildingmotif.ontology_environment import OntologyImportsNotFound
2930

3031
if TYPE_CHECKING:
3132
from buildingmotif.dataclasses.model import Model
@@ -354,6 +355,90 @@ def shape_collections(self, error_on_missing: bool = True) -> List[ShapeCollecti
354355
for library in self.resolve(error_on_missing=error_on_missing)
355356
]
356357

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.
398+
:type error_on_missing: bool
399+
:raises OntologyImportsNotFound: per ``error_on_missing``
400+
:return: the shapes graph
401+
:rtype: Graph
402+
"""
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()
408+
409+
env = self._model._bm.ontology_environment
410+
self.register()
411+
412+
closure, _ = env.closure_copy(str(self.uri))
413+
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))
420+
try:
421+
library = self._resolve_one(name)
422+
except ManifestLibraryNotFound:
423+
unresolved.append(iri)
424+
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
441+
357442
def _ensure_declared(self) -> None:
358443
"""Give the manifest graph an ``owl:Ontology`` declaration if it has none."""
359444
graph = self._shape_collection.graph

buildingmotif/dataclasses/model.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,15 @@ def validate(
285285
so code that only reads failures need not care which one it got.
286286
:rtype: ValidationResult
287287
"""
288+
manifest = None
288289
if not shape_collections:
289-
shape_collections = self.manifest.shape_collections(
290+
manifest = self.manifest
291+
shape_collections = manifest.shape_collections(
290292
error_on_missing=error_on_missing_imports
291293
)
292-
compiled_model = self.compile(shape_collections, shacl_engine=shacl_engine)
294+
compiled_model = self.compile(
295+
shape_collections, shacl_engine=shacl_engine, manifest=manifest
296+
)
293297
return compiled_model.validate(
294298
error_on_missing_imports,
295299
shacl_engine,
@@ -301,6 +305,7 @@ def compile(
301305
self,
302306
shape_collections: Optional[List["ShapeCollection"]] = None,
303307
shacl_engine: Optional[str] = None,
308+
manifest: Optional["Manifest"] = None,
304309
) -> "CompiledModel":
305310
"""Compile the graph of a model against a set of ShapeCollections.
306311
@@ -310,21 +315,33 @@ def compile(
310315
:param shacl_engine: the SHACL engine to use for validation, defaults to whatever
311316
is set in the BuildingMOTIF object
312317
:type shacl_engine: str, optional
318+
:param manifest: the manifest ``shape_collections`` came from, if any.
319+
Passed on to the :py:class:`CompiledModel` so that validating it
320+
later resolves imports as one closure rooted at the manifest. It is
321+
filled in automatically when ``shape_collections`` is omitted.
322+
:type manifest: Optional[Manifest]
313323
:return: copy of model's graph that has been compiled against the
314324
ShapeCollections
315325
:rtype: Graph
316326
"""
317327
from buildingmotif.dataclasses.compiled_model import CompiledModel
318328

319329
if shape_collections is None:
320-
shape_collections = self.manifest.shape_collections()
330+
manifest = self.manifest
331+
shape_collections = manifest.shape_collections()
321332
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.
322338
compiled_graph = backend.compile_model_graph(self.graph, shape_collections)
323339
return CompiledModel(
324340
self,
325341
shape_collections,
326342
compiled_graph,
327343
shacl_engine=shacl_engine,
344+
manifest=manifest,
328345
)
329346

330347
@property

buildingmotif/shacl.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,30 @@ def _shifty_shapes_input(shape_graph: Graph) -> bytes:
106106
return prefixed.serialize(format="turtle", encoding="utf-8")
107107

108108

109+
def _resolved_shape_graph(
110+
shape_collections: List["ShapeCollection"],
111+
error_on_missing_imports: bool,
112+
resolved_shapes: Optional[Graph],
113+
) -> Graph:
114+
"""The shapes graph, with ``owl:imports`` resolved.
115+
116+
``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.
122+
"""
123+
if resolved_shapes is not None:
124+
return resolved_shapes
125+
graph = Graph()
126+
for shape_collection in shape_collections:
127+
graph += shape_collection.resolve_imports(
128+
error_on_missing_imports=error_on_missing_imports
129+
).graph
130+
return graph
131+
132+
109133
@dataclass
110134
class ValidationGraphs:
111135
data_graph: Graph
@@ -146,18 +170,24 @@ def validation_graphs(
146170
compiled_graph: Graph,
147171
shape_collections: List["ShapeCollection"],
148172
error_on_missing_imports: bool = True,
173+
resolved_shapes: Optional[Graph] = None,
149174
) -> ValidationGraphs:
175+
"""
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.
179+
:type resolved_shapes: Optional[Graph]
180+
"""
150181
from buildingmotif.utils import (
151182
copy_graph,
152183
rewrite_shape_graph,
153184
skolemize_shapes,
154185
)
155186

156187
graph = copy_graph(compiled_graph)
157-
for shape_collection in shape_collections:
158-
graph += shape_collection.resolve_imports(
159-
error_on_missing_imports=error_on_missing_imports
160-
).graph
188+
graph += _resolved_shape_graph(
189+
shape_collections, error_on_missing_imports, resolved_shapes
190+
)
161191

162192
graph = rewrite_shape_graph(graph)
163193
graph.remove((None, OWL.imports, None))
@@ -170,9 +200,13 @@ def validate_compiled_model(
170200
compiled_graph: Graph,
171201
shape_collections: List["ShapeCollection"],
172202
error_on_missing_imports: bool = True,
203+
resolved_shapes: Optional[Graph] = None,
173204
) -> Tuple[ValidationResult, Graph]:
174205
graphs = self.validation_graphs(
175-
compiled_graph, shape_collections, error_on_missing_imports
206+
compiled_graph,
207+
shape_collections,
208+
error_on_missing_imports,
209+
resolved_shapes=resolved_shapes,
176210
)
177211
return (
178212
self.validate(graphs.data_graph, graphs.shape_graph),
@@ -303,17 +337,16 @@ def validation_graphs(
303337
compiled_graph: Graph,
304338
shape_collections: List["ShapeCollection"],
305339
error_on_missing_imports: bool = True,
340+
resolved_shapes: Optional[Graph] = None,
306341
) -> ValidationGraphs:
307342
# As in compile_model_graph, the shapes and data are handed to shifty
308343
# un-skolemized and un-rewritten (no sh:node inlining): shifty consumes
309344
# the native SHACL algebra directly rather than a flattened shape graph.
310345
from buildingmotif.utils import copy_graph
311346

312-
shape_graph = Graph()
313-
for shape_collection in shape_collections:
314-
shape_graph += shape_collection.resolve_imports(
315-
error_on_missing_imports=error_on_missing_imports
316-
).graph
347+
shape_graph = _resolved_shape_graph(
348+
shape_collections, error_on_missing_imports, resolved_shapes
349+
)
317350

318351
data_graph = copy_graph(compiled_graph)
319352
return ValidationGraphs(data_graph, shape_graph, shape_graph)

docs/explanations/manifests.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,43 @@ what it cannot resolve instead of raising.
8585

8686
`model.manifest.shape_collections()` is the list `validate()` and `compile()` use: the
8787
shape collection of each member library, in name order. The manifest's own graph is *not*
88-
in that list — it holds imports, not shapes. Each member's own `owl:imports` are resolved
89-
as they always were, through OntoEnv, at validation time.
88+
in that list — it holds imports, not shapes.
89+
90+
Resolving those members' own `owl:imports` is **one OntoEnv closure rooted at the
91+
manifest**, not one resolution per member. This falls out of the storage format: a manifest
92+
is an ontology whose `owl:imports` name every member, so registering it with OntoEnv
93+
(`manifest.register()`, done automatically) makes "every graph this model is checked
94+
against" a single `closure` call — already transitive, already deduplicated.
95+
`manifest.imports_closure()` is that graph.
96+
97+
Measured on a Brick fixture + `shape1.ttl` (4,832-triple closure, 5 reps, warm):
98+
99+
| manifest | one closure | per-collection resolve | speedup |
100+
|---|---|---|---|
101+
| 1 member | 0.080s | 0.146s | 1.8x |
102+
| 2 members | 0.077s | 0.248s | 3.2x |
103+
| 4 members | 0.082s | 0.390s | 4.8x |
104+
105+
The closure's cost is flat in the number of members; the per-collection path is linear,
106+
because each member re-resolves imports the others may share. Registering the manifest
107+
costs ~3.7ms and happens on each call, so a manifest edited between validations is never
108+
validated against its old membership.
109+
110+
Two things are deliberate here:
111+
112+
- **Each member is taken from exactly one source.** For a member OntoEnv knows, the closure
113+
supplies it; for one it does not — a directory-loaded library, or anything built with
114+
`Library.create` — the library's own shape collection is unioned in instead. Never both:
115+
OntoEnv's copy and the library's shape collection hold the same triples but relabel blank
116+
nodes, so unioning them would duplicate every SHACL property shape and report each
117+
violation twice.
118+
- **Inference input is unchanged.** `compile()` still runs inference against the member
119+
shape collections themselves, not the closure. What a model infers from should be the
120+
shapes it was compiled against; pulling every transitively imported ontology into the
121+
inference input would change what lands in every compiled model.
122+
123+
An explicit list — `model.validate([sc1, sc2])` — has no manifest to root a closure at, so
124+
it resolves each collection's imports as before.
90125

91126
Passing shape collections explicitly still bypasses the manifest entirely:
92127
`model.validate([sc1, sc2])` checks against exactly those. To validate against the

0 commit comments

Comments
 (0)