Skip to content

Replace graph store with Oxigraph + OntoEnv 0.6 - #396

Open
gtfierro wants to merge 18 commits into
developfrom
gtf-ontoenv
Open

Replace graph store with Oxigraph + OntoEnv 0.6#396
gtfierro wants to merge 18 commits into
developfrom
gtf-ontoenv

Conversation

@gtfierro

@gtfierro gtfierro commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Swaps out the old rdflib-sqlalchemy graph store, which kept triples inside the SQL database and was slow because every SPARQL query became a pile of joins. Triples now live in Oxigraph (via oxrdflib) with real triple indexes, and metadata stays in SQLAlchemy. The two stores are linked by an opaque graph_id pointer per row. There's a new explanations page (docs/explanations/storage-architecture.md) that walks through the whole design if you want the details.

  • Replaced rdflib-sqlalchemy with oxrdflib, meaning faster SPARQL queries
  • Load ontology files through Oxigraph's native Rust loader (GraphConnection.load_file_into_graph) — ~11x faster on Brick.ttl (0.80s → 0.07s), falls back to Graph.parse if native loading can't handle a file, so it's never worse than before. Currently only used for trusted on-disk RDF.
  • Integrated OntoEnv for owl:imports resolution. Library.load, ShapeCollection.resolve_imports, and infer_templates all delegate to it instead of the old hand-rolled recursive resolver, and imported ontologies share the same Oxigraph store
  • Copy-on-write graph replacement: Model.replace_graph / ShapeCollection.replace_graph write into a fresh graph_id and flip the SQL pointer last, so a crash or session.rollback() leaves the old graph intact instead of a half-written one. Replaces the manual cleanup in Library.load and the remove-then-add stuff in the PUT endpoint. Also opens the path for doing versioned models down the road
  • BuildingMOTIF.collect_graph_garbage reclaims orphaned graphs left behind by COW and deletes. Runs on close().
  • Bumped the Python minimum to 3.11 (and GH Actions / CI / Dockerfiles to match), added --graph-store-path to the load/serve CLI subcommands, and moved ontology loading out of pytest collection into session fixtures so --collect-only goes from minutes to under 1s.
  • Fixed the test/notebooks that broke as a result of the change

One major change: the graph store is now a separate on-disk artifact from the SQLite metadata DB, and existing rdflib-sqlalchemy databases aren't migrated by this PR.

OntoEnv 0.6

The OntoEnv pin moved 0.6.0-a50.6.0 over the last five commits, following its 0.5 → 0.6 migration. All of it is confined to buildingmotif/ontology_environment.py, the one place BuildingMOTIF talks to OntoEnv, apart from one exception type in shape_collection.py.

  • Lifecycle entry points instead of flags. create_or_use_cached=True and init_from_store=True are both deprecated in 0.6. Persistent environments now use OntoEnv.connect(path, graph_store=...); temporary ones use OntoEnv(graph_store=..., temporary=True) plus an explicit refresh_from_store(full=True), since there's no saved index for a temporary env to reuse. Not adopt, despite it being the method the deprecation names — adopt always persists an index, which fits neither case. This also removed ~470 deprecation warnings per test run (one per BuildingMOTIF()).
  • Stop materializing closures we throw away. Two call sites did _, names = copy_closure(...) — building the entire imports closure across the FFI boundary just to keep a list of names. Now OntologyEnvironment.closure_names, wrapping list_closure. On the Brick 1.4 closure (15 graphs, ~155k triples): list_closure is free where copy_closure costs ~4s and get_closure ~2.4s, all three returning the same 15 names.
  • Persistent ontology_cache_path works for the first time. It was never usable: the old code passed graph_store and create_or_use_cached=True together, which OntoEnv rejects outright. Only the temporary path is exercised by the suite (ontology_cache_path defaults to None), which is why this stayed invisible.
  • Interrupted-cache recovery. .ontoenv/catalog.pending marks an interrupted write, and finding it at open time raises CatalogRecoveryError. _connect_recovering calls OntoEnv.recover(path, graph_store=...), logging a warning first because recovery rescans every stored graph. It's automatic rather than surfaced to the caller because the documented fix isn't reachable from outside: recovering a custom store means passing that store, and BuildingMOTIF's doesn't exist until a BuildingMOTIF has been constructed — which is what just failed. OntoEnv clears the marker only after publishing the replacement index, so a failed recovery still raises. Verified by planting a marker on a populated cache: warns, rebuilds, ontologies intact, marker cleared, next open clean.
  • Unresolved imports are typed. 0.6 raises UnresolvedImportError (a LookupError) for every unresolved owl:imports target, so infer_templates catches that instead of every Exception when collecting per-dependency graphs — a storage error or malformed IRI now propagates rather than being logged as a missing dependency and skipped. Re-exported from ontology_environment so callers don't import ontoenv directly.
  • Container protocol. ontology in env replaces building the full name list to test one membership, and is broader: it resolves aliases and source URLs as well as canonical names, so an ontology known under another spelling isn't needlessly re-added.

get_closure/get_union return a read-only ViewGraph in 0.6 that deliberately doesn't subclass rdflib.Graph, so the paths that hand a mutable graph back to callers still use copy_*. Nothing in BuildingMOTIF's own API changed.

The pin is a range (^0.6.0) rather than the exact pin the alphas needed. Unit suite is unchanged across the whole migration: 535 passed, 1 skipped.

gtfierro added 12 commits June 19, 2026 10:58
Wire Library.load(ontology_graph=...) through OntologyEnvironment so
owl:imports are fetched by OntoEnv and stored in BuildingMOTIF's
Oxigraph graph store rather than resolved manually. ShapeCollection
resolve_imports and infer_templates now delegate to OntoEnv as well,
removing the old recursive _resolve_imports helper.
pytest_generate_tests was calling setup_building_motif_brick/s223 at
collection time, causing Brick.ttl, 223p.ttl, and all QUDT dependencies
to be loaded into an in-memory SQLite DB just for --collect-only.

Replace with cheap YAML-key + rdflib-local-parse extraction at collection
time (~0.5s), and move the full Library.load into session-scoped fixtures
that run once during test execution. Collection time drops from minutes to
under 1s.
…ntoenv

- Add init_from_store=True to OntologyEnvironment so libraries already
  persisted in Oxigraph are visible to ontoenv import resolution on restart
- Remove xsd:string-stripping branch from _decode_term; RDF 1.1 treats plain
  strings and xsd:string as equal, and the strip made triples() and query()
  return incompatible objects for the same stored value
- Fix false-positive OntologyImportsNotFound in resolve_imports fallback path:
  track whether the fallback was used and check missing_imports against
  self.graph instead of graph_name to avoid index-based false positives
- Add warning log to resolve_imports except-branch to match infer_templates
- Wrap Oxigraph graph write in try/except in _load_from_ontology; on failure,
  clears any partial write and rolls back the SQL session for consistency
- Forward infer_templates and run_shacl_inference through _load_from_directory
  to _load_shapes_from_directory (flags were silently ignored before)
- Add try/finally to BuildingMOTIF.close() so all resources are released even
  if an earlier close step raises
- Extract OntologyEnvironment.ensure_and_get_closure() helper to remove the
  duplicated ensure-registered+closure_copy pattern from resolve_imports and
  infer_templates
- Update CLI docs to document --graph-store-path on load and serve subcommands
- Update developer docs Python minimum version (3.10 -> 3.11)
Replace in-place graph mutation with copy-on-write. Model.replace_graph
and ShapeCollection.replace_graph write new contents into a fresh
graph_id and flip the SQL pointer, so a failure or session rollback
leaves the previous graph intact instead of leaving a half-written graph.
This removes the hand-rolled cleanup in Library.load and fixes the
remove-then-add hazard in the model PUT endpoint.

Add BuildingMOTIF.collect_graph_garbage to reclaim orphaned named graphs
left by replacement and by row deletion. Only UUID-keyed graphs (models,
shape collections, template bodies) are considered, so OntoEnv's
IRI-keyed ontology graphs are excluded by construction. Runs best-effort
on close().

Document the dual-store backend design (graph_id pointer, consistency
model, copy-on-write, garbage collection) in a new explanations page.
Add GraphConnection.load_file_into_graph, which parses RDF files directly
into a named graph using Oxigraph's native (Rust) loader instead of
rdflib's per-triple Python path. On Brick.ttl (~54k triples) this is ~11x
faster (0.80s -> 0.07s) and produces an identical stored graph. The
library directory loader now uses it.

Native loading is used only for trusted on-disk RDF (the native parser
requires valid IRIs, unlike in-memory graphs that may carry generated
template parameters), does not propagate file prefixes (callers re-bind
the standard prefixes), and falls back to rdflib.parse if a file cannot
be loaded natively, so behavior is never worse than before.
- test_database_persistence: snapshot store-backed graphs before bm.close()
  (Oxigraph deletes _store on close, making live views inaccessible); call
  BuildingMOTIF.clean() before reopening so the singleton is fresh; filter
  bare UUID graph identifiers out of BuildingMOTIFGraphStore.graph_ids() so
  OntoEnv's init_from_store does not choke on non-IRI names
- dataclasses: add field(compare=False) to _bm and graph fields on Library,
  Template, Model, and ShapeCollection so __eq__ compares identity (id/name)
  rather than session pointers or graph object identity
- notebooks/Session-example.ipynb: replace dead SQL table queries
  (kb_625d302a74_type_statements) with RDFLib graph API; graph data is now
  in Oxigraph, not the SQLite relational store
- notebooks/223P-Validation.ipynb: fix relative path to 223p.ttl
  (notebooks/ runs as cwd, so ../libraries/... is correct)
In ontoenv 0.6.x, copy_closure and iter_closure_triples read from the
native Rust store and bypass the custom graph_store adapter. When
BuildingMOTIF's BuildingMOTIFGraphStore is in use, this returns empty
graphs/iterators even though the ontology data is correctly stored.
get_closure returns a ClosureGraphView that correctly routes through the
adapter.

Also bumps the BACnet integration test Dockerfile from python:3.10 to
3.11, matching the pyproject.toml minimum.
@gtfierro

Copy link
Copy Markdown
Collaborator Author

@TShapinsky @MatthewSteen I'm in the middle of making some major changes to buildingmotif to use new tooling I have been developing. This will help with performance issues, which in my view have been a significant challenge to using bmotif.

gtfierro added 6 commits July 23, 2026 10:36
_load_shapes_from_directory loaded every file in a library directory
straight into the shape collection's graph via Oxigraph's native
loader, but never told OntoEnv these ontologies exist. Any owl:imports
referencing them - e.g. mediumOffice_constraints.ttl importing
urn:ashrae/g36, which guideline36.ttl declares - couldn't be resolved
by name, so OntoEnv fell back to treating the import as a relative
file path and failed with "No such file or directory".

Register each file with the ontology environment alongside the
existing bulk graph load. Files without their own owl:Ontology header
(guideline36's per-equipment fragments) just get a harmless synthetic
name; files that do declare an identity (Brick's imports/*.ttl, which
import each other) become resolvable the same way single-file
Library.load(ontology_graph=...) already registers them.

Verified: without this change, missing_imports() on a graph importing
urn:ashrae/g36 reports it as missing after loading the guideline36
directory; with it, the import resolves.
Bumps the pin from 0.6.0-a5. None of a8's breaking changes touch the API
BuildingMOTIF uses: get_graph() becoming a read-only view does not matter
because we only ever call copy_graph, we call neither snapshot_as_dataset nor
as_dataset, and we import none of the removed top-level helpers. poetry lock
resolved with no other dependency movement.

The bump also makes a fix worth taking. Two of the three closure call sites
read `_, closure_names = closure_copy(...)` -- materializing the entire imports
closure into an rdflib graph, one term at a time across the FFI boundary, and
then discarding it to keep a list of names. Both now call the new
OntologyEnvironment.closure_names, which wraps ontoenv's list_closure.

Measured on the Brick 1.4 closure (15 graphs, ~155k triples), three reps:

  list_closure   0.000s  0.000s  0.000s
  copy_closure   4.271s  3.879s  5.322s   (materializes every call)
  get_closure    8.846s  0.000s  0.000s   (eager permutation indexes, then free)

All three report the same 15 names. So this is ~4-5s saved per ontology load
that follows imports, and library loading does it for every ontology.

get_closure -- a8's read-only zero-copy view -- was the obvious-looking fix and
is the wrong one here: it builds all four permutation indexes eagerly at bind
time, which pays off for repeated queries against a closure but is pure
overhead for a one-shot name lookup. closure_copy remains in use at
ontology_environment.py:133, whose caller gets a graph it may mutate; a
ViewGraph would raise ValueError there.

Note for anyone reading ontoenv's changelog against this pin: the
`get_closure_view` described under [Unreleased] does not exist in a8. That
section is ahead of the published alphas, and a8 spells the read-only view
`get_closure`.
…tore

a8 deprecates the `init_from_store` flag: "use OntoEnv.adopt(...) for
first-time adoption". BuildingMOTIF passes it on every construction that has a
graph_connection, which is all of them, so the unit suite went from 58 warnings
to 527 -- one per BuildingMOTIF built.

The replacement is not a single call, because the two cases genuinely differ:

- persistent (a cache path): OntoEnv.connect(path, graph_store=...). connect is
  by definition "create it, or reuse the saved index", which is what the old
  create_or_use_cached meant, and it subsumes init_from_store too -- sync="auto"
  reads graph contents only when it first meets a populated store or when the
  store reports a change.
- temporary (no path): there is no saved index to reuse, so a populated store
  still has to be scanned outright, via refresh_from_store(full=True). That is
  what init_from_store was doing at construction time.

`adopt` -- the method the warning names -- is the deliberate first-time scan of
a pre-populated store and always persists an index, so it fits neither branch:
the temporary case has nowhere to persist to, and the persistent case wants
reuse rather than re-adoption on every open.

No behaviour change intended; the unit suite is the check, since `path=None`
with a graph store is the branch every test exercises.
Pin 0.6.0-a8 -> 0.6.0-a9. a9's breaking changes do not reach BuildingMOTIF:
get_closure/get_union now return a read-only ViewGraph, but we call
copy_closure and copy_graph; snapshot_as_dataset/to_rdflib_dataset are
deprecated in favour of get_dataset/copy_dataset, and we call none of them;
update(all=) is superseded by update(force=), and we never call update.

Membership now goes through the container protocol. `ensure_and_get_closure`
tested `graph_name not in self.ontology_names()`, which materializes every
ontology name to answer one question; `knows()` wraps `ontology in self.env`.
It is also broader in a useful way -- `in` resolves aliases and source URLs as
well as canonical names, so an ontology known under another spelling is no
longer needlessly re-added. Verified equivalent on the canonical-name case:
sorted(get_ontology_names()) == sorted(list(env)).

Re-measured the closure paths on a9, since the docstring quoted a8 numbers and
one of them changed shape (Brick 1.4 closure, 15 graphs, ~155k triples, x3):

  list_closure   0.000s  0.000s  0.000s
  copy_closure   3.972s  3.530s  3.623s
  get_closure    2.053s  2.077s  2.059s

On a8 get_closure read 8.846s / 0.000s / 0.000s -- one eager index build then
cached. On a9 it is a flat ~2.05s: cheaper on first use, no longer free on
repeat. list_closure remains the right call for names either way.

Also recorded, because it is load-bearing for `ensure_and_get_closure`'s return
type and is easy to assume otherwise: a ViewGraph is **not** an rdflib.Graph
subclass (isinstance check: False, type ViewGraph), so it cannot be returned
through a signature promising rdflib.Graph.

The persistent-cache defect is NOT fixed by a9: with a custom graph_store,
.ontoenv/catalog.pending still survives a clean close and the next open raises
CatalogRecoveryError. Reproduced on a8 and a9 alike.
A persistent ontology cache could be opened exactly once. The second open
raised CatalogRecoveryError and there is no way back: ontoenv exposes no
recovery API, so the cache was permanently unusable.

Root cause, traced by wrapping each OntoEnv method and watching the marker
file: ontoenv writes .ontoenv/catalog.pending when a batched mutation begins
and removes it only when the batch finishes cleanly (BatchScope::run in
lib/src/api.rs; its Drop impl does not remove it either). An unresolvable
owl:imports makes the batch end in error, so the marker survives.

That is the normal case here, not an edge case. Loading Brick calls
import_dependencies, which raises ValueError("Failed to resolve graph for URI")
for each of eight imports Brick declares and that do not resolve offline --
brickschema 1.3, qudt unit and quantitykind, ashrae bacnet/2020, Brick/ref,
rec/brickpatches, rec/recimports, datashapes dash. Several 404 even online.
BuildingMOTIF tolerates missing imports and swallows those errors, so the load
"succeeds" while the catalog is left marked. An ontology declaring no imports
leaves no marker, which is how this was isolated.

So `connect` now clears a marker it finds and retries once, with a warning
naming the file. This is a workaround for an upstream defect and is marked as
such: it trades away the marker's value after a genuine crash mid-write, which
is the reason it warns rather than doing it silently, and only retries once.

Verified: load Brick into a persistent cache, close, reopen (warns, succeeds,
ontology intact), close, open a third time cleanly.

The principled fix belongs upstream -- a tolerable non-strict import failure
should not leave an interrupted-mutation marker, since non-strict mode is
documented as best-effort. Failing that, ontoenv should expose a recovery call
so consumers do not have to delete files it owns.
Pin 0.6.0-a9 -> ^0.6.0, the first stable 0.6 release. A caret range rather
than the exact pin the alphas needed: 0.6.0 is a published release with a
migration guide, so patch fixes are wanted, not feared.

0.6.0 closes the two upstream defects this branch had been working around,
and both workarounds go with it.

Deleting .ontoenv/catalog.pending by hand is gone. That existed because a
merely unresolvable owl:imports left the interrupted-mutation marker behind,
which made a single Library.load(ontology_graph=Brick) poison a persistent
cache permanently -- routine here, not exceptional, since Brick declares
imports that 404 even online. In 0.6.0 a tolerated missing import commits
cleanly and leaves no marker, so reaching that handler now means an actually
interrupted write. Verified: load Brick into a persistent cache, close, and
the marker is absent; opens #2 and #3 both succeed with the ontology intact.

_connect_recovering therefore stops removing a file ontoenv owns -- which
0.6's docs now explicitly warn against -- and calls OntoEnv.recover(path,
graph_store=...) instead, the supported rebuild. It is taken automatically
rather than surfaced, because the documented answer is unreachable for our
callers: recovering a custom store requires passing that store, and ours does
not exist until a BuildingMOTIF has been constructed, which is the thing that
just failed. Recovery rescans every stored graph, so it warns; ontoenv only
clears the marker once the replacement index is published, so a failed
recovery still raises.

Shape-collection dependency collection stops swallowing every exception.
0.6.0 raises UnresolvedImportError (a LookupError) for every unresolved
owl:imports target -- confirmed 6 of 6 on the Brick closure, where earlier
builds left some as bare ValueError -- so the expected case can be caught
precisely and a storage error or malformed IRI now propagates instead of
being logged as a missing dependency and skipped. The exception is
re-exported from ontology_environment so ontoenv stays behind that seam.

No API of ours changed. get_closure/get_union still return a ViewGraph that
does not subclass rdflib.Graph, so the copy in ensure_and_get_closure stays;
closure_names' benchmark is re-measured on 0.6.0 (list_closure free,
copy_closure ~4.2s, get_closure ~2.4s over Brick's 15-graph, 155k-triple
closure) and its conclusion is unchanged. No new deprecation warnings: the
create_or_use_cached and init_from_store transitions were done on a8.
@gtfierro gtfierro changed the title Replacing graph store with oxigraph+ontoenv Replace graph store with Oxigraph + OntoEnv 0.6 Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant