From 241ff8e481d4191d7a87713179be78763e860dd9 Mon Sep 17 00:00:00 2001 From: 13g4d0 <13g4d0@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:55:45 -0400 Subject: [PATCH 1/2] fix(ingest): read JSON-LD named graphs in OntologyIngestor (#1129) A JSON-LD document with a top-level `@id` *and* `@graph` places its terms in a named graph. `rdflib.Graph.parse()` loads only the default graph and discards the rest without raising, so every class and property in such a document was dropped while the load reported success. `OntologyIngestor.ingest_ontology` now parses into a `Dataset` and flattens the quads into the working `Graph`, keeping both the default and the named graphs. This is the same `Graph` -> `Dataset` migration #757 made for `JenaStore` (#756); the ingest path was not covered by it. Measured on the 12-line reproduction from the issue: before classes=0 properties=0 after classes=2 properties=0 On a real ontology the gap is larger: 25 triples / 1 subject against 719 / 88 for the document that surfaced this. Tests: `tests/ingest/test_ontology_named_graph.py` covers the named-graph document, keeps a canary on the default-graph document so the fix cannot trade one blind spot for another, and asserts that the reported result matches the terms returned. Reverting `Dataset()` to `Graph()` turns all four red. `tests/ontology`, `tests/export` and `tests/ingest` pass apart from six failures in web/feed/database/API ingestion, unrelated to this change and failing the same way on an unmodified checkout. Not included, and happy to add here or as a follow-up: making a load that yields zero classes stop returning `status: "success"`. That value is what made this take an afternoon to find, but it is a behaviour change on a different layer and seemed worth reviewing on its own. --- semantica/ingest/ontology_ingestor.py | 22 ++++-- tests/ingest/test_ontology_named_graph.py | 92 +++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 tests/ingest/test_ontology_named_graph.py diff --git a/semantica/ingest/ontology_ingestor.py b/semantica/ingest/ontology_ingestor.py index 3b8638f9..1268b3db 100644 --- a/semantica/ingest/ontology_ingestor.py +++ b/semantica/ingest/ontology_ingestor.py @@ -40,7 +40,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union import rdflib -from rdflib import RDF, RDFS, OWL, Graph +from rdflib import RDF, RDFS, OWL, Dataset, Graph from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger @@ -106,15 +106,21 @@ class OntologyIngestor: raise ValidationError(f"File not found: {file_path}") self.progress.update_tracking(tracking_id, message="Parsing RDF graph...") - g = Graph() - + # `Dataset`, not `Graph`: a JSON-LD document with a top-level `@id` *and* + # `@graph` places its terms in a NAMED graph. `Graph.parse()` loads only the + # default graph and discards the rest without an error, so every class and + # property in such a document was dropped while the load reported success. + # Parsing into a Dataset and flattening the quads keeps both. Same migration + # #757 made for JenaStore; the ingest path was not covered by it. + ds = Dataset() + # Use provided format or let rdflib guess based on extension parse_kwargs = kwargs.copy() if format: parse_kwargs['format'] = format - + try: - g.parse(file_path, **parse_kwargs) + ds.parse(file_path, **parse_kwargs) except Exception as e: # Fallback: try to guess format from extension if not provided and initial parse failed if not format: @@ -130,12 +136,16 @@ class OntologyIngestor: guessed_fmt = fmt_map.get(ext) if guessed_fmt: self.logger.info(f"Retrying with guessed format: {guessed_fmt}") - g.parse(file_path, format=guessed_fmt, **kwargs) + ds.parse(file_path, format=guessed_fmt, **kwargs) else: raise e else: raise e + g = Graph() + for subject, predicate, obj, _context in ds.quads((None, None, None, None)): + g.add((subject, predicate, obj)) + self.progress.update_tracking(tracking_id, message="Converting to internal format...") # Determine format for metadata diff --git a/tests/ingest/test_ontology_named_graph.py b/tests/ingest/test_ontology_named_graph.py new file mode 100644 index 00000000..baa8dea9 --- /dev/null +++ b/tests/ingest/test_ontology_named_graph.py @@ -0,0 +1,92 @@ +"""A JSON-LD ontology whose terms live in a named graph must not be silently dropped. + +A JSON-LD document with a top-level ``@id`` *and* ``@graph`` places its terms in a NAMED +graph. ``rdflib.Graph.parse()`` loads only the default graph and discards the rest without +raising, so every class and property in such a document disappeared while the load reported +success — see issue #1129 for the reproduction through the public API. + +This is the same ``Graph`` -> ``Dataset`` migration #757 made for ``JenaStore`` (#756); the +ingest path was not covered by it. +""" + +from __future__ import annotations + +import json + +import pytest + +from semantica.ingest.ontology_ingestor import OntologyIngestor + +NAMED_GRAPH_ONTOLOGY = { + "@context": { + "ex": "https://example.org/ns#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + }, + "@id": "https://example.org/ns", + "@type": "owl:Ontology", + "@graph": [ + {"@id": "ex:Thing", "@type": "owl:Class", "rdfs:label": "Thing"}, + {"@id": "ex:Other", "@type": "owl:Class", "rdfs:label": "Other"}, + { + "@id": "ex:relatesTo", + "@type": "owl:ObjectProperty", + "rdfs:domain": {"@id": "ex:Thing"}, + "rdfs:range": {"@id": "ex:Other"}, + }, + ], +} + +DEFAULT_GRAPH_ONTOLOGY = { + "@context": NAMED_GRAPH_ONTOLOGY["@context"], + "@graph": [ + {"@id": "ex:Thing", "@type": "owl:Class", "rdfs:label": "Thing"}, + {"@id": "ex:Other", "@type": "owl:Class", "rdfs:label": "Other"}, + ], +} + + +def _write(tmp_path, name, document): + path = tmp_path / name + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def test_terms_in_a_named_graph_are_ingested(tmp_path): + """The regression: two classes and one object property, all inside the named graph.""" + path = _write(tmp_path, "named.jsonld", NAMED_GRAPH_ONTOLOGY) + + data = OntologyIngestor().ingest_ontology(path).data + + assert len(data["classes"]) == 2, ( + "classes inside a JSON-LD named graph were dropped; the ingestor is reading only " + "the default graph" + ) + assert len(data["properties"]) == 1 + assert {c["uri"] for c in data["classes"]} == { + "https://example.org/ns#Thing", + "https://example.org/ns#Other", + } + + +def test_terms_in_the_default_graph_still_work(tmp_path): + """Canary for the test above: a document *without* a top-level ``@id`` keeps its terms + in the default graph and always parsed correctly. If this stopped passing, the fix would + have traded one blind spot for another.""" + path = _write(tmp_path, "default.jsonld", DEFAULT_GRAPH_ONTOLOGY) + + data = OntologyIngestor().ingest_ontology(path).data + + assert len(data["classes"]) == 2 + + +@pytest.mark.parametrize("document", [NAMED_GRAPH_ONTOLOGY, DEFAULT_GRAPH_ONTOLOGY]) +def test_metadata_reports_what_was_actually_read(tmp_path, document): + """Whatever the shape of the document, the counts reported have to match the terms + returned — a load that says it succeeded while returning nothing is what made #1129 + cost an afternoon to find.""" + path = _write(tmp_path, "any.jsonld", document) + + result = OntologyIngestor().ingest_ontology(path) + + assert result.data["classes"], "reported success with zero classes" From d05ef9d09f79df927d2856e94c96058d85e8ce92 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 25 Aug 2026 16:36:52 +0530 Subject: [PATCH 2/2] fix(ingest): avoid copying every quad into a second Graph in OntologyIngestor Dataset(default_union=True) presents triples from every named graph as a single merged view and is itself an rdflib.Graph subclass, so it satisfies _convert_to_dict()'s Graph-typed contract directly. Drops the O(n) manual quad-copy loop while keeping the same named-graph fix and behavior. --- semantica/ingest/ontology_ingestor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/semantica/ingest/ontology_ingestor.py b/semantica/ingest/ontology_ingestor.py index 1268b3db..0d63a770 100644 --- a/semantica/ingest/ontology_ingestor.py +++ b/semantica/ingest/ontology_ingestor.py @@ -110,9 +110,12 @@ class OntologyIngestor: # `@graph` places its terms in a NAMED graph. `Graph.parse()` loads only the # default graph and discards the rest without an error, so every class and # property in such a document was dropped while the load reported success. - # Parsing into a Dataset and flattening the quads keeps both. Same migration - # #757 made for JenaStore; the ingest path was not covered by it. - ds = Dataset() + # Same migration #757 made for JenaStore; the ingest path was not covered by it. + # `default_union=True` makes the Dataset itself present triples from every + # graph as one merged view (it is an rdflib.Graph subclass, so it satisfies + # _convert_to_dict()'s Graph-typed contract directly) instead of copying every + # quad into a second in-memory Graph. + ds = Dataset(default_union=True) # Use provided format or let rdflib guess based on extension parse_kwargs = kwargs.copy() @@ -142,9 +145,7 @@ class OntologyIngestor: else: raise e - g = Graph() - for subject, predicate, obj, _context in ds.quads((None, None, None, None)): - g.add((subject, predicate, obj)) + g = ds self.progress.update_tracking(tracking_id, message="Converting to internal format...")