`` closed the reference early and let the
+ rest of the string be parsed as an unrelated, attacker-chosen triple.
+ """
+ payload = (
+ "https://evil.example/x> . "
+ " ' โ cover the control-character half of
+ the grammar, not only the delimiter characters.
+ """
+ payload = "https://evil.example/x\ninjected line\ttabbed"
+ data = {
+ "entities": [
+ {"id": ENTITY_IRI, "text": "Acme", "metadata": {"uri": payload}}
+ ],
+ "relationships": [],
+ }
+ g = _serialize(RDFSerializer(), fmt, data)
+ assert len(g) == 4
diff --git a/tests/export/test_owl_time_reachability.py b/tests/export/test_owl_time_reachability.py
index 92473390..e83339f7 100644
--- a/tests/export/test_owl_time_reachability.py
+++ b/tests/export/test_owl_time_reachability.py
@@ -187,3 +187,59 @@ def test_the_reified_type_matches_the_direct_triples_predicate():
assert set(graph.objects(node, URIRef(NS + "type"))) == {Literal(EMPLOYS)}
assert (URIRef(E1), URIRef(EMPLOYS), URIRef(E2)) in graph
+
+
+# โโ Qodo review: temporal bounds are str-only at the escape helper โโโโโโโโโ
+
+def test_datetime_bounds_do_not_crash_the_turtle_export():
+ """_escape_literal is str-only; datetime bounds must be stringified, not
+ run through .replace(). Regression for Qodo high-priority finding #2 on
+ PR #1221.
+
+ Also asserts the lexical form: xsd:dateTimeStamp requires an ISO 8601 "T"
+ separator (e.g. 2024-01-01T00:00:00+00:00). plain str() emits a space
+ ("2024-01-01 00:00:00+00:00"), which is format-invalid; isoformat() fixes
+ it. Regression for the maintainer review on PR #1221."""
+ from datetime import datetime, timezone
+
+ kg = {
+ "entities": [dict(e) for e in KG["entities"]],
+ "relationships": [
+ {
+ "source_id": E1,
+ "target_id": E2,
+ "type": EMPLOYS,
+ "valid_from": datetime(2024, 1, 1, tzinfo=timezone.utc),
+ "valid_until": datetime(2025, 1, 1, tzinfo=timezone.utc),
+ }
+ ],
+ }
+ turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True)
+ graph = Graph()
+ graph.parse(data=turtle, format="turtle")
+ stamps = {
+ str(o) for o in graph.objects(None, URIRef(TIME + "inXSDDateTimeStamp"))
+ }
+ assert len(stamps) == 2, stamps
+ assert "2024-01-01T00:00:00+00:00" in stamps, stamps
+ assert "2025-01-01T00:00:00+00:00" in stamps, stamps
+
+
+def test_end_only_interval_does_not_crash_the_turtle_export():
+ """A valid_until bound with no valid_from passes None as from_val; it must
+ not be handed to the str-only escaper. Regression for Qodo finding #2."""
+ kg = {
+ "entities": [dict(e) for e in KG["entities"]],
+ "relationships": [
+ {
+ "source_id": E1,
+ "target_id": E2,
+ "type": EMPLOYS,
+ "valid_until": "2025-01-01T00:00:00Z",
+ }
+ ],
+ }
+ turtle = RDFSerializer().serialize_to_turtle(kg, include_temporal=True)
+ graph = Graph()
+ graph.parse(data=turtle, format="turtle")
+ assert list(graph.subjects(RDF.type, URIRef(TIME + "Instant")))
diff --git a/tests/export/test_rdf_exporter_turtle_iris.py b/tests/export/test_rdf_exporter_turtle_iris.py
new file mode 100644
index 00000000..bc197a08
--- /dev/null
+++ b/tests/export/test_rdf_exporter_turtle_iris.py
@@ -0,0 +1,233 @@
+"""Regression tests for valid Turtle IRI generation (issue #1099)."""
+
+from rdflib import RDF, Graph, URIRef
+
+from semantica.export import RDFExporter
+from semantica.kg.graph_builder import GraphBuilder
+
+
+def test_turtle_normalizes_graph_builder_default_identifiers():
+ """Default GraphBuilder labels with spaces become stable absolute IRIs."""
+ source = {
+ "entities": [
+ {
+ "id": "Kochi, Kerala",
+ "name": "Kochi, Kerala",
+ "type": "LOCATION",
+ },
+ {"id": "Jane Doe", "name": "Jane Doe", "type": "PERSON"},
+ ],
+ "relationships": [
+ {
+ "source": "Jane Doe",
+ "target": "Kochi, Kerala",
+ "type": "located_in",
+ },
+ ],
+ }
+ graph_data = GraphBuilder(resolve_conflicts=False).build(sources=[source])
+
+ turtle = RDFExporter().export_to_rdf(graph_data, format="turtle")
+ parsed = Graph().parse(data=turtle, format="turtle")
+ assert "" not in turtle
+ assert "" not in turtle
+
+ kochi = URIRef("https://semantica.dev/ns#Kochi%2C%20Kerala")
+ jane = URIRef("https://semantica.dev/ns#Jane%20Doe")
+ assert (
+ kochi,
+ RDF.type,
+ URIRef("https://semantica.dev/ns#LOCATION"),
+ ) in parsed
+ jane_type = URIRef("https://semantica.dev/ns#PERSON")
+ assert (jane, RDF.type, jane_type) in parsed
+ assert (
+ jane,
+ URIRef("https://semantica.dev/ns#located_in"),
+ kochi,
+ ) in parsed
+
+
+def test_turtle_preserves_absolute_iris():
+ """Already-valid absolute resource IRIs remain unchanged."""
+ turtle = RDFExporter().export_to_rdf(
+ {
+ "entities": [
+ {
+ "id": "https://example.org/entities/jane",
+ "text": "Jane",
+ "type": "urn:example:Person",
+ }
+ ],
+ "relationships": [],
+ },
+ format="turtle",
+ )
+ parsed = Graph().parse(data=turtle, format="turtle")
+
+ assert (
+ URIRef("https://example.org/entities/jane"),
+ RDF.type,
+ URIRef("urn:example:Person"),
+ ) in parsed
+
+
+def test_turtle_preserves_opaque_absolute_iris_and_encodes_bad_percent_escapes():
+ """Opaque schemes remain absolute and malformed percent escapes are encoded."""
+ turtle = RDFExporter().export_to_rdf(
+ {
+ "entities": [
+ {"id": "mailto:foo", "type": "isbn:0451450523"},
+ {"id": "http://example.org/bad%zz", "type": "PERSON"},
+ ],
+ "relationships": [],
+ },
+ format="turtle",
+ )
+ parsed = Graph().parse(data=turtle, format="turtle")
+
+ assert (
+ URIRef("mailto:foo"),
+ RDF.type,
+ URIRef("isbn:0451450523"),
+ ) in parsed
+ assert URIRef("http://example.org/bad%25zz") in parsed.all_nodes()
+
+
+def test_turtle_normalizes_temporal_relationship_endpoints():
+ """Temporal relationship metadata uses the same normalized resource IRIs."""
+ turtle = RDFExporter().export_to_rdf(
+ {
+ "entities": [
+ {"id": "Jane Doe", "type": "PERSON"},
+ {"id": "Kochi, Kerala", "type": "LOCATION"},
+ ],
+ "relationships": [
+ {
+ "source": "Jane Doe",
+ "target": "Kochi, Kerala",
+ "type": "located_in",
+ "valid_from": "2024-01-01T00:00:00+00:00",
+ "valid_until": "2024-02-01T00:00:00+00:00",
+ }
+ ],
+ },
+ format="turtle",
+ include_temporal=True,
+ )
+ parsed = Graph().parse(data=turtle, format="turtle")
+
+ assert (
+ None,
+ URIRef("https://semantica.dev/ns#source"),
+ URIRef("https://semantica.dev/ns#Jane%20Doe"),
+ ) in parsed
+ assert (
+ None,
+ URIRef("https://semantica.dev/ns#target"),
+ URIRef("https://semantica.dev/ns#Kochi%2C%20Kerala"),
+ ) in parsed
+
+
+def test_turtle_expands_context_prefixes_and_mints_relative_values():
+ """Context prefixes expand while bare values use the fallback namespace."""
+ turtle = RDFExporter().export_to_rdf(
+ {
+ "@context": {"ex": "https://example.org/"},
+ "entities": [{"id": "ORG", "type": "ex:Person"}],
+ "relationships": [],
+ },
+ format="turtle",
+ )
+ parsed = Graph().parse(data=turtle, format="turtle")
+
+ assert (
+ URIRef("https://semantica.dev/ns#ORG"),
+ RDF.type,
+ URIRef("https://example.org/Person"),
+ ) in parsed
+
+
+def test_rdfxml_normalizes_resource_iris():
+ """RDF/XML resource attributes use the same safe absolute IRIs."""
+ data = {
+ "entities": [
+ {"id": "Acme Corp", "type": "Person"},
+ {"id": "mailto:foo", "type": "isbn:0451450523"},
+ ],
+ "relationships": [
+ {"source": "Jane Doe", "target": "Acme Corp", "type": "knows"}
+ ],
+ }
+ rdfxml = RDFExporter().export_to_rdf(data, format="rdfxml")
+ parsed = Graph().parse(data=rdfxml, format="xml")
+
+ assert URIRef("https://semantica.dev/ns#Acme%20Corp") in parsed.all_nodes()
+ assert URIRef("mailto:foo") in parsed.all_nodes()
+
+
+def test_ntriples_normalizes_resource_iris():
+ """N-Triples resource IRIs reject neither spaces nor opaque schemes."""
+ data = {
+ "entities": [
+ {"id": "Acme Corp", "type": "Person"},
+ {"id": "mailto:foo", "type": "isbn:0451450523"},
+ ],
+ "relationships": [
+ {"source": "Jane Doe", "target": "Acme Corp", "type": "knows"}
+ ],
+ }
+ ntriples = RDFExporter().export_to_rdf(data, format="ntriples")
+ parsed = Graph().parse(data=ntriples, format="nt")
+
+ assert URIRef("https://semantica.dev/ns#Acme%20Corp") in parsed.all_nodes()
+ assert URIRef("mailto:foo") in parsed.all_nodes()
+
+
+def test_turtle_preserves_existing_valid_percent_escapes():
+ """A pre-encoded absolute IRI keeps its escape, instead of %20 -> %2520."""
+ turtle = RDFExporter().export_to_rdf(
+ {
+ "entities": [
+ {
+ "id": "https://example.org/entities/path%20name",
+ "type": "PERSON",
+ }
+ ],
+ "relationships": [],
+ },
+ format="turtle",
+ )
+ parsed = Graph().parse(data=turtle, format="turtle")
+
+ assert (
+ URIRef("https://example.org/entities/path%20name"),
+ RDF.type,
+ URIRef("https://semantica.dev/ns#PERSON"),
+ ) in parsed
+ assert "%2520" not in turtle
+
+
+def test_ntriples_and_rdfxml_expand_builtin_prefixes_alongside_context():
+ """A user @context must not shadow built-in prefixes like semantica:."""
+ data = {
+ "@context": {"ex": "https://example.org/"},
+ "entities": [{"id": "ORG", "type": "semantica:Entity"}],
+ "relationships": [],
+ }
+
+ ntriples = RDFExporter().export_to_rdf(data, format="ntriples")
+ nt_parsed = Graph().parse(data=ntriples, format="nt")
+ assert (
+ URIRef("https://semantica.dev/ns#ORG"),
+ RDF.type,
+ URIRef("https://semantica.dev/ns#Entity"),
+ ) in nt_parsed
+
+ rdfxml = RDFExporter().export_to_rdf(data, format="rdfxml")
+ xml_parsed = Graph().parse(data=rdfxml, format="xml")
+ assert (
+ URIRef("https://semantica.dev/ns#ORG"),
+ RDF.type,
+ URIRef("https://semantica.dev/ns#Entity"),
+ ) in xml_parsed
diff --git a/tests/export/test_rdf_literal_escaping.py b/tests/export/test_rdf_literal_escaping.py
new file mode 100644
index 00000000..8a3c2354
--- /dev/null
+++ b/tests/export/test_rdf_literal_escaping.py
@@ -0,0 +1,107 @@
+"""Regression tests for #1098: Turtle/N-Triples literal escaping."""
+import pytest
+
+from semantica.export.rdf_exporter import RDFExporter, RDFSerializer
+
+
+@pytest.fixture
+def serializer():
+ return RDFSerializer()
+
+
+class TestTurtleLiteralEscaping:
+ def test_quote_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}],
+ "relationships": [],
+ }
+ turtle = serializer.serialize_to_turtle(kg)
+ assert '"He said \\"hello\\""' in turtle
+
+ def test_backslash_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}],
+ "relationships": [],
+ }
+ turtle = serializer.serialize_to_turtle(kg)
+ assert r"path\\to\\file" in turtle
+
+ def test_newline_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}],
+ "relationships": [],
+ }
+ turtle = serializer.serialize_to_turtle(kg)
+ assert "line1\\nline2" in turtle
+
+ def test_tab_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}],
+ "relationships": [],
+ }
+ turtle = serializer.serialize_to_turtle(kg)
+ assert "a\\tb" in turtle
+
+ def test_plain_text_unchanged(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": "Apple Inc.", "type": "ORG"}],
+ "relationships": [],
+ }
+ turtle = serializer.serialize_to_turtle(kg)
+ assert 'semantica:text "Apple Inc."' in turtle
+
+
+class TestNTriplesLiteralEscaping:
+ def test_quote_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}],
+ "relationships": [],
+ }
+ ntriples = serializer.serialize_to_ntriples(kg)
+ assert '\\"hello\\"' in ntriples
+
+ def test_backslash_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}],
+ "relationships": [],
+ }
+ ntriples = serializer.serialize_to_ntriples(kg)
+ assert r"path\\to\\file" in ntriples
+
+ def test_newline_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}],
+ "relationships": [],
+ }
+ ntriples = serializer.serialize_to_ntriples(kg)
+ assert "line1\\nline2" in ntriples
+
+ def test_tab_in_text_is_escaped(self, serializer):
+ kg = {
+ "entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}],
+ "relationships": [],
+ }
+ ntriples = serializer.serialize_to_ntriples(kg)
+ assert "a\\tb" in ntriples
+
+
+class TestOWLTimeLiteralEscaping:
+ """Timestamp literals in OWL-Time turtle output must also be escaped."""
+
+ def test_owl_time_timestamps_are_escaped(self):
+ exporter = RDFExporter()
+ kg = {
+ "entities": [],
+ "relationships": [
+ {
+ "id": "r1",
+ "source_id": "a",
+ "target_id": "b",
+ "type": "works_for",
+ "valid_from": "2020-01-01T00:00:00Z",
+ "valid_until": None,
+ }
+ ],
+ }
+ turtle = exporter.export_to_rdf(kg, format="turtle", include_temporal=True)
+ assert 'time:inXSDDateTimeStamp "2020-01-01T00:00:00Z"' in turtle
\ No newline at end of file
diff --git a/tests/export/test_timestamp_timezones.py b/tests/export/test_timestamp_timezones.py
index df34dd29..b97b658e 100644
--- a/tests/export/test_timestamp_timezones.py
+++ b/tests/export/test_timestamp_timezones.py
@@ -107,20 +107,31 @@ def test_exported_timestamp_survives_a_timezone_qualified_sparql_filter():
assert len(rows) == 1, "the export was dropped by a timezone-qualified filter"
-def test_document_iri_carrying_an_offset_is_a_valid_iri():
- """The offset puts '+' and ':' in the @id; both are legal in a path."""
+def test_document_iri_is_a_valid_iri():
+ """The graph @id must be a valid IRI regardless of how it is minted.
+
+ Before #1147, this @id was minted from the offset-carrying timestamp
+ itself (``+00:00`` interpolated straight into the path), so this test
+ asserted the offset survived without breaking IRI validity. #1147 mints
+ the @id from the graph's content instead, so the timestamp no longer
+ appears here at all โ it stays in ``semantica:exportedAt`` (still
+ offset-aware, per ``test_jsonld_export_timestamp_is_offset_aware`` above).
+ What's left worth guarding is the general case: whatever the @id is
+ minted from, it has to be a valid IRI that round-trips through RDF.
+ """
rdflib = pytest.importorskip("rdflib")
document_iri = JSONExporter()._convert_kg_to_jsonld(KG)["@id"]
- assert "+00:00" in document_iri
assert rdflib.term._is_valid_uri(document_iri)
graph = rdflib.Graph()
- graph.add((
- rdflib.URIRef(document_iri),
- rdflib.RDF.type,
- rdflib.URIRef("https://semantica.dev/ns#KnowledgeGraph"),
- ))
+ graph.add(
+ (
+ rdflib.URIRef(document_iri),
+ rdflib.RDF.type,
+ rdflib.URIRef("https://semantica.dev/ns#KnowledgeGraph"),
+ )
+ )
reparsed = rdflib.Graph().parse(data=graph.serialize(format="nt"), format="nt")
assert document_iri in {str(s) for s in reparsed.subjects()}
diff --git a/tests/ingest/test_db_ingestor_query.py b/tests/ingest/test_db_ingestor_query.py
new file mode 100644
index 00000000..4c2fcfa3
--- /dev/null
+++ b/tests/ingest/test_db_ingestor_query.py
@@ -0,0 +1,112 @@
+"""Query-execution coverage for DBIngestor and DataExporter.
+
+Regression tests for #1015: ``sqlalchemy.text`` was imported function-locally inside
+``DatabaseConnector.connect()`` and ``DatabaseConnector.test_connection()``, but called
+in ``DataExporter.export_table_data()`` and ``DBIngestor.execute_query()``, which never
+imported it. Both raised ``NameError``, re-wrapped by their ``except Exception`` handlers
+into a ``ProcessingError`` reading "Failed to execute query: name 'text' is not defined"
+-- a message that looks like a database problem rather than a missing import.
+
+Nothing caught it because no test exercised either method; the only ``execute_query``
+references under ``tests/`` are Mock stand-ins for the unrelated graph-store method of
+the same name.
+
+These tests run against a temporary SQLite database, so they need no external service.
+"""
+
+import os
+import tempfile
+import unittest
+
+try:
+ from sqlalchemy import create_engine, text
+
+ SQLALCHEMY_AVAILABLE = True
+except ImportError: # pragma: no cover - exercised only where sqlalchemy is absent
+ SQLALCHEMY_AVAILABLE = False
+
+from semantica.ingest.db_ingestor import DataExporter, DBIngestor
+
+
+@unittest.skipUnless(SQLALCHEMY_AVAILABLE, "sqlalchemy is required for these tests")
+class TestDBIngestorQueryExecution(unittest.TestCase):
+ """Both query paths must survive the call that needed sqlalchemy.text -- see #1015."""
+
+ def setUp(self):
+ # Register each cleanup as soon as the resource exists: tearDown is not
+ # called when setUp raises partway through, but addCleanup callbacks are.
+ self._tmpdir = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmpdir.cleanup)
+ self.db_path = os.path.join(self._tmpdir.name, "test.db")
+ self.connection_string = f"sqlite:///{self.db_path}"
+ self.engine = create_engine(self.connection_string)
+ self.addCleanup(self.engine.dispose)
+
+ with self.engine.begin() as conn:
+ conn.execute(text("CREATE TABLE widgets (id INTEGER, name TEXT)"))
+ for row_id, name in [(1, "alpha"), (2, "beta"), (3, "gamma")]:
+ conn.execute(
+ text("INSERT INTO widgets VALUES (:id, :name)"),
+ {"id": row_id, "name": name},
+ )
+
+ def test_execute_query_returns_rows(self):
+ """DBIngestor.execute_query -- the text() call that raised NameError."""
+ rows = DBIngestor().execute_query(
+ self.connection_string,
+ "SELECT id, name FROM widgets ORDER BY id",
+ )
+ self.assertEqual(
+ rows,
+ [
+ {"id": 1, "name": "alpha"},
+ {"id": 2, "name": "beta"},
+ {"id": 3, "name": "gamma"},
+ ],
+ )
+
+ def test_execute_query_binds_parameters(self):
+ """The params argument is passed alongside text(), so cover it explicitly."""
+ rows = DBIngestor().execute_query(
+ self.connection_string,
+ "SELECT name FROM widgets WHERE id = :wanted",
+ wanted=2,
+ )
+ self.assertEqual(rows, [{"name": "beta"}])
+
+ def test_export_table_data_with_limit(self):
+ """Exercises the main text() call; the COUNT(*) branch is skipped when limit is set."""
+ result = DataExporter().export_table_data(self.engine, "widgets", limit=2)
+
+ self.assertEqual(result.table_name, "widgets")
+ self.assertEqual(len(result.rows), 2)
+ self.assertEqual(result.row_count, 2)
+ self.assertEqual([c["name"] for c in result.columns], ["id", "name"])
+
+ def test_export_table_data_without_limit_counts_rows(self):
+ """Covers the second text() call.
+
+ ``export_table_data`` only issues its ``SELECT COUNT(*)`` when no ``limit`` is
+ passed, so the test above never reaches that line. Without this case one of the
+ three call sites the bug touched would stay untested.
+ """
+ result = DataExporter().export_table_data(self.engine, "widgets")
+
+ self.assertEqual(len(result.rows), 3)
+ self.assertEqual(result.row_count, 3)
+
+ def test_export_table_data_honors_where_and_order(self):
+ """The WHERE/ORDER BY clauses are interpolated before text() wraps the query."""
+ result = DataExporter().export_table_data(
+ self.engine,
+ "widgets",
+ where="id >= 2",
+ order_by="id DESC",
+ )
+
+ self.assertEqual([r["name"] for r in result.rows], ["gamma", "beta"])
+ self.assertEqual(result.row_count, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/ingest/test_notebook_02.py b/tests/ingest/test_notebook_02.py
index 0cbb6a52..f99a8b37 100644
--- a/tests/ingest/test_notebook_02.py
+++ b/tests/ingest/test_notebook_02.py
@@ -157,7 +157,7 @@ class TestNotebook02DataIngestion:
repo_ingestor = RepoIngestor()
with patch.object(repo_ingestor, 'ingest_repository') as mock_ingest:
mock_ingest.return_value = {'name': 'semantica'}
- repo_data = repo_ingestor.ingest_repository("https://github.com/Hawksight-AI/semantica.git")
+ repo_data = repo_ingestor.ingest_repository("https://github.com/semantica-agi/semantica.git")
assert repo_data['name'] == 'semantica'
def test_07_email_ingestion(self):
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"
diff --git a/tests/integrations/agno/test_load_urls_ssrf.py b/tests/integrations/agno/test_load_urls_ssrf.py
new file mode 100644
index 00000000..28ad1cbe
--- /dev/null
+++ b/tests/integrations/agno/test_load_urls_ssrf.py
@@ -0,0 +1,278 @@
+"""SSRF regression tests for AgnoKnowledgeGraph.load_urls().
+
+Prior to the fix, load_urls() used urllib.request.urlopen with only a
+scheme check โ private/loopback/link-local/metadata IPs were not blocked
+and redirects were followed without re-validation.
+
+These tests exercise the real SSRF guard (no mock of request_with_ssrf_guard
+itself) by patching at the socket.getaddrinfo level, confirming that
+blocked addresses never reach the network layer.
+"""
+
+from __future__ import annotations
+
+import socket
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# conftest.py installs the full agno stub before this file is collected.
+from integrations.agno.knowledge_graph import AgnoKnowledgeGraph
+
+from semantica.utils.exceptions import ValidationError
+
+
+# ---------------------------------------------------------------------------
+# Minimal fakes so AgnoKnowledgeGraph.__init__ succeeds without real imports.
+# ---------------------------------------------------------------------------
+class _FakeNER:
+ def extract_entities(self, text):
+ return []
+
+
+class _FakeRelExtractor:
+ def extract_relations(self, text, entities=None):
+ return []
+
+
+class _FakeGraphBuilder:
+ def build(self, sources):
+ pass
+
+
+class _FakeContextGraph:
+ def find_nodes(self, label=None):
+ return []
+
+ def get_neighbors(self, node_id=None, hops=1):
+ return []
+
+
+def _make_kg() -> AgnoKnowledgeGraph:
+ return AgnoKnowledgeGraph(
+ graph_builder=_FakeGraphBuilder(),
+ ner_extractor=_FakeNER(),
+ relation_extractor=_FakeRelExtractor(),
+ context_graph=_FakeContextGraph(),
+ )
+
+
+def _public_getaddrinfo(host, *args, **kwargs):
+ """Stub that makes every hostname resolve to a public IP."""
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
+
+
+# ---------------------------------------------------------------------------
+# Tests: blocked addresses must never be fetched
+# ---------------------------------------------------------------------------
+
+class TestLoadUrlsBlockedAddresses:
+ """load_urls() must silently skip (warn) any URL that fails the SSRF guard."""
+
+ @pytest.mark.parametrize("url", [
+ "http://127.0.0.1/secret",
+ "http://127.0.0.1:9200/", # common internal service port
+ "http://0.0.0.0/",
+ "http://169.254.169.254/latest/meta-data/",
+ "http://169.254.169.254/computeMetadata/v1/",
+ "http://10.0.0.1/internal",
+ "http://10.255.255.255/",
+ "http://172.16.0.1/",
+ "http://172.31.255.255/",
+ "http://192.168.0.1/admin",
+ "http://192.168.100.200/",
+ "http://[::1]/ipv6-loopback",
+ "http://[fc00::1]/ipv6-ula",
+ "http://[fe80::1]/ipv6-link-local",
+ ])
+ def test_blocked_ip_never_reaches_network(self, url):
+ """Blocked addresses must raise ValidationError inside the guard,
+ which load_urls() catches and logs โ _ingest_text must NOT be called."""
+ kg = _make_kg()
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls([url])
+ mock_ingest.assert_not_called()
+
+ def test_localhost_hostname_blocked(self):
+ kg = _make_kg()
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["http://localhost/admin"])
+ mock_ingest.assert_not_called()
+
+ def test_localhost_subdomain_blocked(self):
+ kg = _make_kg()
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["http://foo.localhost/"])
+ mock_ingest.assert_not_called()
+
+ def test_hostname_resolving_to_private_ip_blocked(self):
+ """A hostname that resolves to a private IP must be blocked even though
+ the URL string itself looks like a normal hostname."""
+ def _internal_getaddrinfo(host, *a, **kw):
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 0))]
+
+ kg = _make_kg()
+ with patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_internal_getaddrinfo):
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["http://internal.corp/secret"])
+ mock_ingest.assert_not_called()
+
+ def test_hostname_resolving_to_metadata_ip_blocked(self):
+ def _meta_getaddrinfo(host, *a, **kw):
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 0))]
+
+ kg = _make_kg()
+ with patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_meta_getaddrinfo):
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["http://metadata.internal/v1/token"])
+ mock_ingest.assert_not_called()
+
+
+class TestLoadUrlsNonHttpSchemes:
+ """Non-HTTP(S) schemes must be rejected."""
+
+ @pytest.mark.parametrize("url", [
+ "file:///etc/passwd",
+ "file://localhost/etc/shadow",
+ "ftp://example.com/file.txt",
+ "gopher://example.com/1",
+ "dict://example.com/",
+ "sftp://example.com/data",
+ ])
+ def test_non_http_scheme_blocked(self, url):
+ kg = _make_kg()
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls([url])
+ mock_ingest.assert_not_called()
+
+
+class TestLoadUrlsRedirects:
+ """Redirects to private/blocked addresses must be rejected."""
+
+ def test_redirect_to_loopback_blocked(self):
+ """A public first hop that redirects to loopback must be blocked."""
+ redirect = MagicMock()
+ redirect.status_code = 302
+ redirect.headers = {"Location": "http://127.0.0.1/secret"}
+ redirect.close = MagicMock()
+
+ kg = _make_kg()
+ with patch(
+ "semantica.ingest.ssrf.socket.getaddrinfo",
+ return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))],
+ ):
+ # Patch requests.Session so the first hop returns our redirect mock.
+ # The guard sees the 302, then validates the Location โ 127.0.0.1 is
+ # blocked without a second network call.
+ with patch("semantica.ingest.ssrf.requests.Session") as MockSession:
+ mock_session = MockSession.return_value
+ mock_session.adapters = {}
+ mock_session.headers = {}
+ mock_session.auth = None
+ mock_session.trust_env = True
+ mock_session.request.return_value = redirect
+
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["https://example.com/start"])
+ mock_ingest.assert_not_called()
+
+ def test_redirect_to_metadata_ip_blocked(self):
+ """Redirect to cloud metadata endpoint must be blocked."""
+ redirect = MagicMock()
+ redirect.status_code = 301
+ redirect.headers = {"Location": "http://169.254.169.254/latest/meta-data/"}
+ redirect.close = MagicMock()
+
+ kg = _make_kg()
+ with patch(
+ "semantica.ingest.ssrf.socket.getaddrinfo",
+ return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))],
+ ):
+ with patch("semantica.ingest.ssrf.requests.Session") as MockSession:
+ mock_session = MockSession.return_value
+ mock_session.adapters = {}
+ mock_session.headers = {}
+ mock_session.auth = None
+ mock_session.trust_env = True
+ mock_session.request.return_value = redirect
+
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["https://example.com/redirect-me"])
+ mock_ingest.assert_not_called()
+
+
+class TestLoadUrlsValidUrls:
+ """Valid public URLs must succeed and call _ingest_text."""
+
+ def test_valid_public_url_ingested(self):
+ """A URL resolving to a public IP must be fetched and ingested."""
+ ok_response = MagicMock()
+ ok_response.status_code = 200
+ ok_response.headers = {}
+ ok_response.text = "This is the document content."
+
+ kg = _make_kg()
+ with patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo):
+ with patch("semantica.ingest.ssrf.requests.Session") as MockSession:
+ mock_session = MockSession.return_value
+ mock_session.adapters = {}
+ mock_session.headers = {}
+ mock_session.auth = None
+ mock_session.trust_env = True
+ mock_session.request.return_value = ok_response
+
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls(["https://example.com/doc.txt"])
+
+ mock_ingest.assert_called_once_with(
+ "This is the document content.", source="https://example.com/doc.txt"
+ )
+
+ def test_multiple_urls_each_independently_validated(self):
+ """Each URL in the list is independently validated; one blocked URL
+ must not prevent valid subsequent URLs from being ingested."""
+ ok_response = MagicMock()
+ ok_response.status_code = 200
+ ok_response.headers = {}
+ ok_response.text = "Valid content."
+
+ def _selective_getaddrinfo(host, *a, **kw):
+ if host == "internal.corp":
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 0))]
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
+
+ kg = _make_kg()
+ with patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_selective_getaddrinfo):
+ with patch("semantica.ingest.ssrf.requests.Session") as MockSession:
+ mock_session = MockSession.return_value
+ mock_session.adapters = {}
+ mock_session.headers = {}
+ mock_session.auth = None
+ mock_session.trust_env = True
+ mock_session.request.return_value = ok_response
+
+ with patch.object(kg, "_ingest_text") as mock_ingest:
+ kg.load_urls([
+ "http://internal.corp/secret", # blocked
+ "https://example.com/public.txt", # allowed
+ ])
+
+ # Only the valid URL triggers ingestion
+ mock_ingest.assert_called_once_with("Valid content.", source="https://example.com/public.txt")
+
+ def test_failed_fetch_does_not_raise(self):
+ """A network failure on a valid URL must log a warning, not raise."""
+ import requests as _requests
+
+ kg = _make_kg()
+ with patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo):
+ with patch("semantica.ingest.ssrf.requests.Session") as MockSession:
+ mock_session = MockSession.return_value
+ mock_session.adapters = {}
+ mock_session.headers = {}
+ mock_session.auth = None
+ mock_session.trust_env = True
+ mock_session.request.side_effect = _requests.exceptions.ConnectionError("refused")
+
+ # Must not raise; failure is logged and skipped
+ kg.load_urls(["https://example.com/unreachable"])
diff --git a/tests/integrations/langchain/test_degradation.py b/tests/integrations/langchain/test_degradation.py
new file mode 100644
index 00000000..b50ce76c
--- /dev/null
+++ b/tests/integrations/langchain/test_degradation.py
@@ -0,0 +1,92 @@
+"""
+Graceful-degradation tests for the LangChain integration.
+
+Runs the adapters in a fresh subprocess with langchain-core hidden, so the
+object-base path is proven even when this env has langchain-core installed.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+from integrations.langchain import (
+ LANGCHAIN_AVAILABLE,
+ SemanticaDecisionTool,
+ SemanticaKGTool,
+)
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+
+_SCRIPT = r"""
+import sys
+from types import SimpleNamespace
+
+class _BlockLangchain:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "langchain_core" or fullname.startswith("langchain_core."):
+ raise ImportError("langchain_core blocked for degradation test")
+ return None
+
+sys.meta_path.insert(0, _BlockLangchain())
+for name in list(sys.modules):
+ if name == "langchain_core" or name.startswith("langchain_core."):
+ del sys.modules[name]
+
+from integrations.langchain.retriever import LANGCHAIN_AVAILABLE as RET_AVAIL
+from integrations.langchain.vectorstore import (
+ LANGCHAIN_AVAILABLE as VS_AVAIL,
+ SemanticaVectorStore,
+)
+from integrations.langchain.tools import (
+ LANGCHAIN_AVAILABLE as TOOL_AVAIL,
+ SemanticaKGTool,
+ SemanticaDecisionTool,
+)
+from integrations.langchain.retriever import SemanticaRetriever, _get_document
+
+assert RET_AVAIL is False and VS_AVAIL is False and TOOL_AVAIL is False
+
+retriever = SemanticaRetriever(graph=SimpleNamespace(), hops=2)
+assert retriever.hops == 2
+
+store = SemanticaVectorStore(hybrid=SimpleNamespace(), tags=["x"])
+assert store.hybrid is not None
+
+graph = SimpleNamespace(query=lambda q, limit=10: [{"q": q, "limit": limit}])
+assert SemanticaKGTool(graph).build() is None
+assert SemanticaDecisionTool(graph).build() is None
+
+try:
+ _get_document(page_content="x")
+ raise SystemExit("expected RuntimeError from _get_document")
+except RuntimeError as exc:
+ assert "langchain-core" in str(exc)
+
+print("DEGRADATION_OK")
+"""
+
+
+def test_importable_and_functional_without_langchain():
+ result = subprocess.run(
+ [sys.executable, "-c", _SCRIPT],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ assert result.returncode == 0, (
+ f"subprocess failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+ )
+ assert "DEGRADATION_OK" in result.stdout
+
+
+@pytest.mark.skipif(LANGCHAIN_AVAILABLE, reason="langchain-core is installed")
+def test_tools_build_returns_none_without_langchain():
+ graph = SimpleNamespace()
+ assert SemanticaKGTool(graph).build() is None
+ assert SemanticaDecisionTool(graph).build() is None
diff --git a/tests/integrations/langchain/test_langchain_integration.py b/tests/integrations/langchain/test_langchain_integration.py
new file mode 100644
index 00000000..88812e06
--- /dev/null
+++ b/tests/integrations/langchain/test_langchain_integration.py
@@ -0,0 +1,231 @@
+"""
+Tests for integrations/langchain.
+
+Adapter behavior is always exercised (hit parsing, seed/fallback, tool JSON).
+LangChain-present paths use pytest.importorskip; degradation without
+langchain-core is covered in test_degradation.py via a subprocess so it still
+runs when langchain-core is installed in this env.
+"""
+
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from integrations.langchain import (
+ LANGCHAIN_AVAILABLE,
+ SemanticaDecisionTool,
+ SemanticaKGTool,
+ SemanticaRetriever,
+ SemanticaVectorStore,
+)
+from integrations.langchain.retriever import _hit_content, _hit_id, _hit_type
+from integrations.langchain.tools import QueryDecisionsInput, QueryGraphInput
+
+# HybridSearch.search() returns {id, score, distance, metadata} โ content lives
+# inside metadata, and id is a vector id, not a graph node id.
+_HYBRID_HIT = {
+ "id": "vec_0",
+ "score": 0.91,
+ "distance": 0.09,
+ "metadata": {
+ "node_id": "alice",
+ "content": "Alice is a developer",
+ "node_type": "person",
+ "source": "graph",
+ },
+}
+
+
+def test_exports_exist():
+ assert callable(SemanticaRetriever)
+ assert callable(SemanticaVectorStore)
+ assert callable(SemanticaKGTool)
+ assert callable(SemanticaDecisionTool)
+
+
+def test_version():
+ from integrations.langchain import __version__
+
+ assert __version__ == "0.1.0"
+
+
+# ---------------------------------------------------------------------------
+# Hit parsing (the Qodo high-severity finding)
+# ---------------------------------------------------------------------------
+def test_hit_id_prefers_metadata_node_id_over_vector_id():
+ assert _hit_id(_HYBRID_HIT) == "alice"
+ assert _hit_id({"node_id": "n1"}) == "n1"
+ assert _hit_id({"id": "n2"}) == "n2"
+
+
+def test_hit_id_unwraps_context_graph_query_shape():
+ hit = {
+ "node": {
+ "id": "alice",
+ "type": "person",
+ "properties": {"content": "Alice"},
+ },
+ "score": 1.0,
+ "content": "Alice is a developer",
+ }
+ assert _hit_id(hit) == "alice"
+ assert _hit_content(hit) == "Alice is a developer"
+ assert _hit_type(hit) == "person"
+
+
+def test_hit_content_and_type_read_nested_metadata():
+ assert _hit_content(_HYBRID_HIT) == "Alice is a developer"
+ assert _hit_type(_HYBRID_HIT) == "person"
+ assert _hit_content({"id": "x"}) == ""
+
+
+# ---------------------------------------------------------------------------
+# Retriever
+# ---------------------------------------------------------------------------
+def test_empty_seed_returns_empty():
+ graph = MagicMock()
+ graph.query.return_value = []
+ retriever = SemanticaRetriever(graph=graph, top_k=5)
+ assert retriever._seed_results("query") == []
+ assert retriever.hops == 2
+
+
+def test_seed_uses_hybrid_when_provided():
+ graph = MagicMock()
+ hybrid = MagicMock()
+ hybrid.search.return_value = [_HYBRID_HIT]
+ retriever = SemanticaRetriever(graph=graph, hybrid=hybrid)
+ results = retriever._seed_results("query")
+ assert len(results) == 1
+ hybrid.search.assert_called_once_with("query", k=10)
+
+
+def test_graph_fallback_when_hybrid_fails():
+ graph = MagicMock()
+ graph.query.return_value = [{"node_id": "n1", "content": "c1"}]
+ hybrid = MagicMock()
+ hybrid.search.side_effect = RuntimeError("down")
+ retriever = SemanticaRetriever(graph=graph, hybrid=hybrid)
+ results = retriever._seed_results("query")
+ assert len(results) == 1
+ graph.query.assert_called_once()
+
+
+def test_retriever_reads_hybrid_metadata_and_expands_by_node_id():
+ pytest.importorskip("langchain_core")
+ graph = MagicMock()
+ graph.get_neighbors.return_value = [
+ {
+ "id": "bob",
+ "type": "person",
+ "content": "Bob reports to Alice",
+ "weight": 0.8,
+ }
+ ]
+ hybrid = MagicMock()
+ hybrid.search.return_value = [_HYBRID_HIT]
+ retriever = SemanticaRetriever(graph=graph, hybrid=hybrid)
+ docs = retriever._get_relevant_documents("Alice")
+ assert docs[0].page_content == "Alice is a developer"
+ assert docs[0].metadata["node_id"] == "alice"
+ assert docs[0].metadata["source"] == "graph"
+ graph.get_neighbors.assert_called_once_with("alice", hops=2)
+ assert [d.metadata["node_id"] for d in docs] == ["alice", "bob"]
+
+
+# ---------------------------------------------------------------------------
+# VectorStore
+# ---------------------------------------------------------------------------
+def test_add_texts_delegates_to_vector_store():
+ vs = MagicMock()
+ vs.add_documents.return_value = ["id1"]
+ store = SemanticaVectorStore(hybrid=MagicMock(), vector_store=vs)
+ assert store.add_texts(["hello"]) == ["id1"]
+ vs.add_documents.assert_called_once()
+
+
+def test_add_texts_raises_without_vector_store():
+ store = SemanticaVectorStore(hybrid=SimpleNamespace(vector_store=None))
+ with pytest.raises(ValueError):
+ store.add_texts(["hello"])
+
+
+def test_from_texts_requires_hybrid_kwarg():
+ with pytest.raises(ValueError):
+ SemanticaVectorStore.from_texts(["hello"], embedding=None)
+
+
+def test_vectorstore_reads_hybrid_metadata():
+ pytest.importorskip("langchain_core")
+ hybrid = MagicMock()
+ hybrid.search.return_value = [_HYBRID_HIT]
+ store = SemanticaVectorStore(hybrid=hybrid)
+ docs = store.similarity_search("Alice", k=1)
+ assert docs[0].page_content == "Alice is a developer"
+ assert docs[0].metadata["node_id"] == "alice"
+ assert docs[0].metadata["source"] == "graph"
+ pairs = store.similarity_search_with_score("Alice", k=1)
+ assert pairs[0][0].page_content == "Alice is a developer"
+ assert pairs[0][1] == pytest.approx(0.91)
+
+
+# ---------------------------------------------------------------------------
+# Tools โ JSON payload + BaseTool contract
+# ---------------------------------------------------------------------------
+def test_kg_tool_returns_full_valid_json():
+ graph = MagicMock()
+ graph.query.return_value = [{"content": "x" * 5000, "id": i} for i in range(3)]
+ raw = SemanticaKGTool(graph)._run("q", limit=3)
+ parsed = json.loads(raw)
+ assert len(parsed) == 3
+ assert len(parsed[0]["content"]) == 5000
+ graph.query.assert_called_once_with("q", limit=3)
+
+
+def test_tool_errors_are_json():
+ graph = MagicMock()
+ graph.query.side_effect = RuntimeError("boom")
+ assert json.loads(SemanticaKGTool(graph)._run("q")) == {"error": "boom"}
+ assert json.loads(SemanticaDecisionTool(graph)._run("q")) == {"error": "boom"}
+
+
+def test_decision_tool_empty_category_uses_insights():
+ graph = MagicMock()
+ graph.get_decision_insights.return_value = {"n": 0}
+ assert json.loads(SemanticaDecisionTool(graph)._run("")) == {"n": 0}
+
+
+def test_tools_are_base_tools_with_args_schema():
+ pytest.importorskip("langchain_core")
+ from langchain_core.tools import BaseTool
+
+ graph = MagicMock()
+ graph.query.return_value = [{"hit": True}]
+ kg = SemanticaKGTool(graph)
+ dec = SemanticaDecisionTool(graph)
+ assert isinstance(kg, BaseTool)
+ assert isinstance(dec, BaseTool)
+ assert kg.args_schema is QueryGraphInput
+ assert dec.args_schema is QueryDecisionsInput
+ assert kg.build() is kg
+ parsed = json.loads(kg.invoke({"query": "Alice", "limit": 5}))
+ assert parsed == [{"hit": True}]
+
+
+@pytest.mark.skipif(not LANGCHAIN_AVAILABLE, reason="langchain-core not installed")
+def test_kg_tool_invoke_with_context_graph():
+ pytest.importorskip("langchain_core")
+ try:
+ from semantica.context import ContextGraph
+ except ImportError:
+ pytest.skip("ContextGraph import requires optional core deps")
+
+ graph = ContextGraph()
+ graph.add_node(node_id="alice", node_type="person", content="Alice is a developer")
+ result = SemanticaKGTool(graph).invoke({"query": "Alice", "limit": 5})
+ assert "Alice" in result
+ json.loads(result)
diff --git a/tests/integrations/openclaw/__init__.py b/tests/integrations/openclaw/__init__.py
new file mode 100644
index 00000000..6def8d9a
--- /dev/null
+++ b/tests/integrations/openclaw/__init__.py
@@ -0,0 +1 @@
+# tests/integrations/openclaw package
diff --git a/tests/integrations/openclaw/test_mcp_tool_ssrf.py b/tests/integrations/openclaw/test_mcp_tool_ssrf.py
new file mode 100644
index 00000000..4c75c772
--- /dev/null
+++ b/tests/integrations/openclaw/test_mcp_tool_ssrf.py
@@ -0,0 +1,290 @@
+"""SSRF hardening tests for OpenClawKGTool.
+
+OpenClawKGTool is designed to speak to a locally-running Semantica REST server
+(default: http://localhost:8000). The fix validates base_url at construction
+time so that obviously wrong schemes (file://, ftp://, gopher://, etc.) and
+malformed URLs are rejected immediately, while localhost and other private
+addresses remain valid because allow_private_ips=True is the correct posture
+for this tool's intended use case.
+
+These are construction-time tests; per-request SSRF guarding is not the
+contract of this tool (its threat model is operator-configured base_url, not
+untrusted per-call URLs).
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from integrations.openclaw.mcp_tool import OpenClawKGTool
+from semantica.utils.exceptions import ValidationError
+
+
+class TestOpenClawKGToolBaseUrlValidation:
+ """base_url is validated at __init__ time."""
+
+ # ------------------------------------------------------------------
+ # Valid base_urls โ all must construct without raising
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize("url", [
+ "http://localhost:8000",
+ "http://localhost",
+ "http://127.0.0.1:8000",
+ "http://127.0.0.1",
+ "https://localhost:8443",
+ "http://0.0.0.0:8000",
+ "http://192.168.1.10:8000", # LAN Semantica server
+ "http://10.0.0.5:8000", # corporate intranet deployment
+ "https://semantica.internal/api",
+ "https://semantica.example.com",
+ ])
+ def test_valid_base_url_accepted(self, url):
+ """All reasonable operator-configured base_urls must be accepted."""
+ tool = OpenClawKGTool(base_url=url)
+ assert tool.base_url == url.rstrip("/")
+
+ # ------------------------------------------------------------------
+ # Invalid schemes โ must raise at construction
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize("url", [
+ "file:///etc/passwd",
+ "file://localhost/etc/shadow",
+ "ftp://example.com/",
+ "gopher://example.com/1",
+ "dict://example.com/",
+ "sftp://example.com/",
+ "ldap://example.com/",
+ "javascript:alert(1)",
+ ])
+ def test_invalid_scheme_rejected(self, url):
+ """Non-HTTP(S) schemes must be rejected at construction time."""
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url=url)
+
+ # ------------------------------------------------------------------
+ # Malformed URLs
+ # ------------------------------------------------------------------
+
+ def test_empty_string_rejected(self):
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url="")
+
+ def test_no_scheme_rejected(self):
+ """A bare hostname without a scheme must be rejected."""
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url="localhost:8000")
+
+ def test_whitespace_only_rejected(self):
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url=" ")
+
+ # ------------------------------------------------------------------
+ # Default is the documented localhost value
+ # ------------------------------------------------------------------
+
+ def test_default_base_url_is_localhost(self):
+ """The default must remain http://localhost:8000 for backward compat."""
+ tool = OpenClawKGTool()
+ assert tool.base_url == "http://localhost:8000"
+
+ def test_trailing_slash_stripped(self):
+ """base_url trailing slash must be stripped so paths concatenate cleanly."""
+ tool = OpenClawKGTool(base_url="http://localhost:8000/")
+ assert tool.base_url == "http://localhost:8000"
+
+ def test_multiple_trailing_slashes_stripped(self):
+ tool = OpenClawKGTool(base_url="http://localhost:8000///")
+ assert tool.base_url == "http://localhost:8000"
+
+ def test_leading_and_trailing_whitespace_stripped(self):
+ """Whitespace around a valid URL must be stripped before storage so
+ _post/_get don't build requests with space-padded URLs like
+ ' http://localhost:8000 /extract'."""
+ tool = OpenClawKGTool(base_url=" http://localhost:8000 ")
+ assert tool.base_url == "http://localhost:8000"
+
+ def test_whitespace_plus_trailing_slash_both_stripped(self):
+ tool = OpenClawKGTool(base_url=" http://localhost:8000/ ")
+ assert tool.base_url == "http://localhost:8000"
+
+
+class TestOpenClawKGToolFallbackValidation:
+ """When semantica.ingest.ssrf is unavailable (ImportError path), the fallback
+ must perform the same structural checks as validate_url_for_request:
+ non-empty string, http/https scheme, netloc present, hostname present.
+
+ The fallback is exercised by temporarily hiding semantica.ingest.ssrf
+ from sys.modules so the import inside __init__ raises ImportError.
+ """
+
+ @staticmethod
+ def _hide_ssrf(monkeypatch):
+ """Return a context in which semantica.ingest.ssrf appears unimportable."""
+ import sys
+ monkeypatch.setitem(sys.modules, "semantica.ingest.ssrf", None)
+
+ # ------------------------------------------------------------------
+ # Valid URLs must still be accepted in the fallback path
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize("url", [
+ "http://localhost:8000",
+ "http://127.0.0.1:8000",
+ "https://semantica.example.com",
+ ])
+ def test_fallback_valid_url_accepted(self, url, monkeypatch):
+ self._hide_ssrf(monkeypatch)
+ tool = OpenClawKGTool(base_url=url)
+ assert tool.base_url == url.rstrip("/")
+
+ # ------------------------------------------------------------------
+ # Malformed URLs that the fallback previously let through
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize("url", [
+ "http://", # scheme only, no netloc or hostname
+ "https://", # same
+ "http:///path", # empty hostname (netloc is present but hostname is None)
+ ])
+ def test_fallback_no_netloc_rejected(self, url, monkeypatch):
+ """URLs with a valid scheme but missing netloc/hostname must be rejected
+ in the fallback path, matching validate_url_for_request's behaviour."""
+ self._hide_ssrf(monkeypatch)
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url=url)
+
+ def test_fallback_empty_string_rejected(self, monkeypatch):
+ self._hide_ssrf(monkeypatch)
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url="")
+
+ def test_fallback_whitespace_only_rejected(self, monkeypatch):
+ self._hide_ssrf(monkeypatch)
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url=" ")
+
+ def test_fallback_invalid_scheme_rejected(self, monkeypatch):
+ self._hide_ssrf(monkeypatch)
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url="file:///etc/passwd")
+
+ def test_fallback_no_scheme_rejected(self, monkeypatch):
+ self._hide_ssrf(monkeypatch)
+ with pytest.raises((ValidationError, ValueError)):
+ OpenClawKGTool(base_url="localhost:8000")
+
+ def test_fallback_whitespace_padded_valid_url_stored_clean(self, monkeypatch):
+ """Whitespace around a valid URL must be stripped before storage in the
+ fallback path too โ same guarantee as the normal path."""
+ self._hide_ssrf(monkeypatch)
+ tool = OpenClawKGTool(base_url=" http://localhost:8000 ")
+ assert tool.base_url == "http://localhost:8000"
+
+
+class TestOpenClawKGToolEndpointConstruction:
+ """Verify that per-method URLs are assembled from base_url + hardcoded paths.
+
+ The endpoint strings are always literals defined in the class body โ
+ they are not caller-supplied โ so these tests confirm the URL assembly
+ logic is correct rather than testing SSRF guards on the endpoints.
+
+ All HTTP calls are mocked so no real network connection is made.
+ """
+
+ def _mock_session(self, status: int = 200, body: bytes = b"{}") -> "MagicMock":
+ """Return a mock session whose post/get return a minimal JSON response."""
+ from unittest.mock import MagicMock
+ mock_resp = MagicMock()
+ mock_resp.status_code = status
+ mock_resp.raise_for_status = MagicMock()
+ mock_resp.json.return_value = {}
+ session = MagicMock()
+ session.post.return_value = mock_resp
+ session.get.return_value = mock_resp
+ return session
+
+ def test_post_url_constructed_from_base_url(self):
+ """_post must call session.post with the exact URL base_url+endpoint,
+ the supplied payload as json=, and the tool timeout. No real connection."""
+ from unittest.mock import patch
+
+ tool = OpenClawKGTool(base_url="http://localhost:8000")
+ mock_session = self._mock_session()
+
+ with patch.object(tool, "_get_session", return_value=mock_session):
+ tool._post("/extract", {"text": "hello"})
+
+ mock_session.post.assert_called_once_with(
+ "http://localhost:8000/extract",
+ json={"text": "hello"},
+ timeout=30,
+ )
+
+ def test_post_url_with_custom_base_url(self):
+ """base_url is reflected correctly in the outbound URL for _post."""
+ from unittest.mock import patch
+
+ tool = OpenClawKGTool(base_url="http://192.168.1.10:9000")
+ mock_session = self._mock_session()
+
+ with patch.object(tool, "_get_session", return_value=mock_session):
+ tool._post("/decisions", {"decision": "deploy"})
+
+ mock_session.post.assert_called_once_with(
+ "http://192.168.1.10:9000/decisions",
+ json={"decision": "deploy"},
+ timeout=30,
+ )
+
+ def test_get_url_constructed_from_base_url(self):
+ """_get must call session.get with the exact URL base_url+endpoint,
+ params={} when none are supplied, and the tool timeout."""
+ from unittest.mock import patch
+
+ tool = OpenClawKGTool(base_url="http://localhost:8000")
+ mock_session = self._mock_session()
+
+ with patch.object(tool, "_get_session", return_value=mock_session):
+ tool._get("/analytics")
+
+ mock_session.get.assert_called_once_with(
+ "http://localhost:8000/analytics",
+ params={},
+ timeout=30,
+ )
+
+ def test_get_url_with_params(self):
+ """_get must forward supplied params to session.get."""
+ from unittest.mock import patch
+
+ tool = OpenClawKGTool(base_url="http://localhost:8000")
+ mock_session = self._mock_session()
+
+ with patch.object(tool, "_get_session", return_value=mock_session):
+ tool._get("/decisions/search", {"q": "deploy", "limit": 5})
+
+ mock_session.get.assert_called_once_with(
+ "http://localhost:8000/decisions/search",
+ params={"q": "deploy", "limit": 5},
+ timeout=30,
+ )
+
+ def test_custom_timeout_forwarded(self):
+ """A non-default timeout must reach session.post and session.get."""
+ from unittest.mock import patch
+
+ tool = OpenClawKGTool(base_url="http://localhost:8000", timeout=60)
+ mock_session = self._mock_session()
+
+ with patch.object(tool, "_get_session", return_value=mock_session):
+ tool._post("/extract", {"text": "x"})
+ tool._get("/analytics")
+
+ assert mock_session.post.call_args.kwargs["timeout"] == 60
+ assert mock_session.get.call_args.kwargs["timeout"] == 60
+
+ def test_repr_includes_base_url(self):
+ tool = OpenClawKGTool(base_url="http://localhost:9000")
+ assert "http://localhost:9000" in repr(tool)
diff --git a/tests/kg/test_kg.py b/tests/kg/test_kg.py
index a983694d..4d02b1e4 100644
--- a/tests/kg/test_kg.py
+++ b/tests/kg/test_kg.py
@@ -407,6 +407,69 @@ class TestTemporalGraphQuery(unittest.TestCase):
self.assertEqual(result["num_relationships"], 1)
+ def test_analyze_evolution_stability_is_mean_duration_seconds(self):
+ day = 86400.0
+ graph = {
+ "relationships": [
+ {
+ "source": "1",
+ "target": "2",
+ "type": "a",
+ "valid_from": "2024-01-01",
+ "valid_until": "2024-01-02", # 1 day
+ },
+ {
+ "source": "2",
+ "target": "3",
+ "type": "b",
+ "valid_from": "2024-01-01",
+ "valid_until": "2024-01-04", # 3 days
+ },
+ ]
+ }
+
+ result = self.query_engine.analyze_evolution(graph, metrics=["stability"])
+
+ # Mean of 1-day and 3-day durations == 2 days in seconds.
+ self.assertAlmostEqual(result["stability"], 2 * day)
+
+ def test_analyze_evolution_stability_skips_unbounded_intervals(self):
+ graph = {
+ "relationships": [
+ {
+ "source": "1",
+ "target": "2",
+ "type": "bounded",
+ "valid_from": "2024-01-01",
+ "valid_until": "2024-01-02",
+ },
+ {
+ "source": "2",
+ "target": "3",
+ "type": "open",
+ "valid_from": "2024-01-01",
+ "valid_until": TemporalBound.OPEN,
+ },
+ {
+ "source": "3",
+ "target": "4",
+ "type": "no-start",
+ "valid_until": "2024-06-01",
+ },
+ ]
+ }
+
+ result = self.query_engine.analyze_evolution(graph, metrics=["stability"])
+
+ # Only the fully bounded relationship contributes (1 day).
+ self.assertAlmostEqual(result["stability"], 86400.0)
+
+ def test_analyze_evolution_stability_empty_is_zero(self):
+ result = self.query_engine.analyze_evolution(
+ {"relationships": []}, metrics=["stability"]
+ )
+ self.assertEqual(result["stability"], 0)
+
def test_query_at_time_legacy_transaction_axis_uses_valid_from_when_recorded_missing(self):
graph = {
"relationships": [
diff --git a/tests/normalize/test_number_normalizer.py b/tests/normalize/test_number_normalizer.py
index a7cf359c..2e06260c 100644
--- a/tests/normalize/test_number_normalizer.py
+++ b/tests/normalize/test_number_normalizer.py
@@ -1,4 +1,6 @@
import unittest
+
+from semantica.utils.exceptions import ValidationError
from semantica.normalize.number_normalizer import (
NumberNormalizer,
UnitConverter,
@@ -32,6 +34,19 @@ class TestUnitConverter(unittest.TestCase):
# 1 kg = 1000 g
self.assertEqual(self.converter.convert_units(1, "kg", "g"), 1000.0)
+ def test_convert_accepts_aliases_for_category_validation(self):
+ # Aliases are part of the documented API, not just parsing syntax.
+ self.assertEqual(self.converter.convert_units(1, "feet", "m"), 0.3048)
+ self.assertEqual(self.converter.convert_units(1, "gal", "liter"), 3.78541)
+
+ def test_convert_rejects_mismatched_categories_even_for_aliases(self):
+ # Both units normalize to canonical names first, so the category
+ # check sees real categories and rejects cross-category conversions.
+ with self.assertRaises(ValidationError):
+ self.converter.convert_units(1, "kg", "ft")
+ with self.assertRaises(ValidationError):
+ self.converter.convert_units(1, "gal", "lb")
+
def test_normalize_unit(self):
self.assertEqual(self.converter.normalize_unit("km"), "kilometer")
self.assertEqual(self.converter.normalize_unit("kgs"), "kilogram")
diff --git a/tests/ontology/test_ontology_advanced.py b/tests/ontology/test_ontology_advanced.py
index 98d5dc6e..cf77dbe0 100644
--- a/tests/ontology/test_ontology_advanced.py
+++ b/tests/ontology/test_ontology_advanced.py
@@ -340,6 +340,27 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
ttl = gen.serialize(graph, format="turtle")
self.assertIn("myorg.com", ttl)
+ # 24a โ an RDF namespace ending in `#` must not gain a trailing `/`,
+ # or every generated URI lands in a different namespace and SHACL
+ # validation silently targets nothing.
+ def test_hash_namespace_base_uri_is_not_mangled(self):
+ gen = self._make_gen(base_uri="http://example.org/manufacturing#")
+ self.assertEqual(gen.base_uri, "http://example.org/manufacturing#")
+ self.assertEqual(gen.shapes_uri, "http://example.org/manufacturing#shapes")
+
+ graph = gen.generate(self._HIER_ONTOLOGY)
+ ttl = gen.serialize(graph, format="turtle")
+ self.assertNotIn("#/", ttl)
+ self.assertIn("manufacturing#", ttl)
+
+ # 24b โ a `#`-terminated base is preserved verbatim, while slash runs
+ # are collapsed: Qodo review caught that `endswith(("/","#"))` left
+ # `.../ns////` intact, leaking a different namespace into emitted IRIs.
+ def test_slash_run_normalization_regression(self):
+ gen = self._make_gen(base_uri="http://example.org/ns////")
+ self.assertEqual(gen.base_uri, "http://example.org/ns/")
+ self.assertEqual(gen.shapes_uri, "http://example.org/ns/shapes")
+
# 25
def test_severity_warning(self):
gen = self._make_gen(severity="Warning")
@@ -525,6 +546,65 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
self.assertEqual(mc[0].max_count, 2)
# 33
+ def test_public_run_shacl_validation_api(self):
+ """The public API validates data and retains the legacy alias."""
+ try:
+ import pyshacl # noqa: F401
+ import rdflib # noqa: F401
+ except ImportError:
+ self.skipTest("pyshacl/rdflib not installed")
+ from semantica.ontology import run_shacl_validation
+ from semantica.ontology.ontology_validator import _run_pyshacl
+
+ data = "@prefix ex: . ex:alice a ex:Person ."
+ shacl = """
+ @prefix ex: .
+ @prefix sh: .
+ ex:PersonShape a sh:NodeShape ; sh:targetClass ex:Person ;
+ sh:property [ sh:path ex:name ; sh:minCount 1 ] .
+ """
+ public_report = run_shacl_validation(data, shacl)
+ legacy_report = _run_pyshacl(data, shacl)
+ self.assertFalse(public_report.conforms)
+ self.assertEqual(public_report.violation_count, 1)
+ self.assertEqual(legacy_report.conforms, public_report.conforms)
+ self.assertEqual(legacy_report.violation_count, public_report.violation_count)
+ self.assertEqual(
+ [
+ (v.focus_node, v.result_path, v.constraint, v.severity, v.message)
+ for v in legacy_report.violations
+ ],
+ [
+ (v.focus_node, v.result_path, v.constraint, v.severity, v.message)
+ for v in public_report.violations
+ ],
+ )
+
+ # 34
+ def test_public_run_shacl_validation_conforming_graph(self):
+ """The public API reports a valid graph without violations."""
+ try:
+ import pyshacl # noqa: F401
+ import rdflib # noqa: F401
+ except ImportError:
+ self.skipTest("pyshacl/rdflib not installed")
+ from semantica.ontology import run_shacl_validation
+
+ data = """
+ @prefix ex: .
+ ex:alice a ex:Person ; ex:name "Alice" .
+ """
+ shacl = """
+ @prefix ex: .
+ @prefix sh: .
+ ex:PersonShape a sh:NodeShape ; sh:targetClass ex:Person ;
+ sh:property [ sh:path ex:name ; sh:minCount 1 ] .
+ """
+
+ report = run_shacl_validation(data, shacl)
+
+ self.assertTrue(report.conforms)
+ self.assertEqual(report.violation_count, 0)
def test_shacl_violation_to_dict(self):
from semantica.ontology.ontology_validator import SHACLViolation
v = SHACLViolation(
diff --git a/tests/ontology/test_ontology_normalized_properties.py b/tests/ontology/test_ontology_normalized_properties.py
new file mode 100644
index 00000000..5cef53d3
--- /dev/null
+++ b/tests/ontology/test_ontology_normalized_properties.py
@@ -0,0 +1,41 @@
+from semantica.ontology.class_inferrer import ClassInferrer
+from semantica.ontology.ontology_generator import OntologyGenerator
+from semantica.ontology.property_generator import PropertyGenerator
+
+
+def _entities():
+ return [
+ {
+ "id": "e1",
+ "type": "software engineer",
+ "name": "Alice",
+ "email": "alice@example.org",
+ },
+ {
+ "id": "e2",
+ "type": "software engineer",
+ "name": "Bob",
+ "email": "bob@example.org",
+ },
+ ]
+
+
+def test_property_generator_matches_normalized_class_names():
+ entities = _entities()
+ classes = ClassInferrer().infer_classes(entities)
+
+ properties = PropertyGenerator().infer_properties(entities, [], classes)
+
+ email = next(prop for prop in properties if prop["name"] == "email")
+ assert email["domain"] == ["SoftwareEngineer"]
+ assert email["range"] == "xsd:string"
+
+
+def test_ontology_pipeline_emits_data_properties_for_normalized_types():
+ ontology = OntologyGenerator().generate_ontology(
+ {"entities": _entities(), "relationships": []}
+ )
+
+ email = next(prop for prop in ontology["properties"] if prop["name"] == "email")
+ assert email["domain"] == ["SoftwareEngineer"]
+ assert email["range"] == "xsd:string"
diff --git a/tests/ontology/test_ontology_relationship_endpoints.py b/tests/ontology/test_ontology_relationship_endpoints.py
new file mode 100644
index 00000000..a1251daf
--- /dev/null
+++ b/tests/ontology/test_ontology_relationship_endpoints.py
@@ -0,0 +1,98 @@
+from semantica.ontology.ontology_generator import OntologyGenerator
+
+
+def _object_property(ontology, name):
+ return next(prop for prop in ontology["properties"] if prop["name"] == name)
+
+
+def test_id_based_relationship_endpoints_infer_domain_and_range():
+ entities = [
+ {"id": "p1", "type": "Person", "name": "Alice"},
+ {"id": "p2", "type": "Person", "name": "Bob"},
+ {"id": "o1", "type": "Organization", "name": "Acme"},
+ {"id": "o2", "type": "Organization", "name": "Beta"},
+ ]
+ relationships = [
+ {"source_id": "p1", "target_id": "o1", "type": "works_for"},
+ {"source_id": "p2", "target_id": "o2", "type": "works_for"},
+ ]
+
+ ontology = OntologyGenerator().generate_ontology(
+ {"entities": entities, "relationships": relationships}
+ )
+
+ works_for = _object_property(ontology, "worksFor")
+ assert works_for["domain"] == ["Person"]
+ assert works_for["range"] == ["Organization"]
+
+
+def test_source_and_target_aliases_resolve_without_matching_missing_fields():
+ entities = [
+ {"id": "p1", "type": "Person", "name": "Alice"},
+ {"id": "p2", "type": "Person", "name": "Bob"},
+ {"id": "o1", "type": "Organization", "name": "Acme"},
+ {"id": "o2", "type": "Organization", "name": "Beta"},
+ ]
+ relationships = [
+ {"source": "p1", "target": "o1", "type": "works_for"},
+ {"source": "p2", "target": "o2", "type": "works_for"},
+ ]
+
+ ontology = OntologyGenerator().generate_ontology(
+ {"entities": entities, "relationships": relationships}
+ )
+
+ works_for = _object_property(ontology, "worksFor")
+ assert works_for["domain"] == ["Person"]
+ assert works_for["range"] == ["Organization"]
+
+
+def test_explicit_relationship_endpoint_types_are_preserved():
+ entities = [
+ {"id": "p1", "type": "Person", "name": "Alice"},
+ {"id": "o1", "type": "Organization", "name": "Acme"},
+ ]
+ relationships = [
+ {
+ "source_id": "p1",
+ "target_id": "o1",
+ "type": "works_for",
+ "source_type": "Employee",
+ "target_type": "Company",
+ }
+ ]
+
+ ontology = OntologyGenerator(min_occurrences=1).generate_ontology(
+ {"entities": entities, "relationships": relationships}
+ )
+
+ works_for = _object_property(ontology, "worksFor")
+ assert works_for["domain"] == ["Employee"]
+ assert works_for["range"] == ["Company"]
+
+
+def test_nested_endpoint_alias_skips_empty_id_and_uses_name():
+ entities = [
+ {"id": "p1", "type": "Person", "name": "Alice"},
+ {"id": "o1", "type": "Organization", "name": "Acme"},
+ ]
+ relationships = [
+ {
+ "source": {"id": "", "name": "Alice"},
+ "target": {"id": "", "name": "Acme"},
+ "type": "works_for",
+ },
+ {
+ "source": {"id": "", "name": "Alice"},
+ "target": {"id": "", "name": "Acme"},
+ "type": "works_for",
+ },
+ ]
+
+ ontology = OntologyGenerator().generate_ontology(
+ {"entities": entities, "relationships": relationships}
+ )
+
+ works_for = _object_property(ontology, "worksFor")
+ assert works_for["domain"] == ["Person"]
+ assert works_for["range"] == ["Organization"]
diff --git a/tests/pipeline/test_pipeline.py b/tests/pipeline/test_pipeline.py
index 3dc7f530..e681b3c7 100644
--- a/tests/pipeline/test_pipeline.py
+++ b/tests/pipeline/test_pipeline.py
@@ -66,6 +66,93 @@ class TestPipelineModule(unittest.TestCase):
self.assertEqual(result.output, 12) # (5 + 1) * 2 = 12
self.assertEqual(pipeline.steps[0].status, StepStatus.COMPLETED)
+ def test_registered_step_handler_executes(self):
+ """A handler registered by step type should execute."""
+ def increment(data):
+ return data + 1
+
+ builder = PipelineBuilder()
+ builder.register_step_handler("math", increment)
+ builder.add_step("increment", "math")
+
+ result = ExecutionEngine().execute_pipeline(
+ builder.build("registered"), data=1
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.output, 2)
+
+ def test_explicit_handler_does_not_receive_control_fields(self):
+ """Builder-only fields should not be passed to strict handlers."""
+ def source(data):
+ return data
+
+ def increment(data, amount):
+ return data + amount
+
+ builder = PipelineBuilder()
+ builder.add_step("source", "source", handler=source)
+ step = builder.add_step(
+ "increment",
+ "math",
+ handler=increment,
+ dependencies=["source"],
+ amount=2,
+ )
+
+ result = ExecutionEngine().execute_pipeline(
+ builder.build("strict"), data=1
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.output, 3)
+ self.assertEqual(step.config, {"amount": 2})
+
+ def test_explicit_handler_overrides_registered_handler(self):
+ """An explicit step handler should take precedence over the registry."""
+ builder = PipelineBuilder()
+ builder.register_step_handler("math", lambda data: data + 100)
+ builder.add_step("increment", "math", handler=lambda data: data + 1)
+
+ result = ExecutionEngine().execute_pipeline(
+ builder.build("override"), data=1
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.output, 2)
+
+ def test_step_without_handler_passes_input_through(self):
+ """A step with no explicit or registered handler should be a no-op."""
+ builder = PipelineBuilder()
+ builder.add_step("passthrough", "unregistered")
+
+ result = ExecutionEngine().execute_pipeline(
+ builder.build("handlerless"), data={"value": 1}
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.output, {"value": 1})
+
+ def test_falsy_explicit_handler_is_invoked(self):
+ """A handler whose __bool__ is False must still be dispatched."""
+
+ class FalseyHandler:
+ def __bool__(self):
+ return False
+
+ def __call__(self, data):
+ return {"explicit": data}
+
+ builder = PipelineBuilder()
+ builder.add_step("step1", "mytype", handler=FalseyHandler())
+
+ result = ExecutionEngine().execute_pipeline(
+ builder.build("falsy"), data=1
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(result.output, {"explicit": 1})
+
def test_execution_engine_failure(self):
"""Test pipeline failure handling."""
def failing_handler(data, **kwargs):
@@ -112,8 +199,8 @@ class TestPipelineModule(unittest.TestCase):
_ = semantica.pipeline
- from semantica.pipeline import PipelineBuilder, PipelineValidator
from semantica.deduplication import DuplicateDetector
+ from semantica.pipeline import PipelineBuilder, PipelineValidator
builder = PipelineBuilder()
builder.add_step("step1", "dummy")
diff --git a/tests/pipeline/test_pipeline_serializer.py b/tests/pipeline/test_pipeline_serializer.py
new file mode 100644
index 00000000..fe616ddd
--- /dev/null
+++ b/tests/pipeline/test_pipeline_serializer.py
@@ -0,0 +1,103 @@
+import copy
+import json
+
+import pytest
+
+from semantica.pipeline.pipeline_builder import PipelineBuilder, PipelineSerializer
+
+
+@pytest.mark.parametrize("serialization_format", ["dict", "json"])
+def test_roundtrip_preserves_dependencies_and_delta_metadata(serialization_format):
+ builder = PipelineBuilder()
+ builder.add_step("extract", "source")
+ builder.add_step(
+ "index",
+ "sink",
+ delta_mode=True,
+ base_version_id="v1",
+ target_version_id="v2",
+ )
+ builder.connect_steps("extract", "index")
+ pipeline = builder.build("incremental-index")
+
+ serializer = PipelineSerializer()
+ serialized = serializer.serialize_pipeline(pipeline, format=serialization_format)
+ restored = serializer.deserialize_pipeline(serialized)
+
+ index_step = next(step for step in restored.steps if step.name == "index")
+ assert index_step.dependencies == ["extract"]
+ assert index_step.delta_mode is True
+ assert index_step.base_version_id == "v1"
+ assert index_step.target_version_id == "v2"
+
+
+@pytest.mark.parametrize("serialization_format", ["dict", "json"])
+def test_serialization_omits_runtime_handlers(serialization_format):
+ def handler(data, **config):
+ return data
+
+ builder = PipelineBuilder()
+ builder.add_step("extract", "source", handler=handler, batch_size=10)
+ pipeline = builder.build("handler-pipeline")
+
+ serializer = PipelineSerializer()
+ serialized = serializer.serialize_pipeline(pipeline, format=serialization_format)
+ serialized_data = (
+ json.loads(serialized) if isinstance(serialized, str) else serialized
+ )
+
+ assert serialized_data["steps"][0]["config"] == {"batch_size": 10}
+
+ restored = serializer.deserialize_pipeline(serialized)
+ assert restored.steps[0].handler is None
+ assert restored.steps[0].config == {"batch_size": 10}
+
+
+def test_deserialization_ignores_legacy_stringified_handler():
+ serialized = json.dumps(
+ {
+ "name": "legacy-handler-pipeline",
+ "steps": [
+ {
+ "name": "extract",
+ "type": "source",
+ "config": {
+ "handler": "",
+ "batch_size": 10,
+ },
+ "dependencies": [],
+ }
+ ],
+ }
+ )
+
+ restored = PipelineSerializer().deserialize_pipeline(serialized)
+
+ assert restored.steps[0].handler is None
+ assert restored.steps[0].config == {"batch_size": 10}
+
+
+def test_deserialization_does_not_mutate_caller_owned_dict():
+ payload = {
+ "name": "legacy-handler-pipeline",
+ "steps": [
+ {
+ "name": "extract",
+ "type": "source",
+ "config": {
+ "handler": "",
+ "batch_size": 10,
+ },
+ "dependencies": [],
+ }
+ ],
+ }
+ snapshot = copy.deepcopy(payload)
+
+ restored = PipelineSerializer().deserialize_pipeline(payload)
+
+ assert payload == snapshot
+ assert "handler" in payload["steps"][0]["config"]
+ assert payload is not snapshot
+ assert restored.steps[0].handler is None
+ assert restored.steps[0].config == {"batch_size": 10}
diff --git a/tests/reasoning/test_rule_actions.py b/tests/reasoning/test_rule_actions.py
new file mode 100644
index 00000000..b4e11846
--- /dev/null
+++ b/tests/reasoning/test_rule_actions.py
@@ -0,0 +1,531 @@
+"""Tests for rule-driven actions (production-rule behaviour) on the Reasoner.
+
+Covers the L1 Action layer (Assert/Retract/Call/Emit), provenance logging of
+fired actions (L2), and backward compatibility with the legacy Rule.handler
+callback.
+"""
+
+import unittest
+from collections import UserDict
+
+from semantica.reasoning import (
+ AssertAction,
+ CallAction,
+ EmitEventAction,
+ Fact,
+ Match,
+ Reasoner,
+ ReteEngine,
+ RetractAction,
+)
+
+
+class TestRuleActions(unittest.TestCase):
+ def setUp(self):
+ self.reasoner = Reasoner()
+
+ def _add_person_parent_facts(self):
+ self.reasoner.add_fact("Person(John)")
+ self.reasoner.add_fact("Parent(John, Jane)")
+
+ def test_assert_action_fires_and_substitutes_bindings(self):
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [AssertAction("Adult(?x)")]
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ # Action-asserted fact uses the match bindings (?x -> John).
+ self.assertIn("Adult(John)", self.reasoner.facts)
+
+ def test_retract_action_removes_fact(self):
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [RetractAction("Person(?x)")]
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ self.assertNotIn("Person(John)", self.reasoner.facts)
+
+ def test_call_action_invoked_with_bindings(self):
+ seen = {}
+
+ def record(bindings, reasoner):
+ seen.update(bindings)
+
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [CallAction(record, name="record")]
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ self.assertEqual(seen.get("x"), "John")
+ self.assertEqual(seen.get("y"), "Jane")
+
+ def test_emit_event_action_delivers_to_sink(self):
+ events = []
+ self.reasoner.on_event(lambda name, payload: events.append((name, payload)))
+
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [EmitEventAction("child_derived:?y")]
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ self.assertEqual(len(events), 1)
+ name, payload = events[0]
+ self.assertEqual(name, "child_derived:Jane")
+ self.assertEqual(payload["bindings"]["x"], "John")
+
+ def test_assert_action_write_back_to_knowledge_graph(self):
+ class FakeKG:
+ def __init__(self):
+ self.added = []
+
+ def add_fact(self, fact):
+ self.added.append(fact)
+
+ kg = FakeKG()
+ reasoner = Reasoner(knowledge_graph=kg)
+ rule = reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [AssertAction("Adult(?x)", write_back=True)]
+ reasoner.add_fact("Person(John)")
+ reasoner.add_fact("Parent(John, Jane)")
+
+ reasoner.forward_chain()
+
+ self.assertIn("Adult(John)", kg.added)
+
+ def test_provenance_logs_fired_actions(self):
+ reasoner = Reasoner(provenance=True)
+ rule = reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [AssertAction("Adult(?x)")]
+ reasoner.add_fact("Person(John)")
+ reasoner.add_fact("Parent(John, Jane)")
+
+ reasoner.forward_chain()
+
+ self.assertEqual(len(reasoner.action_log), 1)
+ entry = reasoner.action_log[0]
+ self.assertEqual(entry["action"], "AssertAction")
+ self.assertEqual(entry["rule_id"], rule.rule_id)
+ self.assertEqual(entry["bindings"]["x"], "John")
+ self.assertIn("Adult(John)", entry["description"])
+
+ def test_repeated_forward_chain_fires_same_activation_once(self):
+ calls = []
+
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [
+ CallAction(lambda bindings, reasoner: calls.append(dict(bindings)))
+ ]
+ self.reasoner.add_fact("Person(John)")
+
+ self.reasoner.forward_chain()
+ self.reasoner.forward_chain()
+
+ self.assertEqual(calls, [{"x": "John"}])
+
+ def test_repeated_forward_chain_records_provenance_once(self):
+ reasoner = Reasoner(provenance=True)
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [AssertAction("Verified(?x)")]
+ reasoner.add_fact("Person(John)")
+
+ reasoner.forward_chain()
+ reasoner.forward_chain()
+
+ self.assertEqual(len(reasoner.action_log), 1)
+
+ def test_new_binding_creates_a_new_activation(self):
+ calls = []
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [
+ CallAction(lambda bindings, reasoner: calls.append(bindings["x"]))
+ ]
+ self.reasoner.add_fact("Person(John)")
+
+ self.reasoner.forward_chain()
+ self.reasoner.add_fact("Person(Jane)")
+ self.reasoner.forward_chain()
+
+ self.assertCountEqual(calls, ["John", "Jane"])
+
+ def test_reset_action_history_allows_deliberate_replay(self):
+ calls = []
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(lambda bindings, reasoner: calls.append("called"))]
+ self.reasoner.add_fact("Person(John)")
+
+ self.reasoner.forward_chain()
+ self.reasoner.reset_action_history()
+ self.reasoner.forward_chain()
+
+ self.assertEqual(calls, ["called", "called"])
+
+ def test_clear_resets_action_history(self):
+ calls = []
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(lambda bindings, reasoner: calls.append("called"))]
+ self.reasoner.add_fact("Person(John)")
+ self.reasoner.forward_chain()
+
+ self.reasoner.clear()
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(lambda bindings, reasoner: calls.append("called"))]
+ self.reasoner.add_fact("Person(John)")
+ self.reasoner.forward_chain()
+
+ self.assertEqual(calls, ["called", "called"])
+
+ def test_failed_action_is_not_retried_without_explicit_reset(self):
+ attempts = []
+
+ def fail(bindings, reasoner):
+ attempts.append(bindings["x"])
+ raise RuntimeError("boom")
+
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(fail)]
+ self.reasoner.add_fact("Person(John)")
+
+ self.reasoner.forward_chain()
+ self.reasoner.forward_chain()
+ self.assertEqual(attempts, ["John"])
+
+ self.reasoner.reset_action_history()
+ self.reasoner.forward_chain()
+ self.assertEqual(attempts, ["John", "John"])
+
+ def test_replacing_actions_in_place_requires_explicit_history_reset(self):
+ calls = []
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(lambda bindings, reasoner: calls.append("first"))]
+ self.reasoner.add_fact("Person(John)")
+ self.reasoner.forward_chain()
+
+ rule.actions = [CallAction(lambda bindings, reasoner: calls.append("second"))]
+ self.reasoner.forward_chain()
+ self.assertEqual(calls, ["first"])
+
+ self.reasoner.reset_action_history()
+ self.reasoner.forward_chain()
+ self.assertEqual(calls, ["first", "second"])
+
+ def test_activation_is_recorded_before_reentrant_action_execution(self):
+ calls = []
+
+ def reenter(bindings, reasoner):
+ calls.append(bindings["x"])
+ if len(calls) == 1:
+ reasoner.forward_chain()
+
+ rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [CallAction(reenter)]
+ self.reasoner.add_fact("Person(John)")
+
+ self.reasoner.forward_chain()
+
+ self.assertEqual(calls, ["John"])
+
+ def test_no_provenance_log_when_disabled(self):
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [AssertAction("Adult(?x)")]
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ self.assertEqual(self.reasoner.action_log, [])
+
+ def test_legacy_handler_still_invoked(self):
+ calls = []
+
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.handler = lambda bindings, reasoner: calls.append(bindings)
+ self._add_person_parent_facts()
+
+ self.reasoner.forward_chain()
+
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls[0]["x"], "John")
+
+ def test_action_error_does_not_break_chain(self):
+ def boom(bindings, reasoner):
+ raise RuntimeError("boom")
+
+ rule = self.reasoner.add_rule(
+ "IF Person(?x) AND Parent(?x, ?y) THEN Child(?y, ?x)"
+ )
+ rule.actions = [CallAction(boom, name="boom"), AssertAction("Adult(?x)")]
+ self._add_person_parent_facts()
+
+ # A failing action is logged but must not abort the pass; the later
+ # action still runs and the conclusion is still derived.
+ self.reasoner.forward_chain()
+
+ self.assertIn("Adult(John)", self.reasoner.facts)
+ self.assertIn("Child(Jane, John)", self.reasoner.facts)
+
+
+class TestRuleActionRegressions(unittest.TestCase):
+ """Regression coverage for the qodo-flagged bugs on PR #1096."""
+
+ def test_variable_substitution_no_prefix_collision(self):
+ # bug7: naive str.replace of "?x" would also corrupt "?xy". A
+ # token-aware substitution must bind ?x and ?xy independently.
+ reasoner = Reasoner()
+ rule = reasoner.add_rule("IF Pair(?x, ?xy) THEN Linked(?x, ?xy)")
+ rule.actions = [AssertAction("Tag(?x, ?xy)")]
+ reasoner.add_fact("Pair(John, Johny)")
+
+ reasoner.forward_chain()
+
+ self.assertIn("Tag(John, Johny)", reasoner.facts)
+
+ def test_assert_write_back_to_canonical_knowledge_graph(self):
+ # bug1: a KG exposing only entities/relationships (no add_fact) must
+ # still receive the asserted fact via canonical translation.
+ from semantica.kg.knowledge_graph import KnowledgeGraph
+
+ kg = KnowledgeGraph()
+ reasoner = Reasoner(knowledge_graph=kg)
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [AssertAction("Adult(?x)", write_back=True)]
+ reasoner.add_fact("Person(John)")
+
+ reasoner.forward_chain()
+
+ # A single-argument fact lands as an entity node.
+ self.assertTrue(any("John" in str(e) for e in kg.entities))
+
+ def test_write_back_unsupported_target_raises(self):
+ # bug1: an unsupported write-back target must fail loudly, not silently.
+ from semantica.reasoning.reasoner import _write_fact_to_graph
+
+ with self.assertRaises(ValueError):
+ _write_fact_to_graph(object(), "Adult(John)")
+
+ def test_provenance_entry_has_timestamp(self):
+ # bug3: action_log entries must be structured with a timestamp.
+ reasoner = Reasoner(provenance=True)
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [AssertAction("Adult(?x)")]
+ reasoner.add_fact("Person(John)")
+
+ reasoner.forward_chain()
+
+ entry = reasoner.action_log[0]
+ self.assertIn("timestamp", entry)
+ self.assertTrue(entry["timestamp"])
+
+ def test_action_fires_even_when_conclusion_already_known(self):
+ # bug4: previously an activation whose conclusion was already known
+ # skipped firing its actions. Now it must still fire exactly once.
+ reasoner = Reasoner()
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [AssertAction("Verified(?x)")]
+ reasoner.add_fact("Person(John)")
+ # Conclusion already present before the pass runs.
+ reasoner.add_fact("Adult(John)")
+
+ reasoner.forward_chain()
+
+ self.assertIn("Verified(John)", reasoner.facts)
+
+ def test_retract_self_conclusion_terminates(self):
+ # bug5: a RetractAction removing its own premise previously re-fired
+ # every pass up to max_iterations. It must fire once and terminate.
+ reasoner = Reasoner()
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.actions = [RetractAction("Person(?x)")]
+ reasoner.add_fact("Person(John)")
+
+ # Should return promptly without exhausting iterations.
+ reasoner.forward_chain()
+
+ self.assertNotIn("Person(John)", reasoner.facts)
+
+ def test_infer_with_results_preserves_confidence(self):
+ # bug9: confidence must survive to the InferenceResult objects.
+ reasoner = Reasoner()
+ rule = reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ rule.confidence = 0.8
+
+ results = reasoner.infer_with_results(["Person(John)"])
+
+ self.assertTrue(results)
+ self.assertTrue(all(0.0 <= r.confidence <= 1.0 for r in results))
+ self.assertAlmostEqual(
+ min(r.confidence for r in results), 0.8, places=6
+ )
+
+
+class TestReteActionExecution(unittest.TestCase):
+ def setUp(self):
+ self.calls = []
+ self.reasoner = Reasoner()
+ self.rule = self.reasoner.add_rule("IF Person(?x) THEN Adult(?x)")
+ self.rule.actions = [
+ CallAction(
+ lambda bindings, reasoner: self.calls.append(dict(bindings))
+ )
+ ]
+ self.match = Match(
+ rule=self.rule,
+ facts=[Fact("person-1", "Person", ["John"])],
+ bindings={"x": "John"},
+ )
+ self.engine = ReteEngine(reasoner=self.reasoner)
+
+ def test_rete_repeated_execute_matches_fires_activation_once(self):
+ first_results = self.engine.execute_matches([self.match])
+ second_results = self.engine.execute_matches([self.match])
+
+ self.assertEqual(first_results, ["Adult(?x)"])
+ self.assertEqual(second_results, ["Adult(?x)"])
+ self.assertEqual(self.calls, [{"x": "John"}])
+
+ def test_rete_duplicate_match_preserves_results_but_fires_once(self):
+ results = self.engine.execute_matches([self.match, self.match])
+
+ self.assertEqual(results, ["Adult(?x)", "Adult(?x)"])
+ self.assertEqual(self.calls, [{"x": "John"}])
+
+ def test_rete_distinct_fact_ids_create_distinct_activations(self):
+ other_match = Match(
+ rule=self.rule,
+ facts=[Fact("person-2", "Person", ["John"])],
+ bindings={"x": "John"},
+ )
+
+ self.engine.execute_matches([self.match])
+ self.engine.execute_matches([other_match])
+
+ self.assertEqual(self.calls, [{"x": "John"}, {"x": "John"}])
+
+ def test_rete_equivalent_nested_bindings_share_an_activation(self):
+ first_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": {"a": 1, "b": 2}},
+ )
+ reordered_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": {"b": 2, "a": 1}},
+ )
+
+ self.engine.execute_matches([first_match])
+ self.engine.execute_matches([reordered_match])
+
+ self.assertEqual(len(self.calls), 1)
+
+ def test_rete_structured_fact_identity_avoids_separator_collisions(self):
+ first_match = Match(
+ rule=self.rule,
+ facts=[Fact("a", "b:C", [])],
+ bindings={"x": "John"},
+ )
+ colliding_text_match = Match(
+ rule=self.rule,
+ facts=[Fact("a:b", "C", [])],
+ bindings={"x": "John"},
+ )
+
+ self.engine.execute_matches([first_match])
+ self.engine.execute_matches([colliding_text_match])
+
+ self.assertEqual(len(self.calls), 2)
+
+ def test_rete_cyclic_binding_preserves_results_and_deduplicates_actions(self):
+ cyclic_value = []
+ cyclic_value.append(cyclic_value)
+ cyclic_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": cyclic_value},
+ )
+
+ first_results = self.engine.execute_matches([cyclic_match])
+ second_results = self.engine.execute_matches([cyclic_match])
+
+ self.assertEqual(first_results, ["Adult(?x)"])
+ self.assertEqual(second_results, ["Adult(?x)"])
+ self.assertEqual(len(self.calls), 1)
+
+ def test_rete_equivalent_mapping_implementations_share_an_activation(self):
+ first_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": UserDict({"a": 1, "b": 2})},
+ )
+ reordered_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": UserDict({"b": 2, "a": 1})},
+ )
+
+ self.engine.execute_matches([first_match])
+ self.engine.execute_matches([reordered_match])
+
+ self.assertEqual(len(self.calls), 1)
+
+ def test_rete_key_error_does_not_suppress_conclusion(self):
+ class UnrepresentableValue:
+ def __repr__(self):
+ raise RuntimeError("cannot represent")
+
+ invalid_match = Match(
+ rule=self.rule,
+ facts=self.match.facts,
+ bindings={"x": UnrepresentableValue()},
+ )
+
+ results = self.engine.execute_matches([invalid_match])
+
+ self.assertEqual(results, ["Adult(?x)"])
+ self.assertEqual(self.calls, [])
+
+ def test_rete_reset_action_history_allows_deliberate_replay(self):
+ self.engine.execute_matches([self.match])
+
+ self.engine.reset_action_history()
+ self.engine.execute_matches([self.match])
+
+ self.assertEqual(self.calls, [{"x": "John"}, {"x": "John"}])
+
+ def test_rete_reset_allows_action_replay(self):
+ self.engine.execute_matches([self.match])
+
+ self.engine.reset()
+ self.engine.execute_matches([self.match])
+
+ self.assertEqual(self.calls, [{"x": "John"}, {"x": "John"}])
+
+ def test_rete_build_network_allows_action_replay(self):
+ self.engine.execute_matches([self.match])
+
+ self.engine.build_network([self.rule])
+ self.engine.execute_matches([self.match])
+
+ self.assertEqual(self.calls, [{"x": "John"}, {"x": "John"}])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/reasoning/test_specialized_reasoners.py b/tests/reasoning/test_specialized_reasoners.py
index 3a519171..51dbdc9a 100644
--- a/tests/reasoning/test_specialized_reasoners.py
+++ b/tests/reasoning/test_specialized_reasoners.py
@@ -30,6 +30,29 @@ class TestSpecializedReasoners(unittest.TestCase):
binding_types = [b.get("x_type") for b in inferred.bindings]
self.assertIn("Human", binding_types)
+ def test_execute_query_raises_not_implemented(self):
+ """Empty results must not pass as a valid answer (issue #1083).
+
+ Both branches returned ``SPARQLQueryResult(bindings=[], variables=[])``
+ -- with or without a triplet store -- so callers that trust an empty
+ result as "no matches" silently drew wrong conclusions. Until a real
+ execution path lands, refusing loudly is safer.
+ """
+ reasoner = SPARQLReasoner()
+ with self.assertRaises(NotImplementedError):
+ reasoner.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
+
+ def test_execute_query_with_triplet_store_raises_not_implemented(self):
+ reasoner = SPARQLReasoner(triplet_store=object())
+ with self.assertRaises(NotImplementedError):
+ reasoner.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
+
+ def test_execute_query_error_explains_why_the_query_is_refused(self):
+ reasoner = SPARQLReasoner()
+ with self.assertRaises(NotImplementedError) as ctx:
+ reasoner.execute_query("SELECT ?s WHERE { ?s ?p ?o }")
+ self.assertIn("not implemented", str(ctx.exception))
+
def test_abductive_reasoner_generate_hypotheses(self):
reasoner = AbductiveReasoner()
reasoner.reasoner.add_rule("IF Disease(Flu) THEN Symptom(Fever)")
diff --git a/tests/reproduce_issue_176.py b/tests/reproduce_issue_176.py
deleted file mode 100644
index a24ba7c8..00000000
--- a/tests/reproduce_issue_176.py
+++ /dev/null
@@ -1,100 +0,0 @@
-
-import unittest
-from unittest.mock import MagicMock, patch
-from semantica.semantic_extract.methods import extract_relations_llm, extract_entities_llm, extract_triplets_llm
-from semantica.semantic_extract.ner_extractor import Entity
-
-class TestMaxTokensPropagation(unittest.TestCase):
- @patch("semantica.semantic_extract.methods.create_provider")
- def test_max_tokens_propagation_relations(self, mock_create_provider):
- """Test that max_tokens is passed to generate_typed in extract_relations_llm."""
- # Setup mock
- mock_llm = MagicMock()
- mock_create_provider.return_value = mock_llm
- mock_llm.is_available.return_value = True
-
- # Setup return value to avoid pydantic validation errors
- mock_response = MagicMock()
- mock_response.relations = []
- mock_llm.generate_typed.return_value = mock_response
-
- # Create dummy entities
- entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
-
- # Call the function with max_tokens
- extract_relations_llm(
- text="some text",
- entities=entities,
- provider="openai",
- model="gpt-4",
- max_tokens=128000
- )
-
- # Check if generate_typed was called with max_tokens
- args, kwargs = mock_llm.generate_typed.call_args
-
- print(f"Relations Call kwargs: {kwargs}")
-
- self.assertIn("max_tokens", kwargs)
- self.assertEqual(kwargs["max_tokens"], 128000)
-
- @patch("semantica.semantic_extract.methods.create_provider")
- def test_max_tokens_propagation_entities(self, mock_create_provider):
- """Test that max_tokens is passed to generate_typed in extract_entities_llm."""
- # Setup mock
- mock_llm = MagicMock()
- mock_create_provider.return_value = mock_llm
- mock_llm.is_available.return_value = True
-
- # Setup return value to avoid pydantic validation errors
- mock_response = MagicMock()
- mock_response.entities = []
- mock_llm.generate_typed.return_value = mock_response
-
- # Call the function with max_tokens
- extract_entities_llm(
- text="some text",
- provider="openai",
- model="gpt-4",
- max_tokens=128000
- )
-
- # Check if generate_typed was called with max_tokens
- args, kwargs = mock_llm.generate_typed.call_args
-
- print(f"Entities Call kwargs: {kwargs}")
-
- self.assertIn("max_tokens", kwargs)
- self.assertEqual(kwargs["max_tokens"], 128000)
-
- @patch("semantica.semantic_extract.methods.create_provider")
- def test_max_tokens_propagation_triplets(self, mock_create_provider):
- """Test that max_tokens is passed to generate_typed in extract_triplets_llm."""
- # Setup mock
- mock_llm = MagicMock()
- mock_create_provider.return_value = mock_llm
- mock_llm.is_available.return_value = True
-
- # Setup return value to avoid pydantic validation errors
- mock_response = MagicMock()
- mock_response.triplets = []
- mock_llm.generate_typed.return_value = mock_response
-
- # Call the function with max_tokens
- extract_triplets_llm(
- text="some text",
- provider="openai",
- model="gpt-4",
- max_tokens=128000
- )
-
- # Check if generate_typed was called with max_tokens
- args, kwargs = mock_llm.generate_typed.call_args
-
- print(f"Triplets Call kwargs: {kwargs}")
-
- self.assertIn("max_tokens", kwargs)
- self.assertEqual(kwargs["max_tokens"], 128000)
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py
index 44e6d2a9..da5e1d9c 100644
--- a/tests/test_cli_commands.py
+++ b/tests/test_cli_commands.py
@@ -1851,3 +1851,522 @@ class TestExitCodes:
assert "Traceback" not in result.output, (
f"Traceback found for {argv}: {result.output}"
)
+
+
+class TestDoctorEmbeddings:
+ """#994: doctor must surface non-functional embedding backends instead of
+ reporting all green. Default = import-level check; --deep-embeddings (or
+ SEMANTICA_DOCTOR_DEEP_EMBEDDINGS=1) instantiates via TextEmbedder."""
+
+ def _doctor_checks(self, runner, *extra):
+ result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
+ _ok(result)
+ import json as _json
+ return {c["check"]: c for c in _json.loads(result.output)}
+
+ def _with_fake_st(self, monkeypatch, **embedder_attrs):
+ fake_st = _fake_module(
+ __version__="9.9.9",
+ SentenceTransformer=object,
+ )
+ monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
+
+ def test_doctor_reports_embedding_checks(self, runner):
+ checks = self._doctor_checks(runner)
+ assert "Embeddings (sentence-transformers)" in checks
+ assert "Embeddings (fastembed)" in checks
+
+ def test_import_failure_is_fail_status_with_hint(self, runner, monkeypatch):
+ # Force the 'import sentence_transformers' inside _embedding_backend to
+ # raise ImportError regardless of whether the package is installed on
+ # this machine. Setting a module entry to None is the standard Python
+ # mechanism: any subsequent 'import ' raises
+ # "import of halted; None in sys.modules".
+ monkeypatch.setitem(
+ __import__("sys").modules, "sentence_transformers", None
+ )
+ checks = self._doctor_checks(runner)
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "fail"
+ assert st["hint"] == "pip install sentence-transformers"
+
+ def test_deep_probe_detects_fallback_active(self, runner, monkeypatch):
+ self._with_fake_st(monkeypatch)
+ fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
+
+ fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
+
+ checks = self._doctor_checks(runner, "--deep-embeddings")
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "fail"
+ assert "hash fallback" in st["note"]
+
+ def test_deep_probe_ok_when_model_loads(self, runner, monkeypatch):
+ self._with_fake_st(monkeypatch)
+ import numpy as np
+ fake_embedder = types.SimpleNamespace(
+ model=object(),
+ fastembed_model=None,
+ embed_text=lambda text: np.zeros(384, dtype=np.float32),
+ )
+ fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
+
+ checks = self._doctor_checks(runner, "--deep-embeddings")
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "ok"
+ assert "384-dim" in st["note"]
+
+ def test_env_var_enables_deep_mode(self, runner, monkeypatch):
+ monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", "1")
+ self._with_fake_st(monkeypatch)
+ fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
+ fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
+
+ checks = self._doctor_checks(runner)
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "fail"
+ assert "hash fallback" in st["note"]
+
+
+class TestDoctorEmbeddingHintsAndEnv:
+ """Review follow-ups: deep failures must not carry the pip-install hint,
+ and the env toggle tolerates case/whitespace variants."""
+
+ def _doctor_checks(self, runner, *extra):
+ result = runner.invoke(cli_module.main, ["doctor", "--json", *extra])
+ _ok(result)
+ import json as _json
+ return {c["check"]: c for c in _json.loads(result.output)}
+
+ def _with_fake_st(self, monkeypatch):
+ fake_st = _fake_module(
+ __version__="9.9.9",
+ SentenceTransformer=object,
+ )
+ monkeypatch.setitem(__import__("sys").modules, "sentence_transformers", fake_st)
+
+ def test_deep_failure_hint_is_not_pip_install(self, runner, monkeypatch):
+ self._with_fake_st(monkeypatch)
+ fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
+ fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
+
+ checks = self._doctor_checks(runner, "--deep-embeddings")
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "fail"
+ assert "pip install" not in (st["hint"] or ""), (
+ "a deep probe failure means the package imported fine โ pointing "
+ "users at pip sends them to reinstall for a runtime/model problem"
+ )
+ assert "runtime/model-load" in st["hint"]
+
+ def test_env_var_tolerates_case_and_whitespace(self, runner, monkeypatch):
+ monkeypatch.setenv("SEMANTICA_DOCTOR_DEEP_EMBEDDINGS", " TRUE ")
+ self._with_fake_st(monkeypatch)
+ fake_embedder = types.SimpleNamespace(model=None, fastembed_model=None)
+ fake_emb_mod = _fake_module(TextEmbedder=lambda **k: fake_embedder)
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb_mod)
+
+ checks = self._doctor_checks(runner)
+ st = checks["Embeddings (sentence-transformers)"]
+ assert st["status"] == "fail"
+ assert "hash fallback" in st["note"], "padded/caps env value must enable deep mode"
+
+
+class TestEmbedGenerateOutput:
+ """#994: `embed generate --output` must write files `embed index` can read."""
+
+ def _patch_generate(self, monkeypatch, retval):
+ import numpy as np
+ fake_emb = _fake_module(generate_embeddings=lambda *a, **k: np.asarray(retval))
+ monkeypatch.setitem(__import__("sys").modules, "semantica.embeddings", fake_emb)
+
+ def test_writes_valid_parquet(self, runner, monkeypatch, tmp_path):
+ pytest.importorskip("pyarrow", reason="parquet writer regression needs pyarrow")
+ import numpy as np
+ import pandas as pd
+ self._patch_generate(monkeypatch, [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
+ out = tmp_path / "embeddings.parquet"
+ result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
+ _ok(result)
+ df = pd.read_parquet(out)
+ assert "embedding" in df.columns
+ assert len(df) == 2
+ # Use allclose: the writer may store float32 or float64 depending on
+ # the model backend; exact == fails for float32-precision values.
+ assert np.allclose(df["embedding"].iloc[0], [0.1, 0.2, 0.3], atol=1e-6)
+
+ def test_writes_1d_result_as_single_row_parquet(self, runner, monkeypatch, tmp_path):
+ pytest.importorskip("pyarrow", reason="parquet writer regression needs pyarrow")
+ import numpy as np
+ import pandas as pd
+ self._patch_generate(monkeypatch, [0.1, 0.2, 0.3])
+ out = tmp_path / "embeddings.parquet"
+ result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
+ _ok(result)
+ df = pd.read_parquet(out)
+ assert len(df) == 1
+ assert np.allclose(df["embedding"].iloc[0], [0.1, 0.2, 0.3], atol=1e-6)
+
+ def test_writes_json_records_not_repr_strings(self, runner, monkeypatch, tmp_path):
+ import json as _json
+ import numpy as np
+ import pandas as pd
+ self._patch_generate(monkeypatch, [[0.1, 0.2], [0.3, 0.4]])
+ out = tmp_path / "embeddings.json"
+ result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
+ _ok(result)
+ records = _json.loads(out.read_text(encoding="utf-8"))
+ assert records == [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]
+ # Verify embed index can read the file back (round-trip contract).
+ df = pd.read_json(out, orient="records")
+ vector_col = next(
+ (c for c in df.columns if isinstance(df[c].iloc[0], (list, np.ndarray))),
+ None,
+ )
+ assert vector_col == "embedding", (
+ f"embed index would not find a vector column; got columns {list(df.columns)}"
+ )
+
+ def test_rejects_unsupported_output_format(self, runner, monkeypatch, tmp_path):
+ self._patch_generate(monkeypatch, [[0.1, 0.2]])
+ out = tmp_path / "embeddings.txt"
+ result = runner.invoke(cli_module.main, ["embed", "generate", "in.json", "--output", str(out)])
+ assert result.exit_code != 0
+ assert "Unsupported output format" in result.output
+ assert not out.exists()
+
+
+class TestWriteResultOutput:
+ """Unit-level regression tests for _write_result_output().
+
+ Covers every branch: JSON, JSONL, CSV, unsupported extension, no-extension,
+ dict+JSONL, empty list, NumPy scalar/array values, and round-trip readback.
+ """
+
+ # โโ helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def _write(self, tmp_path, filename, result):
+ """Call _write_result_output and return the output Path."""
+ from semantica.cli import _write_result_output
+ out = tmp_path / filename
+ _write_result_output(out, result)
+ return out
+
+ # โโ JSON โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def test_json_dict_produces_valid_json(self, tmp_path):
+ import json
+ out = self._write(tmp_path, "r.json", {"pairs": 3, "score": 0.9})
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert data == {"pairs": 3, "score": 0.9}
+
+ def test_json_list_produces_valid_json(self, tmp_path):
+ import json
+ out = self._write(tmp_path, "r.json", [{"a": 1}, {"a": 2}])
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert data == [{"a": 1}, {"a": 2}]
+
+ def test_json_numpy_scalar_serialises_as_number_not_repr(self, tmp_path):
+ """np.float32 values must round-trip as JSON numbers, not repr strings."""
+ import json
+ import numpy as np
+ out = self._write(tmp_path, "r.json", {"score": np.float32(0.95)})
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert isinstance(data["score"], float), (
+ f"expected float, got {type(data['score'])}: {data['score']!r}"
+ )
+ assert abs(data["score"] - 0.95) < 1e-4
+
+ def test_json_numpy_array_serialises_as_list_not_repr(self, tmp_path):
+ """np.ndarray values must round-trip as JSON arrays, not '[0.1 0.2]' repr."""
+ import json
+ import numpy as np
+ out = self._write(tmp_path, "r.json", {"vec": np.array([0.1, 0.2, 0.3])})
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert isinstance(data["vec"], list), (
+ f"expected list, got {type(data['vec'])}: {data['vec']!r}"
+ )
+ assert len(data["vec"]) == 3
+
+ # โโ JSONL โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def test_jsonl_list_writes_one_object_per_line(self, tmp_path):
+ """Each item in a list result must occupy exactly one JSONL line."""
+ import json
+ records = [{"id": "a", "score": 0.9}, {"id": "b", "score": 0.7}]
+ out = self._write(tmp_path, "r.jsonl", records)
+ lines = [l for l in out.read_text(encoding="utf-8").splitlines() if l.strip()]
+ assert len(lines) == 2
+ assert json.loads(lines[0]) == {"id": "a", "score": 0.9}
+ assert json.loads(lines[1]) == {"id": "b", "score": 0.7}
+
+ def test_jsonl_dict_writes_exactly_one_line(self, tmp_path):
+ """A dict result (e.g. ontology_align) must write one JSON object on one line,
+ not a pretty-printed multi-line block that pd.read_json(lines=True) cannot parse."""
+ import json
+ import pandas as pd
+ result = {"total_entities": 10, "duplicate_pairs": 3}
+ out = self._write(tmp_path, "r.jsonl", result)
+ raw = out.read_text(encoding="utf-8")
+ lines = [l for l in raw.splitlines() if l.strip()]
+ # Exactly one line
+ assert len(lines) == 1, (
+ f"Expected 1 JSONL line for dict result, got {len(lines)}:\n{raw!r}"
+ )
+ # That line parses as valid JSON
+ parsed = json.loads(lines[0])
+ assert parsed == result
+ # pd.read_json(lines=True) can read it back
+ df = pd.read_json(out, lines=True)
+ assert list(df.columns) == ["total_entities", "duplicate_pairs"]
+
+ def test_jsonl_numpy_values_are_not_repr_strings(self, tmp_path):
+ """NumPy values inside JSONL lines must be proper JSON, not repr()."""
+ import json
+ import numpy as np
+ records = [{"score": np.float32(0.8), "tag": "x"}]
+ out = self._write(tmp_path, "r.jsonl", records)
+ line = out.read_text(encoding="utf-8").strip()
+ parsed = json.loads(line)
+ assert isinstance(parsed["score"], float)
+
+ # โโ CSV โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def test_csv_list_of_dicts_produces_readable_csv(self, tmp_path):
+ import pandas as pd
+ rows = [{"entity_1": "Alice", "entity_2": "Bob", "similarity": 0.87},
+ {"entity_1": "Carol", "entity_2": "Dave", "similarity": 0.72}]
+ out = self._write(tmp_path, "r.csv", rows)
+ df = pd.read_csv(out)
+ assert list(df.columns) == ["entity_1", "entity_2", "similarity"]
+ assert len(df) == 2
+ assert abs(df["similarity"].iloc[0] - 0.87) < 1e-6
+
+ def test_csv_numpy_scalar_becomes_number_not_repr(self, tmp_path):
+ """np.float32 in a result row must not become a repr string in the CSV."""
+ import numpy as np
+ import pandas as pd
+ rows = [{"label": "x", "score": np.float32(0.95)}]
+ out = self._write(tmp_path, "r.csv", rows)
+ df = pd.read_csv(out)
+ # The cell must be a numeric type, not a string like 'np.float32(0.95)'
+ assert df["score"].dtype.kind in ("f", "i"), (
+ f"Expected numeric dtype, got {df['score'].dtype}: {df['score'].iloc[0]!r}"
+ )
+
+ def test_csv_empty_list_raises_clickexception(self, tmp_path):
+ """An empty result list must raise rather than create a headerless newline."""
+ import click
+ from semantica.cli import _write_result_output
+ out = tmp_path / "empty.csv"
+ with pytest.raises(click.ClickException, match="No results to write"):
+ _write_result_output(out, [])
+ assert not out.exists()
+
+ def test_csv_single_dict_written_as_one_row(self, tmp_path):
+ import pandas as pd
+ out = self._write(tmp_path, "r.csv", {"total": 5, "merged": 2})
+ df = pd.read_csv(out)
+ assert len(df) == 1
+ assert df["total"].iloc[0] == 5
+
+ # โโ unsupported / no-extension โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def test_unsupported_extension_raises_clickexception(self, tmp_path):
+ import click
+ from semantica.cli import _write_result_output
+ out = tmp_path / "report.txt"
+ with pytest.raises(click.ClickException, match="Unsupported output format"):
+ _write_result_output(out, {"k": "v"})
+ assert not out.exists()
+
+ def test_no_extension_raises_clickexception(self, tmp_path):
+ """No-extension paths must be rejected โ not silently renamed to .json โ
+ so the path reported to the user always matches the file created."""
+ import click
+ from semantica.cli import _write_result_output
+ out = tmp_path / "report"
+ with pytest.raises(click.ClickException, match="Unsupported output format"):
+ _write_result_output(out, {"k": "v"})
+ assert not out.exists()
+ assert not (tmp_path / "report.json").exists()
+
+ def test_txt_extension_raises_clickexception(self, tmp_path):
+ """.txt is not a documented format and must be rejected, consistent with
+ _write_embeddings_output which also rejects it."""
+ import click
+ from semantica.cli import _write_result_output
+ out = tmp_path / "r.txt"
+ with pytest.raises(click.ClickException, match="Unsupported output format"):
+ _write_result_output(out, {"k": "v"})
+ assert not out.exists()
+
+ def test_uppercase_extension_accepted(self, tmp_path):
+ """Extension matching must be case-insensitive (.CSV == .csv)."""
+ import pandas as pd
+ out = self._write(tmp_path, "r.CSV", [{"a": 1}])
+ df = pd.read_csv(out)
+ assert len(df) == 1
+
+
+class TestDeduplicateOutput:
+ """CLI-level regression tests for deduplicate --output integration.
+
+ Uses the same monkeypatching pattern as TestDeduplicate.test_detect_runtime_path:
+ patch _get_store and get_nodes at the graph_store.methods level, then patch
+ the deduplication module so no real model or DB is needed.
+ """
+
+ _ENTITIES = [
+ {"id": "e1", "name": "Alice", "type": "Person"},
+ {"id": "e2", "name": "Alice", "type": "Person"},
+ ]
+ _DETECT_RESULT = [
+ {"entity_1": "e1", "entity_2": "e2", "similarity": 0.9}
+ ]
+
+ def _patch_dedup(self, monkeypatch):
+ """Wire graph store + deduplication mocks for the detect action."""
+ entities = self._ENTITIES
+ detect_result = self._DETECT_RESULT
+
+ class FakeStore:
+ def get_nodes(self, labels=None, properties=None, limit=100, **opts):
+ return entities
+
+ monkeypatch.setattr(
+ "semantica.graph_store.methods._get_store", lambda: FakeStore()
+ )
+ monkeypatch.setattr(
+ "semantica.graph_store.methods.get_nodes", lambda **kw: entities
+ )
+ monkeypatch.setattr(
+ "semantica.deduplication.methods.detect_duplicates",
+ lambda *a, **k: detect_result,
+ raising=False,
+ )
+ # The CLI imports from .deduplication directly; patch that too.
+ import types
+ fake_dedup = _fake_module(detect_duplicates=lambda *a, **k: detect_result)
+ fake_merger_inst = types.SimpleNamespace(
+ merge_duplicates=lambda *a, **k: detect_result
+ )
+ fake_dedup.entity_merger = types.SimpleNamespace(
+ EntityMerger=lambda: fake_merger_inst
+ )
+ monkeypatch.setitem(
+ __import__("sys").modules, "semantica.deduplication", fake_dedup
+ )
+ monkeypatch.setitem(
+ __import__("sys").modules,
+ "semantica.deduplication.entity_merger",
+ fake_dedup.entity_merger,
+ )
+
+ def test_deduplicate_output_json_is_valid(self, runner, monkeypatch, tmp_path):
+ """deduplicate --output report.json must produce parseable JSON, not a repr."""
+ import json
+ self._patch_dedup(monkeypatch)
+ out = tmp_path / "report.json"
+ result = runner.invoke(
+ cli_module.main,
+ ["deduplicate", "--action", "detect", "--output", str(out)],
+ )
+ _ok(result)
+ assert out.exists(), f"output file not created; output: {result.output!r}"
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert isinstance(data, (list, dict))
+
+ def test_deduplicate_output_csv_is_readable(self, runner, monkeypatch, tmp_path):
+ """deduplicate --output report.csv (documented format) must produce valid CSV."""
+ import pandas as pd
+ self._patch_dedup(monkeypatch)
+ out = tmp_path / "report.csv"
+ result = runner.invoke(
+ cli_module.main,
+ ["deduplicate", "--action", "detect", "--output", str(out)],
+ )
+ _ok(result)
+ assert out.exists(), f"CSV file not created; output: {result.output!r}"
+ df = pd.read_csv(out)
+ assert len(df) >= 1
+
+
+class TestOntologyAlignOutput:
+ """CLI-level regression tests for ontology align --output integration.
+
+ Uses runner.isolated_filesystem() so Click's exists=True source/target
+ validation passes, then patches semantica.ontology at the sys.modules level
+ before the import inside _action() fires โ same pattern as
+ TestOntology.test_align_import_error_is_clean.
+ """
+
+ _ALIGN_RESULT = {
+ "alignments": [{"source": "A", "target": "B", "score": 0.8}],
+ "total": 1,
+ }
+
+ def _patch_align(self, monkeypatch, align_result=None):
+ result = align_result if align_result is not None else self._ALIGN_RESULT
+ import types
+ fake_gen = types.SimpleNamespace(align=lambda *a, **k: result)
+ fake_ontology = _fake_module(
+ OntologyGenerator=lambda **k: fake_gen,
+ )
+ monkeypatch.setitem(
+ __import__("sys").modules, "semantica.ontology", fake_ontology
+ )
+
+ def test_ontology_align_output_json_is_valid(self, runner, monkeypatch, tmp_path):
+ """ontology align --output alignments.json must produce parseable JSON."""
+ import json
+ self._patch_align(monkeypatch)
+ out = tmp_path / "alignments.json"
+ with runner.isolated_filesystem():
+ open("s.ttl", "w").close()
+ open("t.ttl", "w").close()
+ result = runner.invoke(
+ cli_module.main,
+ ["ontology", "align",
+ "--source", "s.ttl", "--target", "t.ttl",
+ "--output", str(out)],
+ )
+ _ok(result)
+ assert out.exists(), f"output file not created; output: {result.output!r}"
+ data = json.loads(out.read_text(encoding="utf-8"))
+ assert isinstance(data, dict)
+ assert "alignments" in data
+
+ def test_ontology_align_output_jsonl_is_readable_by_pandas(
+ self, runner, monkeypatch, tmp_path
+ ):
+ """ontology align --output alignments.jsonl must produce valid JSONL:
+ exactly one JSON object per line, readable by pd.read_json(lines=True).
+ Regression for F2: dict result must NOT be pretty-printed across multiple
+ lines into a .jsonl file."""
+ import pandas as pd
+ self._patch_align(monkeypatch)
+ out = tmp_path / "alignments.jsonl"
+ with runner.isolated_filesystem():
+ open("s.ttl", "w").close()
+ open("t.ttl", "w").close()
+ result = runner.invoke(
+ cli_module.main,
+ ["ontology", "align",
+ "--source", "s.ttl", "--target", "t.ttl",
+ "--output", str(out)],
+ )
+ _ok(result)
+ assert out.exists(), f"JSONL file not created; output: {result.output!r}"
+ raw = out.read_text(encoding="utf-8")
+ lines = [ln for ln in raw.splitlines() if ln.strip()]
+ assert len(lines) == 1, (
+ f"Expected exactly 1 JSONL line for a dict result, got {len(lines)}:\n{raw!r}"
+ )
+ # pd.read_json(lines=True) must succeed โ this is what the F2 bug broke.
+ df = pd.read_json(out, lines=True)
+ assert "alignments" in df.columns
diff --git a/tests/test_embedding_providers.py b/tests/test_embedding_providers.py
index e41de0db..6fcc8735 100644
--- a/tests/test_embedding_providers.py
+++ b/tests/test_embedding_providers.py
@@ -85,3 +85,134 @@ if __name__ == '__main__':
runner = unittest.TextTestRunner(stream=f, verbosity=2)
unittest.main(testRunner=runner, exit=False)
+
+class TestMethodDispatchRecursion(unittest.TestCase):
+ """#994: built-in aliases are registered in the method registry onto the
+ wrapper functions themselves, so dispatching through the registry called a
+ wrapper back into itself with the same default method โ a recursion storm
+ that surfaced as `maximum recursion depth exceeded` during model loading."""
+
+ def test_generate_embeddings_default_does_not_self_recurse(self):
+ from semantica.embeddings.methods import generate_embeddings
+ emb = generate_embeddings("recursion probe")
+ self.assertIsNotNone(emb)
+
+ def test_embed_text_default_does_not_self_recurse(self):
+ # Use the deterministic hash fallback to avoid model download;
+ # "fallback" is registered as embed_text itself, so the identity
+ # guard is the thing being tested โ no sentence-transformers needed.
+ from semantica.embeddings.methods import embed_text
+ emb = embed_text("recursion probe", method="fallback")
+ self.assertIsNotNone(emb)
+
+ def test_custom_registered_method_still_wins(self):
+ from semantica.embeddings.methods import method_registry
+ calls = []
+
+ def spy(data, *a, **k):
+ calls.append(data)
+ return {"custom": True}
+
+ method_registry.register("generation", "my_custom_gen", spy)
+ try:
+ from semantica.embeddings.methods import generate_embeddings
+ out = generate_embeddings("payload", method="my_custom_gen")
+ self.assertEqual(out, {"custom": True})
+ self.assertEqual(calls, ["payload"])
+ finally:
+ method_registry.unregister("generation", "my_custom_gen")
+
+ def test_provenance_wrapper_missing_generator_raises_attribute_error(self):
+ # Partially-initialised wrappers (failed __init__, pickle/copy probes)
+ # must raise AttributeError, not RecursionError via __getattr__.
+ from semantica.embeddings.embeddings_provenance import (
+ EmbeddingGeneratorWithProvenance,
+ )
+ bare = EmbeddingGeneratorWithProvenance.__new__(
+ EmbeddingGeneratorWithProvenance
+ )
+ with self.assertRaises(AttributeError):
+ getattr(bare, "model")
+
+ def test_calculate_similarity_cosine_does_not_self_recurse(self):
+ """calculate_similarity is registered under "cosine"/"euclidean" โ the
+ identity guard must prevent infinite recursion when those aliases fire."""
+ import numpy as np
+ from semantica.embeddings.methods import calculate_similarity
+ e1 = np.array([1.0, 0.0, 0.0])
+ e2 = np.array([0.0, 1.0, 0.0])
+ result = calculate_similarity(e1, e2, method="cosine")
+ self.assertIsNotNone(result)
+
+ def test_pool_embeddings_mean_does_not_self_recurse(self):
+ """pool_embeddings is registered under all pooling aliases โ the identity
+ guard must prevent infinite recursion for every built-in pooling method."""
+ import numpy as np
+ from semantica.embeddings.methods import pool_embeddings
+ embs = np.array([[1.0, 2.0], [3.0, 4.0]])
+ result = pool_embeddings(embs, method="mean")
+ self.assertIsNotNone(result)
+
+
+class TestDeduplicationDispatchRecursion(unittest.TestCase):
+ """Indirect recursion in deduplication/methods.py: the private wrapper
+ functions (_multi_factor_similarity, _pairwise_detection, _graph_based_clustering)
+ are registered as handlers under their respective default method names and
+ call back into the public dispatch functions with the same method, creating
+ an indirect infinite recursion loop without an identity guard."""
+
+ def test_calculate_similarity_multi_factor_does_not_recurse(self):
+ """_multi_factor_similarity is registered under 'similarity/multi_factor'
+ and calls calculate_similarity(method='multi_factor'), which without a
+ guard would re-enter _multi_factor_similarity infinitely."""
+ from semantica.deduplication.methods import calculate_similarity
+ e1 = {"name": "Apple Inc.", "type": "Company"}
+ e2 = {"name": "Apple", "type": "Company"}
+ result = calculate_similarity(e1, e2, method="multi_factor")
+ self.assertIsNotNone(result)
+
+ def test_detect_duplicates_pairwise_does_not_recurse(self):
+ """_pairwise_detection is registered under 'detection/pairwise' and
+ calls detect_duplicates(method='pairwise') โ indirect loop without guard."""
+ from semantica.deduplication.methods import detect_duplicates
+ entities = [
+ {"id": "1", "name": "Alice"},
+ {"id": "2", "name": "Bob"},
+ ]
+ result = detect_duplicates(entities, method="pairwise")
+ self.assertIsNotNone(result)
+
+ def test_build_clusters_graph_based_does_not_recurse(self):
+ """_graph_based_clustering is registered under 'clustering/graph_based'
+ and calls build_clusters(method='graph_based') โ indirect loop without guard."""
+ from semantica.deduplication.methods import build_clusters
+ entities = [
+ {"id": "1", "name": "Alice"},
+ {"id": "2", "name": "Bob"},
+ ]
+ result = build_clusters(entities, method="graph_based")
+ self.assertIsNotNone(result)
+
+ def test_custom_deduplication_method_still_wins(self):
+ """A genuinely user-registered custom method must still take precedence
+ over the built-in implementation after the guard is added."""
+ from semantica.deduplication.methods import (
+ calculate_similarity,
+ )
+ from semantica.deduplication.registry import method_registry
+ calls = []
+
+ def spy(e1, e2, **kw):
+ calls.append((e1, e2))
+ from semantica.deduplication.similarity_calculator import SimilarityResult
+ return SimilarityResult(score=0.99, method="spy")
+
+ method_registry.register("similarity", "spy_method", spy)
+ try:
+ e1 = {"name": "Alice"}
+ e2 = {"name": "Alice"}
+ result = calculate_similarity(e1, e2, method="spy_method")
+ self.assertEqual(result.score, 0.99)
+ self.assertEqual(len(calls), 1)
+ finally:
+ method_registry.unregister("similarity", "spy_method")
diff --git a/tests/test_mcp_package_export_graph.py b/tests/test_mcp_package_export_graph.py
new file mode 100644
index 00000000..b8d63228
--- /dev/null
+++ b/tests/test_mcp_package_export_graph.py
@@ -0,0 +1,232 @@
+"""Regression tests for the standalone mcp/ package export_graph tool.
+
+The mcp/ server (python -m mcp / python -m mcp.server) had two failures on
+every RDF export format:
+
+ 1. AttributeError: 'ContextGraph' object has no attribute 'get'
+ handle_export_graph() in mcp/tools/export.py called
+ RDFExporter().export_to_rdf(graph, ...) passing the raw ContextGraph
+ object instead of the canonical kg dict expected by the exporter.
+
+ 2. stdout progress corruption
+ RDFExporter.__init__ instantiated the Semantica progress-tracker
+ singleton, which wrote a progress bar to sys.stdout before the
+ AttributeError was raised. stdout is the MCP stdio JSON-RPC transport,
+ so this interleaved non-JSON bytes corrupted framing for every client.
+
+Fixes applied:
+ - mcp/tools/export.py: convert with graph.to_kg_dict() before export_to_rdf()
+ - mcp/__init__.py: os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1" at
+ package initialisation, before any tool handler can instantiate
+ RDFExporter and therefore before the tracker singleton is created.
+"""
+
+from __future__ import annotations
+
+import io
+import os
+import sys
+import subprocess
+import unittest
+
+import semantica.utils.progress_tracker as _progress_module
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_graph():
+ """Return a ContextGraph with two entities and one relationship."""
+ from semantica.context.context_graph import ContextGraph
+ g = ContextGraph()
+ g.add_node("n1", node_type="entity")
+ g.add_node("n2", node_type="entity")
+ g.add_edge("n1", "n2", "related_to")
+ return g
+
+
+def _reset_progress_singleton():
+ """Destroy any cached progress-tracker singleton so the next call
+ to get_progress_tracker() reads the current environment variable."""
+ _progress_module.ProgressTracker._instance = None
+ _progress_module._global_tracker = None
+
+
+# ---------------------------------------------------------------------------
+# RDF export correctness
+# ---------------------------------------------------------------------------
+
+class TestMCPPackageExportGraphRDF(unittest.TestCase):
+ """handle_export_graph() must return a non-empty RDF string for every
+ supported RDF format, not an error dict."""
+
+ def setUp(self):
+ # Inject a known graph into the mcp/ session so handlers don't try to
+ # build a full ContextGraph (which requires heavy ML dependencies).
+ import mcp.session as _session
+ self._orig_graph = _session._graph
+ _session._graph = _make_graph()
+
+ def tearDown(self):
+ import mcp.session as _session
+ _session._graph = self._orig_graph
+
+ def test_turtle_returns_non_empty_string(self):
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "turtle"})
+ self.assertNotIn("error", result, result)
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+ # Turtle output must carry prefix declarations
+ self.assertIn("@prefix", result["data"])
+
+ def test_ttl_alias_returns_non_empty_string(self):
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "ttl"})
+ self.assertNotIn("error", result, result)
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_nt_returns_non_empty_string(self):
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "nt"})
+ self.assertNotIn("error", result, result)
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_xml_returns_non_empty_string(self):
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "xml"})
+ self.assertNotIn("error", result, result)
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_jsonld_returns_non_empty_string(self):
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "json-ld"})
+ self.assertNotIn("error", result, result)
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_all_rdf_formats_succeed(self):
+ from mcp.tools.export import handle_export_graph
+ for fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
+ with self.subTest(fmt=fmt):
+ result = handle_export_graph({"format": fmt})
+ self.assertNotIn("error", result, f"format={fmt}: {result}")
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_rdf_branch_does_not_raise_context_graph_attribute_error(self):
+ """The pre-fix code passed ContextGraph directly to export_to_rdf(),
+ causing AttributeError: 'ContextGraph' object has no attribute 'get'.
+ Verify that error does not appear in the result."""
+ from mcp.tools.export import handle_export_graph
+ result = handle_export_graph({"format": "turtle"})
+ if "error" in result:
+ self.assertNotIn("'ContextGraph' object has no attribute 'get'",
+ result["error"])
+
+
+# ---------------------------------------------------------------------------
+# stdout protection โ subprocess-based to avoid process-state cross-contamination
+# ---------------------------------------------------------------------------
+
+class TestMCPPackageStdoutProtection(unittest.TestCase):
+ """The standalone mcp/ server must not write any progress bytes to stdout.
+ stdout is the MCP JSON-RPC transport channel.
+
+ These tests use a subprocess to get a clean process state where
+ SEMANTICA_DISABLE_PROGRESS has not yet been set, so we can verify that
+ importing mcp and running an export produces no progress bytes on stdout.
+ """
+
+ def _run_in_subprocess(self, code: str, timeout: int = 30) -> subprocess.CompletedProcess:
+ """Run a Python snippet in a clean subprocess with the repo on sys.path."""
+ repo_root = os.path.abspath(
+ os.path.join(os.path.dirname(__file__), "..")
+ )
+ env = os.environ.copy()
+ env["PYTHONPATH"] = repo_root
+ # Start with a clean slate โ no pre-set disable flag
+ env.pop("SEMANTICA_DISABLE_PROGRESS", None)
+ return subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=repo_root,
+ env=env,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ check=False,
+ )
+
+ def test_importing_mcp_sets_disable_progress(self):
+ """Importing the mcp package must set SEMANTICA_DISABLE_PROGRESS=1
+ before any tool handler runs."""
+ code = (
+ "import os; "
+ "import mcp; " # triggers mcp/__init__.py
+ "print(os.environ.get('SEMANTICA_DISABLE_PROGRESS', 'NOT SET'))"
+ )
+ result = self._run_in_subprocess(code)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("1", result.stdout)
+
+ def test_rdf_export_writes_no_progress_to_stdout(self):
+ """An RDF export via handle_export_graph() must not write any Semantica
+ progress bytes to stdout. The only stdout bytes should be the explicit
+ print() call at the end of the snippet."""
+ code = """
+import os, sys
+# Ensure clean state
+os.environ.pop("SEMANTICA_DISABLE_PROGRESS", None)
+
+import mcp # sets SEMANTICA_DISABLE_PROGRESS=1
+import mcp.session as session
+from semantica.context.context_graph import ContextGraph
+
+g = ContextGraph()
+g.add_node("n1", node_type="entity")
+g.add_node("n2", node_type="entity")
+g.add_edge("n1", "n2", "related_to")
+session._graph = g
+
+# Intercept stdout writes to detect any progress output
+written = []
+_orig = sys.stdout.write
+def _capture(s):
+ written.append(s)
+ return _orig(s)
+sys.stdout.write = _capture
+
+from mcp.tools.export import handle_export_graph
+result = handle_export_graph({"format": "turtle"})
+
+sys.stdout.write = _orig
+
+# Only our explicit output below should be in written
+# (the sentinel line is added after restoring stdout)
+progress_writes = [s for s in written]
+print("RESULT_OK:" + str("error" not in result))
+print("STDOUT_WRITES:" + str(len(progress_writes)))
+"""
+ proc = self._run_in_subprocess(code)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ # Extract the printed lines
+ lines = proc.stdout.strip().splitlines()
+ result_ok_line = next((l for l in lines if l.startswith("RESULT_OK:")), None)
+ writes_line = next((l for l in lines if l.startswith("STDOUT_WRITES:")), None)
+ self.assertIsNotNone(result_ok_line, f"stdout: {proc.stdout!r}")
+ self.assertIsNotNone(writes_line, f"stdout: {proc.stdout!r}")
+ self.assertEqual(result_ok_line, "RESULT_OK:True",
+ f"export returned error; stdout={proc.stdout!r}, stderr={proc.stderr!r}")
+ n_writes = int(writes_line.split(":")[1])
+ self.assertEqual(n_writes, 0,
+ f"Expected 0 progress writes to stdout, got {n_writes}; "
+ f"stdout={proc.stdout!r}")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_mcp_server_export_graph.py b/tests/test_mcp_server_export_graph.py
new file mode 100644
index 00000000..09179fd8
--- /dev/null
+++ b/tests/test_mcp_server_export_graph.py
@@ -0,0 +1,90 @@
+"""Regression tests for the MCP export_graph tool (issue: all branches broken).
+
+The MCP server's export_graph tool failed on every format in 0.6.5/0.6.6:
+ - json: JSONExporter().export(graph) called without the required file_path
+ argument -> TypeError, surfaced as {"error": ...}
+ - RDF: RDFExporter().export_to_rdf(graph, ...) received the ContextGraph
+ object instead of the canonical kg dict -> AttributeError
+ - all: the RDF path printed a rich progress bar to stdout, corrupting the
+ stdio JSON-RPC framing and hanging the client (observed: 300s
+ timeout over MCP, <1s directly).
+
+The fix: convert the graph with ContextGraph.to_kg_dict() before handing it to
+the exporters, serialize json to a string, and force SEMANTICA_DISABLE_PROGRESS
+for the server process (stdout is the protocol channel, not a console).
+"""
+
+import json
+import os
+import unittest
+
+from semantica import mcp_server
+from semantica.context import ContextGraph
+
+
+def _graph_with_content() -> ContextGraph:
+ graph = ContextGraph(advanced_analytics=True)
+ graph.add_node("n1", node_type="entity", properties={"text": "hello"})
+ graph.add_node("n2", node_type="entity", properties={"text": "world"})
+ graph.add_edge("n1", "n2", "related_to")
+ return graph
+
+
+class TestExportGraphTool(unittest.TestCase):
+
+ def setUp(self):
+ self._old_graph = mcp_server._graph
+ mcp_server._graph = _graph_with_content()
+
+ def tearDown(self):
+ mcp_server._graph = self._old_graph
+
+ def test_json_branch_returns_string_data_not_error(self):
+ result = mcp_server._tool_export_graph({"format": "json"})
+ self.assertNotIn("error", result)
+ self.assertEqual(result["format"], "json")
+ payload = json.loads(result["data"])
+ self.assertEqual(len(payload["entities"]), 2)
+ self.assertEqual(len(payload["relationships"]), 1)
+
+ def test_jsonld_branch_returns_string_data_not_error(self):
+ result = mcp_server._tool_export_graph({"format": "json-ld"})
+ self.assertNotIn("error", result)
+ self.assertEqual(result["format"], "json-ld")
+ self.assertIsInstance(result["data"], str)
+ self.assertGreater(len(result["data"]), 0)
+
+ def test_turtle_branch_returns_string_data_not_error(self):
+ result = mcp_server._tool_export_graph({"format": "turtle"})
+ self.assertNotIn("error", result)
+ self.assertIsInstance(result["data"], str)
+ self.assertIn("@prefix", result["data"])
+
+ def test_all_rdf_formats_succeed(self):
+ for fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
+ with self.subTest(fmt=fmt):
+ result = mcp_server._tool_export_graph({"format": fmt})
+ self.assertNotIn("error", result, fmt)
+ self.assertIsInstance(result["data"], str)
+
+ def test_progress_is_disabled_for_the_server_process(self):
+ self.assertEqual(os.environ.get("SEMANTICA_DISABLE_PROGRESS"), "1")
+
+ def test_unsupported_format_returns_error_not_mislabeled_json(self):
+ """A format outside the declared enum (typo, unsupported value, or a
+ client that skips schema validation) must error, not silently return
+ JSON data mislabeled with the requested format string."""
+ result = mcp_server._tool_export_graph({"format": "yaml"})
+ self.assertIn("error", result)
+ self.assertIn("yaml", result["error"])
+
+ def test_export_graph_schema_enum_matches_handled_formats(self):
+ """The tool's declared inputSchema enum must not drift from the set
+ of formats the handler actually accepts."""
+ tool = next(t for t in mcp_server.TOOLS if t["name"] == "export_graph")
+ schema_enum = set(tool["inputSchema"]["properties"]["format"]["enum"])
+ self.assertEqual(schema_enum, set(mcp_server._EXPORT_GRAPH_FORMATS))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_progress_tracker_regressions.py b/tests/test_progress_tracker_regressions.py
index ac4c09da..b42a885b 100644
--- a/tests/test_progress_tracker_regressions.py
+++ b/tests/test_progress_tracker_regressions.py
@@ -12,6 +12,7 @@ import semantica.utils.progress_tracker as progress_module
@pytest.fixture(autouse=True)
def reset_progress_singletons(monkeypatch):
monkeypatch.delenv("SEMANTICA_DISABLE_PROGRESS", raising=False)
+ monkeypatch.delenv("SEMANTICA_FORCE_PROGRESS", raising=False)
progress_module.ProgressTracker._instance = None
progress_module._global_tracker = None
yield
@@ -19,6 +20,40 @@ def reset_progress_singletons(monkeypatch):
progress_module._global_tracker = None
+class _FakeStdout:
+ """Minimal stdout stand-in with controllable TTY reporting."""
+
+ encoding = "utf-8"
+
+ def __init__(self, tty):
+ self._tty = tty
+ self.written = []
+
+ def isatty(self):
+ return self._tty
+
+ def write(self, text):
+ self.written.append(text)
+ return len(text)
+
+ def flush(self):
+ pass
+
+
+def _use_stdout(monkeypatch, tty):
+ """Point sys.stdout at a fake with the given TTY behaviour, outside Jupyter."""
+ stream = _FakeStdout(tty=tty)
+ monkeypatch.setattr(sys, "stdout", stream)
+ monkeypatch.setattr(
+ progress_module.ProgressTracker, "_detect_jupyter", lambda *_: False
+ )
+ return stream
+
+
+def _displays_of(tracker, display_cls):
+ return [d for d in tracker.displays if isinstance(d, display_cls)]
+
+
def _install_tracker_as_singleton(tracker: progress_module.ProgressTracker) -> None:
progress_module.ProgressTracker._instance = tracker
progress_module._global_tracker = tracker
@@ -100,6 +135,67 @@ def test_disable_progress_env_prevents_reenable(monkeypatch):
assert tracker.start_tracking(module="core", submodule="test") == ""
+def test_console_display_omitted_when_stdout_is_not_a_tty(monkeypatch):
+ _use_stdout(monkeypatch, tty=False)
+
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+
+ assert _displays_of(tracker, progress_module.ConsoleProgressDisplay) == []
+
+
+def test_console_display_present_when_stdout_is_a_tty(monkeypatch):
+ _use_stdout(monkeypatch, tty=True)
+
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+
+ assert _displays_of(tracker, progress_module.ConsoleProgressDisplay)
+
+
+def test_file_display_survives_non_tty_stdout(monkeypatch):
+ _use_stdout(monkeypatch, tty=False)
+
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+
+ assert _displays_of(tracker, progress_module.FileProgressDisplay)
+
+
+def test_force_progress_env_restores_console_display_on_non_tty(monkeypatch):
+ monkeypatch.setenv("SEMANTICA_FORCE_PROGRESS", "1")
+ _use_stdout(monkeypatch, tty=False)
+
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+
+ assert _displays_of(tracker, progress_module.ConsoleProgressDisplay)
+
+
+def test_disable_progress_env_beats_force_progress_env(monkeypatch):
+ monkeypatch.setenv("SEMANTICA_DISABLE_PROGRESS", "1")
+ monkeypatch.setenv("SEMANTICA_FORCE_PROGRESS", "1")
+ stream = _use_stdout(monkeypatch, tty=False)
+
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+ _install_tracker_as_singleton(tracker)
+
+ assert tracker.enabled is False
+ assert tracker.start_tracking(module="core", submodule="test") == ""
+ assert stream.written == []
+
+
+def test_non_tty_stdout_stays_silent_after_module_reenables_tracker(monkeypatch):
+ stream = _use_stdout(monkeypatch, tty=False)
+ tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
+ _install_tracker_as_singleton(tracker)
+
+ # Mirrors the ~20 modules that do `self.progress_tracker.enabled = True`.
+ tracker.enabled = True
+ tracking_id = tracker.start_tracking(
+ module="core", submodule="Semantica", message="Building"
+ )
+ tracker.update_progress(tracking_id, processed=1, total=1, message="Processing")
+
+ assert stream.written == []
+
+
def test_build_knowledge_base_subprocess_does_not_deadlock():
root = Path(__file__).resolve().parents[1]
runtime_dir = root / "test_data" / "runtime" / f"build-regression-{os.getpid()}"
diff --git a/tests/test_reproduce_issue_176.py b/tests/test_reproduce_issue_176.py
new file mode 100644
index 00000000..79b24076
--- /dev/null
+++ b/tests/test_reproduce_issue_176.py
@@ -0,0 +1,334 @@
+
+import unittest
+from unittest.mock import MagicMock, patch
+from semantica.semantic_extract.methods import extract_relations_llm, extract_entities_llm, extract_triplets_llm
+from semantica.semantic_extract.ner_extractor import Entity
+
+class TestMaxTokensPropagation(unittest.TestCase):
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_max_tokens_propagation_relations(self, mock_create_provider):
+ """Test that max_tokens is passed to generate_typed in extract_relations_llm."""
+ # Setup mock
+ mock_llm = MagicMock()
+ mock_create_provider.return_value = mock_llm
+ mock_llm.is_available.return_value = True
+
+ # Setup return value to avoid pydantic validation errors
+ mock_response = MagicMock()
+ mock_response.relations = []
+ mock_llm.generate_typed.return_value = mock_response
+
+ # Create dummy entities
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ # Call the function with max_tokens
+ extract_relations_llm(
+ text="some text",
+ entities=entities,
+ provider="openai",
+ model="gpt-4",
+ max_tokens=128000
+ )
+
+ # Check if generate_typed was called with max_tokens
+ args, kwargs = mock_llm.generate_typed.call_args
+
+ print(f"Relations Call kwargs: {kwargs}")
+
+ self.assertIn("max_tokens", kwargs)
+ self.assertEqual(kwargs["max_tokens"], 128000)
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_max_tokens_propagation_entities(self, mock_create_provider):
+ """Test that max_tokens is passed to generate_typed in extract_entities_llm."""
+ # Setup mock
+ mock_llm = MagicMock()
+ mock_create_provider.return_value = mock_llm
+ mock_llm.is_available.return_value = True
+
+ # Setup return value to avoid pydantic validation errors
+ mock_response = MagicMock()
+ mock_response.entities = []
+ mock_llm.generate_typed.return_value = mock_response
+
+ # Call the function with max_tokens
+ extract_entities_llm(
+ text="some text",
+ provider="openai",
+ model="gpt-4",
+ max_tokens=128000
+ )
+
+ # Check if generate_typed was called with max_tokens
+ args, kwargs = mock_llm.generate_typed.call_args
+
+ print(f"Entities Call kwargs: {kwargs}")
+
+ self.assertIn("max_tokens", kwargs)
+ self.assertEqual(kwargs["max_tokens"], 128000)
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_max_tokens_propagation_triplets(self, mock_create_provider):
+ """Test that max_tokens is passed to generate_typed in extract_triplets_llm."""
+ # Setup mock
+ mock_llm = MagicMock()
+ mock_create_provider.return_value = mock_llm
+ mock_llm.is_available.return_value = True
+
+ # Setup return value to avoid pydantic validation errors
+ mock_response = MagicMock()
+ mock_response.triplets = []
+ mock_llm.generate_typed.return_value = mock_response
+
+ # Call the function with max_tokens
+ extract_triplets_llm(
+ text="some text",
+ provider="openai",
+ model="gpt-4",
+ max_tokens=128000
+ )
+
+ # Check if generate_typed was called with max_tokens
+ args, kwargs = mock_llm.generate_typed.call_args
+
+ print(f"Triplets Call kwargs: {kwargs}")
+
+ self.assertIn("max_tokens", kwargs)
+ self.assertEqual(kwargs["max_tokens"], 128000)
+
+
+class TestCacheKeyIncludesGenerationParams(unittest.TestCase):
+ """Regression tests for the cache-key bug: two calls with identical extraction
+ inputs but different generation settings must NOT share a cached result.
+
+ Before the fix, extract_relations_llm (and entities/triplets) built
+ cache_params without generation kwargs, so max_tokens=4096 and
+ max_tokens=128000 hashed to the same key. The second call would return the
+ first cached result without ever running generate_typed again.
+ """
+
+ def _make_mock_llm(self, relations=None, entities=None, triplets=None):
+ mock_llm = MagicMock()
+ mock_llm.is_available.return_value = True
+ resp = MagicMock()
+ resp.relations = relations if relations is not None else []
+ resp.entities = entities if entities is not None else []
+ resp.triplets = triplets if triplets is not None else []
+ mock_llm.generate_typed.return_value = resp
+ return mock_llm
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_different_max_tokens_bypass_cache(self, mock_create_provider):
+ """Two relation extraction calls with the same text/entities but different
+ max_tokens must each call generate_typed (2 calls total), not reuse the
+ first cached result."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="openai", model="gpt-4", max_tokens=4096
+ )
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="openai", model="gpt-4", max_tokens=128000
+ )
+
+ # generate_typed must have been called twice โ once per unique key
+ self.assertEqual(
+ mock_llm.generate_typed.call_count, 2,
+ "Different max_tokens values must produce different cache keys; "
+ "second call must not reuse the first cached result."
+ )
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_same_max_tokens_uses_cache(self, mock_create_provider):
+ """Two identical calls must reuse the cache (generate_typed called once)."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="openai", model="gpt-4", max_tokens=4096
+ )
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="openai", model="gpt-4", max_tokens=4096
+ )
+
+ self.assertEqual(
+ mock_llm.generate_typed.call_count, 1,
+ "Identical calls must reuse the cache."
+ )
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_different_temperature_bypass_cache(self, mock_create_provider):
+ """Different temperature values must also produce different cache keys."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Bar", label="PERSON", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="other text", entities=entities,
+ provider="openai", model="gpt-4", temperature=0.0
+ )
+ extract_relations_llm(
+ text="other text", entities=entities,
+ provider="openai", model="gpt-4", temperature=1.0
+ )
+
+ self.assertEqual(mock_llm.generate_typed.call_count, 2)
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_entities_different_max_tokens_bypass_cache(self, mock_create_provider):
+ """extract_entities_llm: different max_tokens must bypass cache."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("entities")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ extract_entities_llm(
+ text="some entity text", provider="openai", model="gpt-4",
+ max_tokens=4096
+ )
+ extract_entities_llm(
+ text="some entity text", provider="openai", model="gpt-4",
+ max_tokens=128000
+ )
+
+ self.assertEqual(mock_llm.generate_typed.call_count, 2)
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_triplets_different_max_tokens_bypass_cache(self, mock_create_provider):
+ """extract_triplets_llm: different max_tokens must bypass cache."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("triplets")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ extract_triplets_llm(
+ text="some triplet text", provider="openai", model="gpt-4",
+ max_tokens=4096
+ )
+ extract_triplets_llm(
+ text="some triplet text", provider="openai", model="gpt-4",
+ max_tokens=128000
+ )
+
+ self.assertEqual(mock_llm.generate_typed.call_count, 2)
+
+
+class TestCacheKeyIncludesProviderSpecificGenerationParams(unittest.TestCase):
+ """Regression tests for provider-specific generation params that aren't part
+ of the common OpenAI-shaped kwargs (max_tokens, temperature, etc.) but still
+ change provider output and must therefore also change the cache key.
+
+ See providers.py: AnthropicProvider.generate/generate_structured read
+ 'system' and 'stop_sequences' via a manual pass-through loop (not
+ _add_if_set); GeminiProvider.generate reads 'candidate_count' and
+ 'stop_sequences'; OllamaProvider._build_options reads 'repeat_penalty' and
+ 'num_ctx'/'context_window'.
+ """
+
+ def _make_mock_llm(self):
+ mock_llm = MagicMock()
+ mock_llm.is_available.return_value = True
+ resp = MagicMock()
+ resp.relations = []
+ mock_llm.generate_typed.return_value = resp
+ return mock_llm
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_different_system_prompt_bypass_cache(self, mock_create_provider):
+ """Anthropic 'system' prompt changes output; must not share a cache entry."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="anthropic", model="claude-3-sonnet-20240229",
+ system="Extract only ORG relations."
+ )
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="anthropic", model="claude-3-sonnet-20240229",
+ system="Extract only PERSON relations."
+ )
+
+ self.assertEqual(
+ mock_llm.generate_typed.call_count, 2,
+ "Different 'system' prompts must produce different cache keys."
+ )
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_different_stop_sequences_bypass_cache(self, mock_create_provider):
+ """Anthropic/Gemini 'stop_sequences' must also be part of the cache key."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="anthropic", model="claude-3-sonnet-20240229",
+ stop_sequences=["\n\n"]
+ )
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="anthropic", model="claude-3-sonnet-20240229",
+ stop_sequences=["STOP"]
+ )
+
+ self.assertEqual(mock_llm.generate_typed.call_count, 2)
+
+ @patch("semantica.semantic_extract.methods.create_provider")
+ def test_relations_different_repeat_penalty_bypass_cache(self, mock_create_provider):
+ """Ollama 'repeat_penalty' must also be part of the cache key."""
+ from semantica.semantic_extract.methods import _result_cache
+ _result_cache.clear("relations")
+
+ mock_llm = self._make_mock_llm()
+ mock_create_provider.return_value = mock_llm
+
+ entities = [Entity(text="Foo", label="ORG", start_char=0, end_char=3)]
+
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="ollama", model="llama2",
+ repeat_penalty=1.0
+ )
+ extract_relations_llm(
+ text="some text", entities=entities,
+ provider="ollama", model="llama2",
+ repeat_penalty=1.5
+ )
+
+ self.assertEqual(mock_llm.generate_typed.call_count, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_seed_manager.py b/tests/test_seed_manager.py
index 95d490a1..1f07ed0b 100644
--- a/tests/test_seed_manager.py
+++ b/tests/test_seed_manager.py
@@ -5,6 +5,9 @@ import json
import csv
from pathlib import Path
from unittest.mock import MagicMock, patch
+
+import requests
+
from semantica.seed.seed_manager import SeedDataManager, SeedDataSource, SeedData
from semantica.utils.exceptions import ProcessingError
@@ -263,6 +266,61 @@ def test_load_from_api_does_not_mutate_empty_headers_dict(mock_guard, seed_manag
guard_headers = call_kwargs.get("headers", {})
assert guard_headers.get("Authorization") == "Bearer key"
+
+# requests.exceptions.RequestException subclasses OSError, so network failures raised
+# by request_with_ssrf_guard used to be reported as "requests library not available"
+# by the obsolete ImportError / OSError handler. They must surface the real cause.
+@pytest.mark.parametrize(
+ "error",
+ [
+ requests.exceptions.ConnectionError("connection refused"),
+ requests.exceptions.Timeout("timed out"),
+ requests.exceptions.HTTPError("500 Server Error"),
+ ],
+)
+@patch("semantica.seed.seed_manager.request_with_ssrf_guard")
+def test_load_from_api_request_failure_reports_real_cause(mock_guard, error, seed_manager):
+ mock_guard.side_effect = error
+
+ with pytest.raises(ProcessingError) as excinfo:
+ seed_manager.load_from_api(api_url="http://api.example.com", endpoint="users")
+
+ message = str(excinfo.value)
+ assert "Failed to load from API" in message
+ assert str(error) in message
+ assert "requests library not available" not in message
+ assert excinfo.value.__cause__ is error
+
+
+@patch("semantica.seed.seed_manager.request_with_ssrf_guard")
+def test_load_from_api_http_status_error_reports_real_cause(mock_guard, seed_manager):
+ http_error = requests.exceptions.HTTPError("404 Client Error: Not Found")
+ mock_response = MagicMock()
+ mock_response.raise_for_status.side_effect = http_error
+ mock_guard.return_value = mock_response
+
+ with pytest.raises(ProcessingError) as excinfo:
+ seed_manager.load_from_api(api_url="http://api.example.com", endpoint="users")
+
+ message = str(excinfo.value)
+ assert "404 Client Error: Not Found" in message
+ assert "requests library not available" not in message
+ mock_response.json.assert_not_called()
+
+
+@patch("semantica.seed.seed_manager.request_with_ssrf_guard")
+def test_load_from_api_invalid_json_reports_real_cause(mock_guard, seed_manager):
+ mock_response = MagicMock()
+ mock_response.json.side_effect = ValueError("Expecting value: line 1 column 1")
+ mock_guard.return_value = mock_response
+
+ with pytest.raises(ProcessingError) as excinfo:
+ seed_manager.load_from_api(api_url="http://api.example.com")
+
+ message = str(excinfo.value)
+ assert "Failed to load from API" in message
+ assert "Expecting value" in message
+
def test_load_source(seed_manager, temp_data_dir):
json_file = temp_data_dir / "source.json"
with open(json_file, "w") as f:
diff --git a/tests/triplet_store/test_oxigraph_store.py b/tests/triplet_store/test_oxigraph_store.py
index cbd3c979..9415795d 100644
--- a/tests/triplet_store/test_oxigraph_store.py
+++ b/tests/triplet_store/test_oxigraph_store.py
@@ -159,3 +159,89 @@ def test_missing_optional_dependency_has_install_hint():
):
with pytest.raises(ImportError, match="tripletstore-oxigraph"):
_store()
+
+
+def test_on_disk_add_triplets_calls_flush(tmp_path):
+ """add_triplets on a disk-backed store must flush once after the batch.
+
+ The pyoxigraph background-thread flush "might lag a little bit"; an
+ explicit flush after the batch closes that race without fsyncing on
+ every individual write. This test verifies the contract directly
+ without relying on CPython destructor timing.
+ """
+ store = OxigraphStore(path=tmp_path / "oxigraph")
+ with patch.object(store, "flush") as mock_flush:
+ store.add_triplets([
+ Triplet(EX + "alice", EX + "knows", EX + "bob"),
+ Triplet(EX + "bob", EX + "knows", EX + "carol"),
+ ])
+ mock_flush.assert_called_once()
+
+
+def test_on_disk_add_triplet_does_not_flush(tmp_path):
+ """add_triplet (single write) must NOT flush on every call.
+
+ Individual writes are committed to the store in memory; the caller is
+ responsible for calling flush() when a hard durability boundary is
+ needed. Flushing on every add_triplet() call would fsync on every
+ write, causing a severe throughput regression for workloads that write
+ triplets one at a time.
+ """
+ store = OxigraphStore(path=tmp_path / "oxigraph")
+ with patch.object(store, "flush") as mock_flush:
+ store.add_triplet(Triplet(EX + "alice", EX + "knows", EX + "bob"))
+ mock_flush.assert_not_called()
+
+
+def test_in_memory_add_triplets_does_not_flush(tmp_path):
+ """In-memory stores must not call flush() โ there is nothing to flush."""
+ store = OxigraphStore() # no path โ in-memory
+ with patch.object(store, "flush") as mock_flush:
+ store.add_triplet(Triplet(EX + "alice", EX + "knows", EX + "bob"))
+ store.add_triplets([Triplet(EX + "bob", EX + "knows", EX + "carol")])
+ mock_flush.assert_not_called()
+
+
+def test_on_disk_add_triplets_is_durable_on_reopen(tmp_path):
+ """End-to-end durability: a batch written via add_triplets and closed
+ cleanly survives a reopen.
+
+ This is an integration test for the full add_triplets โ flush โ close โ
+ reopen lifecycle. The durability contract here is provided by the
+ explicit ``store.flush()`` call before deletion; the internal flush
+ inside add_triplets reduces (but does not eliminate) the crash-window
+ race. The authoritative unit test for the internal flush behaviour is
+ ``test_on_disk_add_triplets_calls_flush``.
+ """
+ path = tmp_path / "oxigraph"
+ store = OxigraphStore(path=path)
+ store.add_triplets([
+ Triplet(EX + "alice", EX + "knows", EX + "bob"),
+ Triplet(EX + "bob", EX + "knows", EX + "carol"),
+ ])
+ store.flush() # belt-and-suspenders: ensures close is clean
+ del store
+ gc.collect()
+
+ reopened = OxigraphStore(path=path)
+ assert len(reopened.get_triplets()) == 2
+
+
+def test_storage_path_is_accepted_as_alias_for_path(tmp_path):
+ """Regression: ``storage_path=...`` used to be silently swallowed by
+ ``**config`` (the __init__ parameter is named ``path``), so the store
+ silently degraded to in-memory with no warning. It must now be accepted
+ as an alias consistent with other Semantica stores (e.g. ProvenanceManager)."""
+ storage_path = tmp_path / "oxigraph"
+
+ store = OxigraphStore(storage_path=str(storage_path))
+
+ assert store.path == str(storage_path)
+ # and it must actually persist (proves the alias wired through to the
+ # on-disk path, not just set the attribute)
+ store.add_triplet(Triplet(EX + "alice", EX + "knows", EX + "bob"))
+ del store
+ gc.collect()
+
+ reopened = OxigraphStore(storage_path=str(storage_path))
+ assert len(reopened.get_triplets()) == 1
diff --git a/tests/triplet_store/test_rdf4j_store.py b/tests/triplet_store/test_rdf4j_store.py
index 4ef4f16b..03a9b90b 100644
--- a/tests/triplet_store/test_rdf4j_store.py
+++ b/tests/triplet_store/test_rdf4j_store.py
@@ -22,6 +22,71 @@ def _make_connected_store():
CONSTRUCT_QUERY = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"
+class TestRDF4JStoreInitialization(unittest.TestCase):
+
+ def test_explicit_repository_id_selects_repository(self):
+ response = MagicMock(status_code=200)
+
+ with patch(
+ "semantica.triplet_store.rdf4j_store.requests.get",
+ return_value=response,
+ ) as mock_get:
+ store = RDF4JStore(
+ endpoint="http://localhost:8080/rdf4j-server/",
+ repository_id="semantica",
+ )
+
+ self.assertEqual(store.repository_id, "semantica")
+ mock_get.assert_called_once_with(
+ "http://localhost:8080/rdf4j-server/repositories/semantica",
+ timeout=30,
+ auth=None,
+ )
+
+ def test_repository_id_is_encoded_as_a_single_url_path_segment(self):
+ response = MagicMock(status_code=200)
+
+ with patch(
+ "semantica.triplet_store.rdf4j_store.requests.get",
+ return_value=response,
+ ) as mock_get:
+ store = RDF4JStore(
+ endpoint="http://localhost:8080/rdf4j-server",
+ repository_id="team/repo ?#",
+ )
+
+ self.assertEqual(store.repository_id, "team/repo ?#")
+ mock_get.assert_called_once_with(
+ "http://localhost:8080/rdf4j-server/repositories/team%2Frepo%20%3F%23",
+ timeout=30,
+ auth=None,
+ )
+ self.assertEqual(
+ store._get_sparql_endpoint(),
+ "http://localhost:8080/rdf4j-server/repositories/team%2Frepo%20%3F%23",
+ )
+ self.assertEqual(
+ store._get_update_endpoint(),
+ "http://localhost:8080/rdf4j-server/repositories/"
+ "team%2Frepo%20%3F%23/statements",
+ )
+
+ transaction_response = MagicMock()
+ transaction_response.headers = {"Location": "/transactions/tx-1"}
+ with patch(
+ "semantica.triplet_store.rdf4j_store.requests.post",
+ return_value=transaction_response,
+ ) as mock_post:
+ self.assertEqual(store.begin_transaction(), "tx-1")
+
+ mock_post.assert_called_once_with(
+ "http://localhost:8080/rdf4j-server/repositories/"
+ "team%2Frepo%20%3F%23/transactions",
+ timeout=30,
+ auth=None,
+ )
+
+
class TestRDF4JStoreIsConstructQuery(unittest.TestCase):
def test_detects_uppercase(self):
self.assertTrue(_make_connected_store()._is_construct_query(
diff --git a/tests/utils/test_normalize_graph_payload.py b/tests/utils/test_normalize_graph_payload.py
index 02b53219..6f64aa22 100644
--- a/tests/utils/test_normalize_graph_payload.py
+++ b/tests/utils/test_normalize_graph_payload.py
@@ -474,6 +474,77 @@ class TestIsRecordBoundary(unittest.TestCase):
)
+class TestKeyDisplayBounds(unittest.TestCase):
+ """Exception messages must not scale with caller-controlled keys (#1001).
+
+ The validation boundary interpolates supplied keys straight into error
+ messages, so an extremely large key produced an equally large exception
+ string -- and, through the export wrappers that log the full exception,
+ an equally large log entry. The displayed key is truncated to a bounded
+ length while the supplied payload itself is never modified.
+ """
+
+ def test_unrecognized_key_display_is_bounded(self):
+ with self.assertRaises(ValidationError) as ctx:
+ normalize_graph_payload({"x" * 1_000_000: [ENTITY]})
+
+ message = str(ctx.exception)
+ self.assertLess(len(message), 300)
+ self.assertIn("x" * 64 + "โฆ", message)
+ # Truncating the supplied key must not cost the actionable part.
+ self.assertIn("no recognized key", message)
+ self.assertIn("entities", message)
+
+ def test_dropped_record_key_display_is_bounded(self):
+ with self.assertRaises(ValidationError) as ctx:
+ normalize_graph_payload({"entities": [], "y" * 1_000_000: [ENTITY]})
+
+ message = str(ctx.exception)
+ self.assertLess(len(message), 300)
+ self.assertIn("y" * 64 + "โฆ", message)
+ self.assertIn("holds records", message)
+
+ def test_short_keys_are_displayed_in_full(self):
+ with self.assertRaises(ValidationError) as ctx:
+ normalize_graph_payload({"short_key": [ENTITY]})
+
+ self.assertIn("'short_key'", str(ctx.exception))
+
+ def test_bounded_display_does_not_mutate_the_payload(self):
+ big_key = "z" * 1_000_000
+ payload = {big_key: [ENTITY]}
+
+ with self.assertRaises(ValidationError):
+ normalize_graph_payload(payload)
+
+ self.assertEqual(list(payload), [big_key])
+ self.assertEqual(payload[big_key], [ENTITY])
+
+ def test_many_unrecognized_keys_are_summarized(self):
+ """Per-key truncation does not bound the number of keys shown.
+
+ A payload carrying many short unrecognized keys would still size the
+ message (and the log entry that records it), so the count of
+ displayed keys is bounded too.
+ """
+ payload = {f"key_{i}": [ENTITY] for i in range(100)}
+ with self.assertRaises(ValidationError) as ctx:
+ normalize_graph_payload(payload)
+
+ message = str(ctx.exception)
+ self.assertLess(len(message), 500)
+ self.assertIn("and 92 more", message)
+
+ def test_many_dropped_record_keys_are_summarized(self):
+ payload = {"entities": [], **{f"data_{i}": [ENTITY] for i in range(100)}}
+ with self.assertRaises(ValidationError) as ctx:
+ normalize_graph_payload(payload)
+
+ message = str(ctx.exception)
+ self.assertLess(len(message), 500)
+ self.assertIn("and 92 more", message)
+
+
@dataclass
class _DataclassNode:
id: str
diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py
index 5bbe3be3..054c010c 100644
--- a/tests/utils/test_utils.py
+++ b/tests/utils/test_utils.py
@@ -31,21 +31,48 @@ class TestHelpers(unittest.TestCase):
dict2 = {"b": {"d": 3}, "e": 4}
merged = helpers.merge_dicts(dict1, dict2, deep=True)
self.assertEqual(merged, {"a": 1, "b": {"c": 2, "d": 3}, "e": 4})
+
def test_flatten_dict(self):
- data = {"a": {"b": 1, "c": 2}}
+ """Basic nested flattening with multiple sibling keys."""
+ data = {"a": {"b": 1, "c": 2}, "d": 3}
result = helpers.flatten_dict(data)
- self.assertEqual(result, {"a.b": 1, "a.c": 2})
+ self.assertEqual(result, {"a.b": 1, "a.c": 2, "d": 3})
+
+ def test_flatten_dict_deeply_nested(self):
+ """Deeply nested structure is fully flattened."""
+ self.assertEqual(
+ helpers.flatten_dict({"a": {"b": {"c": 1}}}),
+ {"a.b.c": 1},
+ )
+
+ def test_flatten_dict_custom_separator(self):
+ """Custom separator is used in generated keys."""
+ self.assertEqual(
+ helpers.flatten_dict({"a": {"b": 1}}, sep="__"),
+ {"a__b": 1},
+ )
+
+ def test_flatten_dict_empty(self):
+ """Empty input returns empty output."""
+ self.assertEqual(helpers.flatten_dict({}), {})
def test_flatten_dict_key_collision(self):
- data = {
- "a.b": 1,
- "a": {
- "b": 2
- }
- }
+ """#1010 regression: a top-level key containing the separator must not
+ silently overwrite a value produced from a nested dict when both resolve
+ to the same flattened key. Before the fix, {'a.b': 1, 'a': {'b': 2}}
+ silently dropped one value; now it raises ValueError."""
+ with self.assertRaises(ValueError) as ctx:
+ helpers.flatten_dict({"a.b": 1, "a": {"b": 2}})
+ self.assertIn("Key collision", str(ctx.exception))
+ self.assertIn("a.b", str(ctx.exception))
- with self.assertRaises(ValueError):
- helpers.flatten_dict(data)
+ def test_flatten_dict_no_false_positive(self):
+ """Similar-looking keys that produce distinct flattened keys must not
+ trigger the collision guard."""
+ self.assertEqual(
+ helpers.flatten_dict({"a.b": 1, "a": {"c": 2}}),
+ {"a.b": 1, "a.c": 2},
+ )
def test_safe_import_returns_module_and_flag(self):
module, available = helpers.safe_import("json")
diff --git a/tests/vector_store/test_vector_store.py b/tests/vector_store/test_vector_store.py
index 219f88c2..80ff3e59 100644
--- a/tests/vector_store/test_vector_store.py
+++ b/tests/vector_store/test_vector_store.py
@@ -243,5 +243,19 @@ class TestVectorStore(unittest.TestCase):
shutil.rmtree(tmpdir, ignore_errors=True)
+class TestCreateIndexFunction(unittest.TestCase):
+ """create_index() forwards vector_store_config's defaults into VectorIndexer,
+ which already receives backend/dimension as explicit args. Regression for the
+ 'got multiple values for keyword argument dimension' crash on the default
+ (unmocked) config, hit by e.g. `semantica embed index`."""
+
+ def test_create_index_with_default_config(self):
+ from semantica.vector_store.methods import create_index
+
+ vectors = [np.array([0.1, 0.2, 0.3]), np.array([0.4, 0.5, 0.6])]
+ index = create_index(vectors, ids=["a", "b"])
+ self.assertIsNotNone(index)
+
+
if __name__ == '__main__':
unittest.main()