This commit is contained in:
LASTA_DEV01\lasta
2026-05-13 19:57:34 +09:00
parent 2e9204243d
commit 9e88f4c7ad
4310 changed files with 48538 additions and 905279 deletions

View File

@@ -0,0 +1,12 @@
"""Test suite for OntoCast.
This package contains comprehensive tests for the OntoCast framework,
including unit tests, integration tests, and test utilities.
Test categories:
- Triple store tests (Neo4j, Fuseki)
- Ontology and state management tests
- Fact extraction and rendering tests
- Validation and utility tests
- Integration tests for the complete workflow
"""

View File

@@ -0,0 +1,934 @@
from rdflib import OWL, RDF, RDFS, Literal, URIRef
from ontocast.onto.constants import DEFAULT_IRI, PROV, RDF_REIFIES, SCHEMA
from ontocast.onto.content_unit import ContentUnit, OutputType
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.agg.aggregate import EmbeddingBasedAggregator
from ontocast.util import render_text_hash
def make_fact_unit(
text: str,
index: int,
doc_iri: URIRef | str,
ttl: str,
) -> ContentUnit:
graph = RDFGraph()
graph.parse(data=ttl, format="turtle")
return ContentUnit(
text=text,
index=index,
doc_iri=URIRef(str(doc_iri)),
graph=graph,
type=OutputType.FACTS,
)
def make_ontology_unit(
text: str,
index: int,
doc_iri: URIRef | str,
ttl: str,
) -> ContentUnit:
graph = RDFGraph()
graph.parse(data=ttl, format="turtle")
return ContentUnit(
text=text,
index=index,
doc_iri=URIRef(str(doc_iri)),
graph=graph,
type=OutputType.ONTOLOGIES,
)
def test_aggregate_graphs_returns_empty_graph_for_no_units() -> None:
aggregator = EmbeddingBasedAggregator()
result = aggregator.aggregate_graphs([])
assert len(result) == 0
def test_fact_entities_use_doc_iri_namespace() -> None:
doc_iri = "https://my-org.io/reports/annual2025"
ttl = f"""
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
facts:Revenue rdf:type facts:FinancialMetric .
facts:Revenue rdfs:label "Revenue" .
facts:Revenue facts:amount "42000000" .
"""
unit = make_fact_unit("Revenue was $42M.", 0, doc_iri, ttl)
result = EmbeddingBasedAggregator().aggregate_graphs([unit])
assert len(result) > 0
fact_subjects = {
str(subject)
for subject, predicate, _ in result
if isinstance(subject, URIRef)
and predicate != RDF.type
and not str(subject).startswith("http://www.w3.org")
and not str(subject).startswith("https://schema.org")
and "/stmt/" not in str(subject)
and "/chunk/" not in str(subject)
}
assert fact_subjects
assert any(subject.startswith(doc_iri) for subject in fact_subjects)
def test_aggregate_graphs_merges_overlapping_facts(monkeypatch) -> None:
doc_iri = "https://example.org/docs/report1"
ttl_chunk_0 = f"""
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
facts:UnitedStates rdf:type facts:Country .
facts:UnitedStates rdfs:label "United States" .
facts:UnitedStates facts:capitalCity "Washington, D.C." .
facts:UnitedStates facts:currency "USD" .
"""
ttl_chunk_1 = f"""
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
facts:united_states rdf:type facts:Country .
facts:united_states rdfs:label "United States" .
facts:united_states facts:population "331000000" .
"""
ttl_chunk_2 = f"""
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
facts:UnitedStatesBank rdf:type facts:Company .
facts:UnitedStatesBank rdfs:label "United States Bank" .
facts:UnitedStatesBank facts:headquarters "Portland" .
"""
text_0 = "The United States has capital Washington, D.C. and uses USD."
text_1 = "In another section, united_states is described with population data."
text_2 = "United States Bank is headquartered in Portland."
units = [
make_fact_unit(
text_0,
0,
doc_iri,
ttl_chunk_0,
),
make_fact_unit(
text_1,
1,
doc_iri,
ttl_chunk_1,
),
make_fact_unit(
text_2,
2,
doc_iri,
ttl_chunk_2,
),
]
aggregator = EmbeddingBasedAggregator()
def cluster_by_normal_form(representations):
clusters_by_key: dict[str, list[URIRef]] = {}
for entity, representation in representations.items():
clusters_by_key.setdefault(representation.normal_form, []).append(entity)
return list(clusters_by_key.values()), {}
monkeypatch.setattr(
aggregator.clusterer, "cluster_entities", cluster_by_normal_form
)
result = aggregator.aggregate_graphs(units)
result.bind("unused", "https://unused.example/")
turtle = result.serialize(format="turtle")
assert "Washington, D.C." in turtle
assert "USD" in turtle
assert "331000000" in turtle
assert "Portland" in turtle
assert "@prefix doc:" in turtle
assert "@prefix unused:" not in turtle
assert len(list(result.triples((None, RDFS.label, None)))) >= 2
us_subjects = {
subject
for subject in result.subjects(RDFS.label, Literal("United States"))
if isinstance(subject, URIRef)
}
assert len(us_subjects) == 1
us_entity = next(iter(us_subjects))
bank_subjects = {
subject
for subject in result.subjects(RDFS.label, Literal("United States Bank"))
if isinstance(subject, URIRef)
}
assert len(bank_subjects) == 1
bank_entity = next(iter(bank_subjects))
assert us_entity != bank_entity
assert str(us_entity).startswith(doc_iri)
assert str(bank_entity).startswith(doc_iri)
assert (us_entity, None, Literal("USD")) in result
assert (us_entity, None, Literal("331000000")) in result
assert (bank_entity, None, Literal("Portland")) in result
original_camel = URIRef(f"{DEFAULT_IRI}/UnitedStates")
original_snake = URIRef(f"{DEFAULT_IRI}/united_states")
assert (us_entity, OWL.sameAs, original_camel) not in result
assert (us_entity, OWL.sameAs, original_snake) not in result
statement_nodes = list(result.subjects(RDF_REIFIES, None))
assert statement_nodes
assert all(
len(set(result.objects(stmt, PROV.wasDerivedFrom))) >= 1
for stmt in statement_nodes
)
chunk_ids = {str(value) for value in result.objects(None, SCHEMA.identifier)}
expected_ids = {
render_text_hash(text_0),
render_text_hash(text_1),
render_text_hash(text_2),
}
assert expected_ids <= chunk_ids
def test_aggregate_graphs_preserves_ontology_uris_and_provenance(monkeypatch) -> None:
doc_iri = "https://example.org/docs/report1"
ttl_chunk_0 = """
@prefix ex: <http://example.org/onto#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Person rdf:type rdfs:Class .
ex:Person rdfs:label "Person" .
"""
ttl_chunk_1 = """
@prefix ex: <http://example.org/onto#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Persno rdf:type rdfs:Class .
ex:Persno rdfs:label "Person" .
"""
units = [
make_ontology_unit("Defines Person class.", 0, doc_iri, ttl_chunk_0),
make_ontology_unit("Repeats class with typo URI.", 1, doc_iri, ttl_chunk_1),
]
aggregator = EmbeddingBasedAggregator()
def force_typo_and_canonical_in_one_cluster(representations):
canonical = URIRef("http://example.org/onto#Person")
typo = URIRef("http://example.org/onto#Persno")
entities = set(representations.keys())
if canonical in entities and typo in entities:
return [[canonical, typo]], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_typo_and_canonical_in_one_cluster,
)
result = aggregator.aggregate_graphs(units)
canonical = URIRef("http://example.org/onto#Person")
typo = URIRef("http://example.org/onto#Persno")
assert (canonical, RDFS.label, Literal("Person")) in result
assert (typo, RDFS.label, Literal("Person")) in result
assert (canonical, OWL.sameAs, typo) in result or (
typo,
OWL.sameAs,
canonical,
) in result
assert str(canonical).startswith("http://example.org/onto#")
statement_nodes = list(result.subjects(RDF_REIFIES, None))
assert statement_nodes
assert all(
len(set(result.objects(stmt, PROV.wasDerivedFrom))) >= 1
for stmt in statement_nodes
)
def test_facts_doc_entity_does_not_replace_ontology_entity(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-42"
ontology_court = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
doc_court = URIRef(f"{doc_iri}/CourAppelRouen")
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
court_type = URIRef("https://growgraph.dev/fcaont#Court")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
doc:Case1 fcaont:heardAt doc:CourAppelRouen .
doc:Case2 fcaont:heardAt fcaont:CourAppelRouen .
doc:CourAppelRouen rdf:type fcaont:Court .
fcaont:CourAppelRouen rdf:type fcaont:Court .
"""
unit = make_fact_unit("Rouen court references.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((ontology_court, RDF.type, court_type))
def force_doc_and_ontology_court_together(representations):
entities = set(representations.keys())
if doc_court in entities and ontology_court in entities:
remaining = [e for e in entities if e not in {doc_court, ontology_court}]
return [[doc_court, ontology_court], *[[e] for e in remaining]], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_doc_and_ontology_court_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
assert (ontology_court, RDF.type, court_type) in result
assert (doc_court, RDF.type, court_type) in result
assert (ontology_court, OWL.sameAs, doc_court) not in result
assert (doc_court, OWL.sameAs, ontology_court) not in result
heard_at_targets = set(result.objects(None, heard_at))
assert ontology_court in heard_at_targets
assert doc_court in heard_at_targets
def test_ontology_entities_in_same_cluster_keep_original_iris(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-43"
court_fr = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
court_en = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
same_as = OWL.sameAs
rdfs_label = RDFS.label
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
doc:Case1 fcaont:heardAt fcaont:CourAppelRouen .
doc:Case2 fcaont:heardAt fcaont:AppealCourt_Rouen .
fcaont:CourAppelRouen rdfs:label "Cour d'appel de Rouen" .
fcaont:AppealCourt_Rouen rdfs:label "Rouen Court of Appeal" .
"""
unit = make_fact_unit("Rouen court variants.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((court_fr, rdfs_label, Literal("Cour d'appel de Rouen")))
ontology_graph.add((court_en, rdfs_label, Literal("Rouen Court of Appeal")))
def force_ontology_variants_together(representations):
entities = set(representations.keys())
if court_fr in entities and court_en in entities:
remaining = [
entity for entity in entities if entity not in {court_fr, court_en}
]
return [[court_fr, court_en], *[[entity] for entity in remaining]], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_ontology_variants_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
assert (court_fr, rdfs_label, Literal("Cour d'appel de Rouen")) in result
assert (court_en, rdfs_label, Literal("Rouen Court of Appeal")) in result
assert (court_fr, heard_at, None) not in result
assert (court_en, heard_at, None) not in result
heard_at_targets = set(result.objects(None, heard_at))
assert court_fr in heard_at_targets
assert court_en in heard_at_targets
assert (court_fr, same_as, court_en) in result or (
court_en,
same_as,
court_fr,
) in result
def test_tentative_ontology_like_alias_maps_to_known_ontology(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-44"
known_court = URIRef("https://growgraph.dev/fcaont#AppealCourtRouen")
invented_court = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
court_type = URIRef("https://growgraph.dev/fcaont#Court")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
doc:Case1 fcaont:heardAt fcaont:AppealCourt_Rouen .
fcaont:AppealCourt_Rouen rdf:type fcaont:Court .
"""
unit = make_fact_unit("Invented ontology-like alias.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((known_court, RDF.type, court_type))
ontology_graph.add((known_court, RDFS.label, Literal("Rouen Court of Appeal")))
def force_known_and_invented_together(representations):
entities = set(representations.keys())
if known_court in entities and invented_court in entities:
remaining = [
entity
for entity in entities
if entity not in {known_court, invented_court}
]
return [
[known_court, invented_court],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_known_and_invented_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
heard_at_targets = set(result.objects(None, heard_at))
assert known_court in heard_at_targets
assert invented_court not in heard_at_targets
assert (known_court, OWL.sameAs, invented_court) not in result
def test_tentative_only_ontology_like_entities_are_preserved(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-45"
invented_court_1 = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
invented_court_2 = URIRef("https://growgraph.dev/fcaont#CourtOfAppealRouen")
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
doc:Case1 fcaont:heardAt fcaont:AppealCourt_Rouen .
doc:Case2 fcaont:heardAt fcaont:CourtOfAppealRouen .
"""
unit = make_fact_unit("Tentative ontology-like terms only.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
def force_tentatives_together(representations):
entities = set(representations.keys())
if invented_court_1 in entities and invented_court_2 in entities:
remaining = [
entity
for entity in entities
if entity not in {invented_court_1, invented_court_2}
]
return [
[invented_court_1, invented_court_2],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_tentatives_together,
)
result = aggregator.aggregate_graphs([unit])
heard_at_targets = set(result.objects(None, heard_at))
assert invented_court_1 in heard_at_targets
assert invented_court_2 in heard_at_targets
def test_unused_ontology_entities_do_not_create_spurious_sameas() -> None:
doc_iri = "https://example.org/docs/case-46"
court_in_facts = URIRef("https://growgraph.dev/fcaont#CourAppelRouen")
heard_at = URIRef("https://growgraph.dev/fcaont#heardAt")
court_type = URIRef("https://growgraph.dev/fcaont#AppealCourt")
unused_a = URIRef("https://growgraph.dev/fcaont#CourAppelParis")
unused_b = URIRef("https://growgraph.dev/fcaont#CourAppelLyon")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
doc:Case1 fcaont:heardAt fcaont:CourAppelRouen .
fcaont:CourAppelRouen rdf:type fcaont:AppealCourt .
"""
unit = make_fact_unit("Case heard at Rouen court of appeal.", 0, doc_iri, ttl)
ontology_graph = RDFGraph()
ontology_graph.add((court_in_facts, RDF.type, court_type))
ontology_graph.add((unused_a, RDF.type, court_type))
ontology_graph.add((unused_b, RDF.type, court_type))
result = EmbeddingBasedAggregator().aggregate_graphs(
[unit], ontology_graph=ontology_graph
)
assert (unused_a, OWL.sameAs, unused_b) not in result
assert (unused_b, OWL.sameAs, unused_a) not in result
assert court_in_facts in set(result.objects(None, heard_at))
def test_tentative_with_incompatible_type_does_not_merge_to_known_ontology(
monkeypatch,
) -> None:
doc_iri = "https://example.org/docs/case-47"
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
tentative_person = URIRef("https://growgraph.dev/fcaont#Conviction1")
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
conviction_type = URIRef("https://growgraph.dev/fcaont#Conviction")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix schema: <https://schema.org/> .
doc:Judgment1 fcaont:isAssociatedWith fcaont:Conviction1 .
fcaont:Conviction1 rdf:type schema:Person .
"""
unit = make_fact_unit("Person associated with judgment.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((known_conviction, RDF.type, conviction_type))
ontology_graph.add((known_conviction, RDFS.label, Literal("Conviction")))
def force_known_and_tentative_together(representations):
entities = set(representations.keys())
if known_conviction in entities and tentative_person in entities:
remaining = [
entity
for entity in entities
if entity not in {known_conviction, tentative_person}
]
return [
[known_conviction, tentative_person],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_known_and_tentative_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
assert tentative_person in set(result.objects(None, associated_with))
assert known_conviction not in set(result.objects(None, associated_with))
assert (known_conviction, OWL.sameAs, tentative_person) not in result
def test_tentative_alias_merged_without_sameas_leak(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-47b"
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
tentative_alias = URIRef("https://growgraph.dev/fcaont#Conviction1")
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
class_type = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
doc:Judgment1 fcaont:isAssociatedWith fcaont:Conviction1 .
fcaont:Conviction1 rdf:type fcaont:Conviction .
"""
unit = make_fact_unit("Ontology-like alias mention.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((known_conviction, RDF.type, class_type))
def force_known_and_tentative_together(representations):
entities = set(representations.keys())
if known_conviction in entities and tentative_alias in entities:
remaining = [
entity
for entity in entities
if entity not in {known_conviction, tentative_alias}
]
return [
[known_conviction, tentative_alias],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_known_and_tentative_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
assert known_conviction in set(result.objects(None, associated_with))
assert tentative_alias not in set(result.objects(None, associated_with))
assert (known_conviction, OWL.sameAs, tentative_alias) not in result
def test_non_alias_ontology_terms_do_not_emit_sameas(monkeypatch) -> None:
doc_iri = "https://example.org/docs/case-48"
appeal = URIRef("https://growgraph.dev/fcaont#Appeal")
appeal_decision = URIRef("https://growgraph.dev/fcaont#AppealDecision")
type_class = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
fcaont:Appeal rdf:type rdfs:Class .
fcaont:AppealDecision rdf:type rdfs:Class .
"""
unit = make_fact_unit("Ontology class references.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((appeal, RDF.type, type_class))
ontology_graph.add((appeal_decision, RDF.type, type_class))
def force_together(representations):
entities = set(representations.keys())
if appeal in entities and appeal_decision in entities:
remaining = [
entity for entity in entities if entity not in {appeal, appeal_decision}
]
return [[appeal, appeal_decision], *[[entity] for entity in remaining]], {}
return [list(entities)], {}
monkeypatch.setattr(aggregator.clusterer, "cluster_entities", force_together)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
assert (appeal, OWL.sameAs, appeal_decision) not in result
assert (appeal_decision, OWL.sameAs, appeal) not in result
def test_entity_in_namespace_accepts_exact_prefix_namespace() -> None:
entity = URIRef("https://growgraph.dev/factsConviction1")
assert EmbeddingBasedAggregator._entity_in_namespace(
entity, "https://growgraph.dev/facts"
)
def test_fact_entity_forced_with_known_ontology_uses_identity_guard(
monkeypatch,
) -> None:
doc_iri = "https://example.org/docs/case-49"
known_conviction = URIRef("https://growgraph.dev/fcaont#Conviction")
fact_conviction = URIRef("https://growgraph.dev/factsConviction1")
associated_with = URIRef("https://growgraph.dev/fcaont#isAssociatedWith")
class_type = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix cd: <https://growgraph.dev/facts> .
@prefix fcaont: <https://growgraph.dev/fcaont#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix schema: <https://schema.org/> .
doc:Judgment1 fcaont:isAssociatedWith cd:Conviction1 .
cd:Conviction1 rdf:type schema:Person .
"""
unit = make_fact_unit("Forced mixed cluster.", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
ontology_graph = RDFGraph()
ontology_graph.add((known_conviction, RDF.type, class_type))
def force_known_and_fact_together(representations):
entities = set(representations.keys())
if known_conviction in entities and fact_conviction in entities:
remaining = [
entity
for entity in entities
if entity not in {known_conviction, fact_conviction}
]
return [
[known_conviction, fact_conviction],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_known_and_fact_together,
)
result = aggregator.aggregate_graphs([unit], ontology_graph=ontology_graph)
associated_targets = {
obj for obj in result.objects(None, associated_with) if isinstance(obj, URIRef)
}
assert associated_targets
assert all(str(obj).startswith(doc_iri) for obj in associated_targets)
assert known_conviction not in associated_targets
uri_nodes = {
term for s, _, o in result for term in (s, o) if isinstance(term, URIRef)
}
assert all(not str(node).startswith(DEFAULT_IRI) for node in uri_nodes)
def test_fact_predicate_is_collected_and_rewritten_to_doc_namespace() -> None:
doc_iri = "https://example.org/docs/predicate-case"
predicate = URIRef("https://growgraph.dev/factsHasCase")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix facts: <https://growgraph.dev/facts> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
doc:CaseA facts:HasCase doc:CaseB .
doc:CaseA rdf:type doc:Case .
"""
unit = make_fact_unit("Predicate-only fact URI.", 0, doc_iri, ttl)
result = EmbeddingBasedAggregator().aggregate_graphs([unit])
rewritten_predicates = {
p for _, p, _ in result if isinstance(p, URIRef) and str(p).startswith(doc_iri)
}
assert rewritten_predicates
assert any("HasCase" in str(p) for p in rewritten_predicates)
assert predicate not in set(result.predicates(None, None))
def test_cross_chunk_entity_context_is_merged_for_representation(monkeypatch) -> None:
doc_iri = "https://example.org/docs/context-merge"
shared = URIRef("https://growgraph.dev/factsSharedEntity")
rel_a = URIRef("https://growgraph.dev/factsHasAlpha")
rel_b = URIRef("https://growgraph.dev/factsHasBeta")
ttl_chunk_0 = """
@prefix facts: <https://growgraph.dev/facts> .
facts:SharedEntity facts:HasAlpha "A" .
"""
ttl_chunk_1 = """
@prefix facts: <https://growgraph.dev/facts> .
facts:SharedEntity facts:HasBeta "B" .
"""
units = [
make_fact_unit("First chunk", 0, doc_iri, ttl_chunk_0),
make_fact_unit("Second chunk", 1, doc_iri, ttl_chunk_1),
]
aggregator = EmbeddingBasedAggregator()
original_create_representation = aggregator.normalizer.create_representation
seen_shared_context: dict[str, set[URIRef]] = {"properties": set()}
def capture_representation(entity, graph):
representation = original_create_representation(entity, graph)
if entity == shared:
seen_shared_context["properties"] = set(representation.properties)
return representation
monkeypatch.setattr(
aggregator.normalizer,
"create_representation",
capture_representation,
)
aggregator.aggregate_graphs(units)
assert rel_a in seen_shared_context["properties"]
assert rel_b in seen_shared_context["properties"]
def test_doc_namespace_forcing_avoids_uri_collisions(monkeypatch) -> None:
doc_iri = "https://example.org/docs/collision-safe"
ttl = """
@prefix facts: <https://growgraph.dev/facts> .
facts:EntityA facts:RelatedTo "left" .
facts:EntityB facts:RelatedTo "right" .
"""
unit = make_fact_unit("Collision case", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
def singleton_clusters(representations):
return [[entity] for entity in representations], {}
original_create_representations = aggregator.normalizer.create_representations_batch
def force_same_normal_form(entities, entity_graphs):
representations = original_create_representations(entities, entity_graphs)
for entity in entities:
if str(entity).endswith("EntityA") or str(entity).endswith("EntityB"):
rep = representations[entity]
rep.normal_form = "collision"
rep.representation = "collision"
return representations
monkeypatch.setattr(aggregator.clusterer, "cluster_entities", singleton_clusters)
monkeypatch.setattr(
aggregator.normalizer,
"create_representations_batch",
force_same_normal_form,
)
result = aggregator.aggregate_graphs([unit])
subject_targets = {
subject
for subject, _, obj in result
if isinstance(subject, URIRef)
and str(subject).startswith(doc_iri)
and isinstance(obj, Literal)
and str(obj) in {"left", "right"}
}
assert len(subject_targets) == 2
assert len({str(target).split("/")[-1] for target in subject_targets}) == 2
assert all(str(target).startswith(doc_iri) for target in subject_targets)
def test_select_ontology_anchor_candidates_preserves_trigger_doc_iri() -> None:
aggregator = EmbeddingBasedAggregator()
doc_a = URIRef("https://example.org/docs/a")
doc_b = URIRef("https://example.org/docs/b")
known_court = URIRef("https://growgraph.dev/fcaont#AppealCourtRouen")
tentative_a = URIRef("https://growgraph.dev/fcaont#AppealCourt_Rouen")
tentative_b = URIRef("https://growgraph.dev/fcaont#AppealCourtRouenAlias")
ontology_graph = RDFGraph()
ontology_graph.add((known_court, RDFS.label, Literal("Appeal Court Rouen")))
tentative_graph = RDFGraph()
tentative_graph.add((tentative_a, RDFS.label, Literal("Appeal Court Rouen")))
tentative_graph.add((tentative_b, RDFS.label, Literal("Appeal Court Rouen")))
tentative_representations = aggregator.normalizer.create_representations_batch(
[tentative_a, tentative_b],
{
tentative_a: tentative_graph,
tentative_b: tentative_graph,
},
)
selected = aggregator._select_ontology_anchor_candidates(
tentative_entities=[tentative_a, tentative_b],
tentative_representations=tentative_representations,
tentative_doc_iris={
tentative_a: doc_a,
tentative_b: doc_b,
},
ontology_graph=ontology_graph,
known_ontology_entities={known_court},
)
assert selected[known_court] == doc_a
def test_jaccard_handles_empty_and_partial_overlap() -> None:
assert EmbeddingBasedAggregator._jaccard(set(), set()) == 1.0
assert EmbeddingBasedAggregator._jaccard(set(), {"a"}) == 0.0
assert EmbeddingBasedAggregator._jaccard({"a", "b"}, {"b", "c"}) == 1 / 3
def test_fact_to_fact_candidate_rejected_when_symbolically_incompatible(
monkeypatch,
) -> None:
doc_iri = "https://example.org/docs/case-merge-gate-1"
criminal_court = URIRef(f"{DEFAULT_IRI}/CriminalCourt")
civil_court = URIRef(f"{DEFAULT_IRI}/CivilCourt")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
doc:Case1 facts:heardAt facts:CriminalCourt .
doc:Case2 facts:heardAt facts:CivilCourt .
facts:CriminalCourt rdf:type <https://example.org/onto#CriminalCourt> .
facts:CivilCourt rdf:type <https://example.org/onto#CivilCourt> .
facts:CriminalCourt rdfs:label "Criminal Court" .
facts:CivilCourt rdfs:label "Civil Court" .
"""
unit = make_fact_unit("Two related courts", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
def force_candidate_cluster(representations):
entities = set(representations.keys())
if criminal_court in entities and civil_court in entities:
remaining = [
entity
for entity in entities
if entity not in {criminal_court, civil_court}
]
return [
[criminal_court, civil_court],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_candidate_cluster,
)
result = aggregator.aggregate_graphs([unit])
heard_at_targets = {
subject
for subject in result.subjects(RDFS.label, Literal("Criminal Court"))
if isinstance(subject, URIRef)
} | {
subject
for subject in result.subjects(RDFS.label, Literal("Civil Court"))
if isinstance(subject, URIRef)
}
assert len(heard_at_targets) == 2
assert all(str(target).startswith(doc_iri) for target in heard_at_targets)
def test_fact_to_fact_candidate_merges_when_symbolically_compatible(
monkeypatch,
) -> None:
doc_iri = "https://example.org/docs/case-merge-gate-2"
united_states = URIRef(f"{DEFAULT_IRI}/UnitedStates")
united_states_alias = URIRef(f"{DEFAULT_IRI}/united_states")
ttl = f"""
@prefix doc: <{doc_iri}/> .
@prefix facts: <{DEFAULT_IRI}/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
facts:UnitedStates rdf:type <https://example.org/onto#Country> .
facts:united_states rdf:type <https://example.org/onto#Country> .
facts:UnitedStates rdfs:label "United States" .
facts:united_states rdfs:label "United States" .
facts:UnitedStates facts:population "331000000" .
facts:united_states facts:population "332000000" .
"""
unit = make_fact_unit("US aliases", 0, doc_iri, ttl)
aggregator = EmbeddingBasedAggregator()
def force_candidate_cluster(representations):
entities = set(representations.keys())
if united_states in entities and united_states_alias in entities:
remaining = [
entity
for entity in entities
if entity not in {united_states, united_states_alias}
]
return [
[united_states, united_states_alias],
*[[entity] for entity in remaining],
], {}
return [list(entities)], {}
monkeypatch.setattr(
aggregator.clusterer,
"cluster_entities",
force_candidate_cluster,
)
result = aggregator.aggregate_graphs([unit])
population_subjects = {
subject
for subject, _, obj in result
if isinstance(subject, URIRef)
and isinstance(obj, Literal)
and str(obj) in {"331000000", "332000000"}
}
assert len(population_subjects) == 1
target = next(iter(population_subjects))
assert str(target).startswith(doc_iri)

View File

@@ -0,0 +1,89 @@
from typing import cast
from unittest.mock import Mock
from rdflib import URIRef
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.tool.agg.clustering import ClusterRepresentativeSelector
from ontocast.tool.agg.normalizer import EntityRepresentation
def test_simplicity_score_prefers_simple_uris(
cluster_representative_selector: ClusterRepresentativeSelector,
) -> None:
simple = URIRef("http://ex.org/Thing")
complex_uri = URIRef("http://example.org/deeply/nested/path/ComplexEntity_123")
simple_score = cluster_representative_selector.compute_simplicity_score(simple)
complex_score = cluster_representative_selector.compute_simplicity_score(
complex_uri
)
assert simple_score < complex_score
def test_select_representative_prefers_ontology_entity(
cluster_representative_selector: ClusterRepresentativeSelector,
) -> None:
ont_entity = URIRef("http://ontology.org/Thing")
chunk_entity = URIRef(f"{DEFAULT_IRI}/entity_long_name")
ont_rep = Mock(is_ontology_entity=True)
chunk_rep = Mock(is_ontology_entity=False)
reps = cast(
dict[URIRef, EntityRepresentation],
{ont_entity: ont_rep, chunk_entity: chunk_rep},
)
selected = cluster_representative_selector.select_representative(
[ont_entity, chunk_entity], reps
)
assert selected == ont_entity
def test_select_representative_prefers_simple_non_ontology_uri(
cluster_representative_selector: ClusterRepresentativeSelector,
) -> None:
simple = URIRef("http://chunk1.org/Thing")
complex_uri = URIRef("http://chunk2.org/very_long_complex_entity_name_123")
simple_rep = Mock(is_ontology_entity=False)
complex_rep = Mock(is_ontology_entity=False)
reps = cast(
dict[URIRef, EntityRepresentation],
{simple: simple_rep, complex_uri: complex_rep},
)
selected = cluster_representative_selector.select_representative(
[simple, complex_uri], reps
)
assert selected == simple
def test_select_representative_returns_singleton(
cluster_representative_selector: ClusterRepresentativeSelector,
) -> None:
entity = URIRef("http://chunk1.org/Only")
rep = Mock(is_ontology_entity=False)
reps = cast(dict[URIRef, EntityRepresentation], {entity: rep})
selected = cluster_representative_selector.select_representative([entity], reps)
assert selected == entity
def test_create_mapping_maps_all_cluster_members(
cluster_representative_selector: ClusterRepresentativeSelector,
) -> None:
e1 = URIRef("http://chunk1.org/A")
e2 = URIRef("http://chunk1.org/B")
e3 = URIRef("http://chunk2.org/C")
rep1 = Mock(is_ontology_entity=False)
rep2 = Mock(is_ontology_entity=False)
rep3 = Mock(is_ontology_entity=False)
reps = cast(dict[URIRef, EntityRepresentation], {e1: rep1, e2: rep2, e3: rep3})
mapping = cluster_representative_selector.create_mapping([[e1, e2], [e3]], reps)
assert mapping[e1] == mapping[e2]
assert mapping[e3] == e3

View File

@@ -0,0 +1,61 @@
from rdflib import RDF, RDFS, Literal, Namespace, URIRef
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.agg.normalizer import EntityNormalizer
def test_normalize_string_camel_case(normalizer: EntityNormalizer) -> None:
assert normalizer.normalize_string("PLRedShift") == "pl red shift"
def test_normalize_string_snake_case(normalizer: EntityNormalizer) -> None:
assert normalizer.normalize_string("PL_red_shift_value") == "pl red shift value"
def test_normalize_string_diacritics(normalizer: EntityNormalizer) -> None:
assert normalizer.normalize_string("Café") == "cafe"
def test_normalize_uri_variants(normalizer: EntityNormalizer) -> None:
camel_uri = URIRef("http://example.org/PLRedShift")
snake_uri = URIRef("http://example.org/PL_red_shift_value")
assert normalizer.normalize_uri(camel_uri) == "pl red shift"
assert normalizer.normalize_uri(snake_uri) == "pl red shift value"
def test_is_ontology_entity(normalizer: EntityNormalizer) -> None:
assert normalizer.is_ontology_entity(URIRef("http://ontology.org/Thing")) is True
assert normalizer.is_ontology_entity(URIRef(f"{DEFAULT_IRI}/entity")) is False
def test_create_representation_collects_metadata(normalizer: EntityNormalizer) -> None:
graph = RDFGraph()
ex = Namespace("http://example.org/")
ont = Namespace("http://ontology.org/")
entity = ex.TestEntity
graph.add((entity, RDF.type, ont.Thing))
graph.add((entity, RDFS.label, Literal("Test Entity")))
graph.add((entity, ex.hasValue, Literal("123")))
representation = normalizer.create_representation(entity, graph)
assert representation.entity == entity
assert "test entity" in representation.normal_form
assert representation.types == [ont.Thing]
assert "Test Entity" in representation.labels
assert ex.hasValue in representation.properties
assert "type" in representation.representation
def test_create_representation_marks_ontology_entity(
normalizer: EntityNormalizer,
) -> None:
graph = RDFGraph()
ont = Namespace("http://ontology.org/")
entity = ont.SomeClass
graph.add((entity, RDF.type, RDFS.Class))
representation = normalizer.create_representation(entity, graph)
assert representation.is_ontology_entity is True

View File

@@ -0,0 +1,107 @@
from rdflib import RDF, Literal, URIRef
from rdflib.namespace import XSD
from ontocast.onto.constants import DEFAULT_IRI, PROV, RDF_REIFIES, SCHEMA
from ontocast.onto.content_unit import ContentUnit, OutputType
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.agg.rewriter import GraphRewriter
def test_merge_graphs_with_provenance_adds_chunk_metadata(
graph_rewriter: GraphRewriter,
) -> None:
graph = RDFGraph()
entity = URIRef(f"{DEFAULT_IRI}/Entity1")
graph.add((entity, RDF.type, URIRef(f"{DEFAULT_IRI}/Thing")))
unit = ContentUnit(
text="test",
index=5,
doc_iri=URIRef("https://example.org/doc/abc123"),
graph=graph,
type=OutputType.FACTS,
)
merged = graph_rewriter.merge_graphs_with_provenance([unit], mapping={})
unit_uri = URIRef(unit.iri_absolute)
assert (unit_uri, RDF.type, PROV.Entity) in merged
assert (unit_uri, SCHEMA.position, Literal(5, datatype=XSD.integer)) in merged
assert (unit_uri, SCHEMA.identifier, Literal(unit.hid)) in merged
namespaces = {prefix: str(namespace) for prefix, namespace in merged.namespaces()}
assert namespaces["prov"] == str(PROV)
assert namespaces["schema"] == str(SCHEMA)
assert namespaces["doc"] == "https://example.org/doc/abc123/"
def test_merge_graphs_with_provenance_reifies_mapped_triple(
graph_rewriter: GraphRewriter,
) -> None:
graph = RDFGraph()
old_subject = URIRef("http://chunk.org/OldEntity")
old_predicate = URIRef("http://chunk.org/prop")
value = Literal("value")
graph.add((old_subject, old_predicate, value))
new_subject = URIRef(f"{DEFAULT_IRI}/NewEntity")
new_predicate = URIRef(f"{DEFAULT_IRI}/prop")
unit = ContentUnit(
text="test",
index=0,
doc_iri=URIRef("https://example.org/doc"),
graph=graph,
type=OutputType.FACTS,
)
merged = graph_rewriter.merge_graphs_with_provenance(
[unit],
{old_subject: new_subject, old_predicate: new_predicate},
)
stmt_nodes = list(merged.subjects(RDF_REIFIES, None))
assert len(stmt_nodes) == 1
reified = list(merged.objects(stmt_nodes[0], RDF_REIFIES))
assert len(reified) == 1
quoted = reified[0]
assert isinstance(quoted, tuple)
assert quoted[0] == new_subject
assert quoted[1] == new_predicate
assert str(quoted[2]) == str(value)
def test_shared_triple_accumulates_multiple_provenance_sources(
graph_rewriter: GraphRewriter,
) -> None:
triple = (
URIRef(f"{DEFAULT_IRI}/Alice"),
URIRef(f"{DEFAULT_IRI}/knows"),
URIRef(f"{DEFAULT_IRI}/Bob"),
)
graph_a = RDFGraph()
graph_b = RDFGraph()
graph_a.add(triple)
graph_b.add(triple)
unit_a = ContentUnit(
text="chunk 0",
index=0,
doc_iri=URIRef("https://example.org/doc"),
graph=graph_a,
type=OutputType.FACTS,
)
unit_b = ContentUnit(
text="chunk 1",
index=1,
doc_iri=URIRef("https://example.org/doc"),
graph=graph_b,
type=OutputType.FACTS,
)
merged = graph_rewriter.merge_graphs_with_provenance([unit_a, unit_b], mapping={})
statements = list(merged.subjects(RDF_REIFIES, None))
assert len(statements) == 1
sources = {str(src) for src in merged.objects(statements[0], PROV.wasDerivedFrom)}
assert str(URIRef(unit_a.iri_absolute)) in sources
assert str(URIRef(unit_b.iri_absolute)) in sources

View File

@@ -0,0 +1,123 @@
from rdflib import OWL, RDF, Literal, URIRef
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.agg.rewriter import GraphRewriter
def test_apply_mapping_to_triple(graph_rewriter: GraphRewriter) -> None:
e1 = URIRef("http://chunk1.org/e1")
p1 = URIRef("http://chunk1.org/p1")
e2 = URIRef("http://chunk1.org/e2")
e1_new = URIRef(f"{DEFAULT_IRI}/Entity1")
p1_new = URIRef(f"{DEFAULT_IRI}/property1")
e2_new = URIRef(f"{DEFAULT_IRI}/Entity2")
mapped = graph_rewriter.apply_mapping_to_triple(
e1,
p1,
e2,
{e1: e1_new, p1: p1_new, e2: e2_new},
)
assert mapped == (e1_new, p1_new, e2_new)
def test_apply_mapping_preserves_ontology_type_object(
graph_rewriter: GraphRewriter,
) -> None:
entity = URIRef("http://chunk1.org/entity")
ontology_type = URIRef("http://ontology.org/Thing")
mapped_entity = URIRef(f"{DEFAULT_IRI}/Entity")
new_s, new_p, new_o = graph_rewriter.apply_mapping_to_triple(
entity,
RDF.type,
ontology_type,
{entity: mapped_entity},
)
assert (new_s, new_p, new_o) == (mapped_entity, RDF.type, ontology_type)
def test_rewrite_graph_applies_mapping(graph_rewriter: GraphRewriter) -> None:
graph = RDFGraph()
e1 = URIRef("http://chunk1.org/e1")
e2 = URIRef("http://chunk1.org/e2")
p = URIRef("http://chunk1.org/p")
ont_type = URIRef("http://ontology.org/Thing")
graph.add((e1, p, e2))
graph.add((e1, RDF.type, ont_type))
e1_new = URIRef(f"{DEFAULT_IRI}/Entity1")
e2_new = URIRef(f"{DEFAULT_IRI}/Entity2")
p_new = URIRef(f"{DEFAULT_IRI}/relatesTo")
rewritten = graph_rewriter.rewrite_graph(graph, {e1: e1_new, e2: e2_new, p: p_new})
assert (e1_new, p_new, e2_new) in rewritten
assert (e1_new, RDF.type, ont_type) in rewritten
def test_merge_graphs_deduplicates_triples(graph_rewriter: GraphRewriter) -> None:
graph1 = RDFGraph()
graph2 = RDFGraph()
e = URIRef("http://chunk1.org/e")
p = URIRef("http://chunk1.org/p")
value = Literal("value")
graph1.add((e, p, value))
graph2.add((e, p, value))
merged = graph_rewriter.merge_graphs(
[graph1, graph2],
mapping={
e: URIRef(f"{DEFAULT_IRI}/Entity"),
p: URIRef(f"{DEFAULT_IRI}/hasValue"),
},
base_namespace=DEFAULT_IRI,
)
assert (
len(list(merged.triples((URIRef(f"{DEFAULT_IRI}/Entity"), None, value)))) == 1
)
def test_rewrite_graph_adds_sameas_for_merged_entities(
graph_rewriter: GraphRewriter,
) -> None:
graph_rewriter = GraphRewriter(add_sameas_links=True)
graph = RDFGraph()
e1 = URIRef("http://chunk1.org/e1")
e2 = URIRef("http://chunk2.org/e2")
p = URIRef("http://chunk1.org/p")
canonical = URIRef(f"{DEFAULT_IRI}/Entity")
graph.add((e1, p, Literal("a")))
graph.add((e2, p, Literal("b")))
rewritten = graph_rewriter.rewrite_graph(graph, {e1: canonical, e2: canonical})
assert len(list(rewritten.triples((canonical, OWL.sameAs, None)))) >= 1
def test_rewriter_blocks_sameas_for_forbidden_namespace() -> None:
base = "https://growgraph.dev/facts"
graph_rewriter = GraphRewriter(
add_sameas_links=True,
blocked_sameas_namespaces=(base,),
)
graph = RDFGraph()
original_fact = URIRef("https://growgraph.dev/factsPersonA")
original_doc = URIRef("https://example.org/docs/case-1/PersonA")
canonical_doc = URIRef("https://example.org/docs/case-1/PersonCanonical")
relation = URIRef("https://example.org/relation")
graph.add((original_doc, relation, Literal("value")))
rewritten = graph_rewriter.rewrite_graph(
graph,
{
original_doc: canonical_doc,
original_fact: canonical_doc,
},
)
assert (canonical_doc, OWL.sameAs, original_doc) in rewritten
assert (canonical_doc, OWL.sameAs, original_fact) not in rewritten

View File

@@ -0,0 +1,141 @@
from rdflib import OWL, RDF, RDFS, URIRef
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.agg.normalizer import EntityRepresentation
from ontocast.tool.agg.uri_builder import (
EntityRole,
URIBuilder,
detect_role,
format_structured_id,
has_structured_id,
normalize_local_name,
to_lower_camel_case,
to_pascal_case,
)
def make_representation(uri: str, normal_form: str) -> EntityRepresentation:
return EntityRepresentation(
entity=URIRef(uri),
normal_form=normal_form,
types=[],
properties=[],
labels=[],
representation=normal_form,
is_ontology_entity=False,
)
def test_pascal_case_and_lower_camel_helpers() -> None:
assert to_pascal_case("judicial decision") == "JudicialDecision"
assert to_pascal_case("case") == "Case"
assert to_lower_camel_case("has decision") == "hasDecision"
assert to_lower_camel_case("name") == "name"
def test_structured_id_helpers() -> None:
assert has_structured_id(URIRef("http://ex.org/Case_2023_456")) is True
assert has_structured_id(URIRef("http://ex.org/Person")) is False
assert (
format_structured_id(URIRef("http://ex.org/case_2023_456")) == "Case_2023_456"
)
def test_detect_role_for_class_property_and_instance() -> None:
graph = RDFGraph()
class_entity = URIRef("http://ex.org/Person")
prop_entity = URIRef("http://ex.org/hasAge")
instance_entity = URIRef("http://ex.org/Alice")
graph.add((class_entity, RDF.type, RDFS.Class))
graph.add((prop_entity, RDF.type, OWL.DatatypeProperty))
graph.add((instance_entity, RDF.type, class_entity))
assert detect_role(class_entity, graph) == EntityRole.CLASS
assert detect_role(prop_entity, graph) == EntityRole.PROPERTY
assert detect_role(instance_entity, graph) == EntityRole.INSTANCE
def test_normalize_local_name_uses_role_specific_formatting() -> None:
class_rep = make_representation(
"http://ex.org/JudicialDecision", "judicial decision"
)
prop_rep = make_representation("http://ex.org/hasDecision", "has decision")
structured_rep = make_representation("http://ex.org/Case_2023_456", "case 2023 456")
assert normalize_local_name(class_rep, EntityRole.CLASS) == "JudicialDecision"
assert normalize_local_name(prop_rep, EntityRole.PROPERTY) == "hasDecision"
assert normalize_local_name(structured_rep, EntityRole.INSTANCE) == "Case_2023_456"
def test_build_uri_preserves_ontology_entities(uri_builder: URIBuilder) -> None:
entity = URIRef("http://ontology.org/Thing")
rep = EntityRepresentation(
entity=entity,
normal_form="thing",
types=[],
properties=[],
labels=[],
representation="thing",
is_ontology_entity=True,
)
assert uri_builder.build_uri(entity, rep, EntityRole.CLASS) == entity
def test_compose_mappings_flattens_two_stage_mapping() -> None:
e1 = URIRef("http://chunk1.org/A")
e2 = URIRef("http://chunk2.org/B")
representative = URIRef("http://chunk1.org/A")
final = URIRef(f"{DEFAULT_IRI}/SomeEntity")
composed = URIBuilder.compose_mappings(
{e1: representative, e2: representative},
{representative: final},
)
assert composed[e1] == final
assert composed[e2] == final
def test_create_entity_uri_mapping_uses_doc_namespace_and_avoids_collisions() -> None:
builder = URIBuilder(base_iri=DEFAULT_IRI)
doc_iri = URIRef("https://example.org/docs/case-1")
left = URIRef("https://growgraph.dev/factsEntityA")
right = URIRef("https://growgraph.dev/factsEntityB")
left_canonical = URIRef("https://growgraph.dev/factsCanonicalA")
right_canonical = URIRef("https://growgraph.dev/factsCanonicalB")
shared_representation = EntityRepresentation(
entity=left_canonical,
normal_form="collision",
types=[],
properties=[],
labels=[],
representation="collision",
is_ontology_entity=False,
)
representations = {
left_canonical: shared_representation,
right_canonical: EntityRepresentation(
entity=right_canonical,
normal_form="collision",
types=[],
properties=[],
labels=[],
representation="collision",
is_ontology_entity=False,
),
}
mapping = builder.create_entity_uri_mapping(
identity_mapping={left: left_canonical, right: right_canonical},
representations=representations,
entity_doc_iris={left: doc_iri, right: doc_iri},
entity_is_ontology={left_canonical: False, right_canonical: False},
)
assert str(mapping[left]).startswith(f"{doc_iri}/")
assert str(mapping[right]).startswith(f"{doc_iri}/")
assert mapping[left] != mapping[right]

View File

@@ -0,0 +1,549 @@
"""Pytest configuration for test suite."""
import importlib
import json
import logging
import os
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import pytest
from suthing import FileHandle
if TYPE_CHECKING:
from langchain_huggingface import HuggingFaceEmbeddings
from ontocast.config import (
Config,
LLMConfig,
LLMProvider,
OpenAIModel,
PathConfig,
ToolConfig,
)
from ontocast.onto.constants import DEFAULT_DOMAIN
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.state import AgentState
from ontocast.tool import (
FilesystemTripleStoreManager,
LLMTool,
OntologyManager,
)
from ontocast.tool.triple_manager.mock import (
MockFusekiTripleStoreManager,
MockNeo4jTripleStoreManager,
)
from ontocast.toolbox import ToolBox
logger = logging.getLogger(__name__)
# Suppress deprecation warnings from third-party libraries that we cannot control
# Note: We adapt to new conventions where possible (e.g., using pyld directly for JSON-LD
# instead of rdflib's deprecated ConjunctiveGraph). These suppressions are only for
# warnings from external libraries that we cannot modify.
warnings.filterwarnings(
"ignore",
category=DeprecationWarning,
message=".*@model_validator.*mode='after'.*",
module="docling_core",
)
def pytest_configure(config):
"""Configure pytest to suppress known deprecation warnings from third-party libraries."""
# Suppress Pydantic deprecation warnings from docling_core (third-party library we cannot modify)
config.addinivalue_line(
"filterwarnings",
"ignore::DeprecationWarning:docling_core",
)
@pytest.fixture
def current_domain():
return os.getenv("CURRENT_DOMAIN", DEFAULT_DOMAIN)
@pytest.fixture
def llm_base_url():
return os.getenv("LLM_BASE_URL", None)
@pytest.fixture
def provider():
return os.getenv("LLM_PROVIDER", LLMProvider.OPENAI)
@pytest.fixture
def model_name():
return OpenAIModel(os.getenv("LLM_MODEL_NAME", OpenAIModel.GPT4_O_MINI))
@pytest.fixture
def temperature():
return 0.1
@pytest.fixture
def test_ontology():
from ontocast.onto.ontology import Ontology
graph = RDFGraph._from_turtle_str(
"""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix ex: <http://example.org/to/> .
@prefix schema: <https://schema.org/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
ex: rdf:type owl:Ontology ;
rdfs:label "Test Domain Ontology" ;
dcterms:title "test_onto"^^rdf:XMLLiteral ;
rdfs:comment "An ontology for testing that covers basic concepts and relationships in a test domain. Used for validating ontology processing functionality." .
ex:SpaceTimeEvent a rdfs:Class ;
rdfs:label "Event" ;
rdfs:comment "Some kind of event with spacetime coordinates" ;
rdfs:subClassOf schema:Event . """
)
return Ontology(graph=graph)
@pytest.fixture
def ontology_path():
return Path("data/ontologies")
@pytest.fixture
def working_directory():
return None
# return Path("test/tmp")
@pytest.fixture
def llm_tool(provider, model_name, temperature, llm_base_url):
config = LLMConfig(
provider=LLMProvider(provider),
model_name=model_name,
temperature=temperature,
base_url=llm_base_url,
)
llm_tool = LLMTool.create(config=config)
return llm_tool
@pytest.fixture
def tsm_tool(ontology_path, working_directory):
return FilesystemTripleStoreManager(
working_directory=working_directory, ontology_path=ontology_path
)
@pytest.fixture
def tools(
ontology_path,
working_directory,
model_name,
temperature,
provider,
llm_base_url,
om_tool_fname,
) -> ToolBox:
# Create LLM config
llm_config = LLMConfig(
provider=LLMProvider(provider),
model_name=model_name,
temperature=temperature,
base_url=llm_base_url,
)
# Create path config
path_config = PathConfig(
working_directory=working_directory,
ontology_directory=ontology_path,
)
# Create tool config
tool_config = ToolConfig(
llm_config=llm_config,
path_config=path_config,
)
# Create main config
config = Config(tool_config=tool_config)
tools: ToolBox = ToolBox(config=config)
import asyncio
asyncio.run(tools.initialize())
# Load ontologies from JSON file if it exists (using Pydantic's load method)
json_path = Path(om_tool_fname)
if json_path.exists():
try:
loaded_om = OntologyManager.load(json_path)
# Merge loaded ontologies into the toolbox's ontology manager
for iri, versions in loaded_om.ontology_versions.items():
for ontology in versions:
tools.ontology_manager.add_ontology(ontology)
except Exception:
# Silently fail if JSON loading fails
pass
return tools
@pytest.fixture
def state_chunked(state_chunked_filename):
return AgentState.load(state_chunked_filename)
@pytest.fixture
def state_ontology_selected(state_onto_selected_filename):
return AgentState.load(state_onto_selected_filename)
@pytest.fixture
def state_ontology_rendered(state_ontology_rendered_filename):
return AgentState.load(state_ontology_rendered_filename)
@pytest.fixture
def state_ontology_criticized(state_ontology_criticized_filename):
return AgentState.load(state_ontology_criticized_filename)
@pytest.fixture
def state_rendered_facts(state_rendered_facts_filename):
return AgentState.load(state_rendered_facts_filename)
@pytest.fixture
def state_sublimated(state_sublimated_filename):
return AgentState.load(state_sublimated_filename)
@pytest.fixture
def state_facts_failed(state_facts_failed_filename):
return AgentState.load(state_facts_failed_filename)
@pytest.fixture
def state_facts_success(state_facts_success_filename):
return AgentState.load(state_facts_success_filename)
@pytest.fixture
def agent_state_select_ontology_null(state_onto_null_filename):
return AgentState.load(state_onto_null_filename)
@pytest.fixture
def om_tool(om_tool_fname):
try:
return OntologyManager.load(om_tool_fname)
except (FileNotFoundError, Exception):
return OntologyManager()
@pytest.fixture
def max_iter():
return 2
@pytest.fixture
def apple_report():
r = FileHandle.load(Path("data/json/fin.10Q.apple.json"))
return {"text": r["text"]}
@pytest.fixture
def random_report():
return FileHandle.load(Path("data/json/random.json"))
@pytest.fixture
def agent_state_onto_fresh():
return AgentState.load("test/data/state_onto_addendum.json")
@pytest.fixture(scope="session")
def neo4j_uri():
return os.environ.get("NEO4J_URI", "bolt://localhost:7687")
@pytest.fixture(scope="session")
def neo4j_auth():
return os.environ.get("NEO4J_AUTH", "neo4j/test")
@pytest.fixture(scope="session")
def neo4j_triple_store_manager(neo4j_uri, neo4j_auth):
"""Mock Neo4j triple store manager for testing."""
return MockNeo4jTripleStoreManager(uri=neo4j_uri, auth=neo4j_auth, clean=True)
@pytest.fixture(scope="session")
def fuseki_triple_store_manager():
"""Mock Fuseki triple store manager for testing."""
uri = os.environ.get("FUSEKI_URI", "http://localhost:3030/test")
auth = os.environ.get("FUSEKI_AUTH", None)
if auth and "/" in auth:
auth = tuple(auth.split("/", 1))
return MockFusekiTripleStoreManager(uri=uri, auth=auth, dataset="test", clean=True)
@pytest.fixture(scope="session")
def real_embeddings() -> Optional["HuggingFaceEmbeddings"]:
"""Fixture providing real HuggingFace embeddings if available, otherwise None.
Uses the same model as in split_chunks.py for consistency.
Session-scoped so the model is loaded only once per test session and reused.
"""
try:
torch = importlib.import_module("torch")
from langchain_huggingface import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(
model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
model_kwargs={
"device": "cuda"
if torch is not None and torch.cuda.is_available()
else "cpu"
},
encode_kwargs={"normalize_embeddings": False},
)
return embeddings
except ImportError as e:
logger.error(f"Could not import HuggingFaceEmbeddings: {e}")
return None
except Exception:
return None
@pytest.fixture(scope="session")
def mock_embeddings():
try:
from langchain_core.embeddings import Embeddings
except ImportError as e:
logger.error(f"Could not import Embeddings: {e}")
class MockEmbeddings(Embeddings):
"""Mock embeddings for testing.
Returns deterministic embeddings based on text content.
"""
def __init__(self, embedding_dim: int = 384):
"""Initialize mock embeddings.
Args:
embedding_dim: Dimension of the embedding vectors. Defaults to 384.
"""
self.embedding_dim = embedding_dim
# Simple hash-based embedding for deterministic results
self._cache: dict[str, list[float]] = {}
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for a list of texts."""
return [self.embed_query(text) for text in texts]
def embed_query(self, text: str) -> list[float]:
"""Generate an embedding for a single text."""
if text in self._cache:
return self._cache[text]
from ontocast.util import render_text_hash
hash_int = int(render_text_hash(text, digits=None), 16)
embedding = []
for i in range(self.embedding_dim):
val = (hash_int + i * 17) % 1000
embedding.append((val / 1000.0) - 0.5)
self._cache[text] = embedding
return embedding
return MockEmbeddings()
@pytest.fixture(scope="session")
def embeddings(real_embeddings, mock_embeddings):
"""Fixture providing embeddings - prefers real embeddings, falls back to mock.
Session-scoped so the model is loaded only once per test session.
"""
if real_embeddings is not None:
return real_embeddings
return mock_embeddings
@pytest.fixture
def sample_text():
"""Fixture providing realistic sample text (~10k characters) from clinical trial JSON."""
json_file = (
Path(__file__).parent.parent
/ "data"
/ "json"
/ "clinical.trials.NCT01239745.json"
)
if json_file.exists():
data = json.load(open(json_file))
def json_to_md(data, depth=1):
md = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (str, int, float, bool, type(None))):
md.append(f"{key}: {value}\n")
elif isinstance(value, dict):
md.append(f"{key}:\n")
md.extend(json_to_md(value, depth + 1))
elif isinstance(value, list):
md.append(f"{key}:\n")
for item in value:
if isinstance(item, (str, int, float, bool, type(None))):
md.append(f" - {item}\n")
else:
md.extend(json_to_md(item, depth + 1))
elif isinstance(data, list):
for item in data:
if isinstance(item, (str, int, float, bool, type(None))):
md.append(f"- {item}\n")
else:
md.extend(json_to_md(item, depth))
return md
text_lines = json_to_md(data)
text = "".join(text_lines)
return text[:10000]
# Fallback
return (
"This is the first sentence. "
"This is the second sentence. "
"This is the third sentence. "
"This is the fourth sentence. "
"This is the fifth sentence. "
"This is the sixth sentence. "
"This is the seventh sentence. "
"This is the eighth sentence. "
"This is the ninth sentence. "
"This is the tenth sentence."
) * 100
@pytest.fixture
def long_text():
"""Fixture providing longer text for testing min/max size constraints."""
paragraphs = []
for i in range(5):
sentences = []
for j in range(10):
sentences.append(
f"This is paragraph {i + 1}, sentence {j + 1}. "
f"It contains some content to make it longer. "
f"Here is more text to ensure we have enough characters."
)
paragraphs.append(" ".join(sentences))
return "\n\n".join(paragraphs)
# --- Aggregator test fixtures (used by test_aggregator.py) ---
@pytest.fixture
def normalizer():
"""EntityNormalizer instance for aggregator tests."""
from ontocast.tool.agg.normalizer import EntityNormalizer
return EntityNormalizer()
@pytest.fixture
def cluster_representative_selector():
"""ClusterRepresentativeSelector instance for aggregator tests."""
from ontocast.tool.agg.clustering import ClusterRepresentativeSelector
return ClusterRepresentativeSelector()
@pytest.fixture
def uri_builder():
"""URIBuilder instance for aggregator tests."""
from ontocast.tool.agg.uri_builder import URIBuilder
return URIBuilder()
@pytest.fixture
def graph_rewriter():
"""GraphRewriter instance for aggregator tests (add_sameas_links=True)."""
from ontocast.tool.agg.rewriter import GraphRewriter
return GraphRewriter(add_sameas_links=False)
def triple_store_roundtrip(manager, test_ontology):
# test_ontology is already an Ontology object, use it directly
ontology = test_ontology
# Store ontology
manager.serialize(ontology)
# Fetch ontologies
ontologies = manager.fetch_ontologies()
# There should be at least one ontology with the correct ontology_id
assert any(o.ontology_id == "to" for o in ontologies)
# The ontology graph should have the same number of triples as the input
assert len(ontologies[0].graph) == len(ontology.graph)
def triple_store_serialize_facts(manager):
"""Test serializing facts (RDF triples) to triple store and retrieving them."""
# Create test facts
facts = RDFGraph._from_turtle_str(
"""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix ex: <http://example.org/test/> .
@prefix schema: <https://schema.org/> .
ex:Person a rdfs:Class ;
rdfs:label "Person" ;
rdfs:comment "A human being" .
ex:John a ex:Person ;
rdfs:label "John Doe" ;
schema:name "John Doe" ;
schema:email "john@example.com" .
ex:Jane a ex:Person ;
rdfs:label "Jane Smith" ;
schema:name "Jane Smith" ;
schema:email "jane@example.com" .
ex:knows a rdf:Property ;
rdfs:label "knows" ;
rdfs:comment "Relationship between people who know each other" .
ex:John ex:knows ex:Jane .
"""
)
# Verify we have the expected number of triples
expected_triple_count = len(facts)
assert expected_triple_count == 15, "Test facts should contain triples"
# Serialize facts to triple store
result = manager.serialize(facts)
assert result is not None, "serialize should return a result"
def triple_store_serialize_empty_facts(manager):
"""Test serializing empty facts graph."""
# Create empty facts
empty_facts = RDFGraph()
# Serialize empty facts - should not raise an error
result = manager.serialize(empty_facts)
assert result is not None, "serialize should return a result even for empty graph"

View File

@@ -0,0 +1,214 @@
import os
from pathlib import Path
import pytest
from rdflib import URIRef
from ontocast.agent.criticise_facts import criticise_facts
from ontocast.agent.criticise_ontology import criticise_ontology
from ontocast.agent.render_facts import render_facts
from ontocast.agent.render_ontology import render_ontology
from ontocast.config import Config, LLMProvider
from ontocast.onto.content_unit import ContentUnit
from ontocast.onto.enum import Status
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
from ontocast.toolbox import ToolBox
RUN_MANUAL_TESTS = os.getenv("ONTOCAST_RUN_MANUAL_TESTS", "0") == "1"
pytestmark = [
pytest.mark.skipif(
not RUN_MANUAL_TESTS,
reason="Set ONTOCAST_RUN_MANUAL_TESTS=1 to run live manual tests.",
),
]
def _require_env(name: str) -> str:
value = os.getenv(name)
if value is None or value.strip() == "":
pytest.fail(f"Missing required environment variable: {name}")
return value
def _create_tools_from_env() -> ToolBox:
_ = _require_env("LLM_PROVIDER")
_ = _require_env("LLM_MODEL_NAME")
provider = LLMProvider(_require_env("LLM_PROVIDER").lower())
if provider == LLMProvider.OPENAI:
_ = _require_env("LLM_API_KEY")
elif provider == LLMProvider.OLLAMA:
_ = _require_env("LLM_BASE_URL")
_ = _require_env("ONTOCAST_WORKING_DIRECTORY")
config = Config()
config.validate_llm_config()
if config.tool_config.path_config.working_directory is None:
pytest.fail("ONTOCAST_WORKING_DIRECTORY must be set to run manual agent tests.")
config.tool_config.path_config.working_directory = Path(
config.tool_config.path_config.working_directory
).expanduser()
config.tool_config.path_config.working_directory.mkdir(parents=True, exist_ok=True)
if config.tool_config.path_config.ontology_directory is not None:
config.tool_config.path_config.ontology_directory = Path(
config.tool_config.path_config.ontology_directory
).expanduser()
return ToolBox(config)
@pytest.fixture(scope="module")
def live_tools() -> ToolBox:
return _create_tools_from_env()
@pytest.fixture
def realistic_text() -> str:
return (
"ACME Robotics announced that it signed a three-year collaboration with "
"North Valley Hospital in Berlin to deploy autonomous delivery carts across "
"seven departments. The pilot starts in May 2026 and is co-funded by the "
"hospital innovation office and the regional health authority. "
"The agreement names Dr. Lena Fischer as clinical lead and ACME CTO "
"Rahul Mehta as technical lead. Success metrics include a 20 percent "
"reduction in nurse walking distance, fewer late medication rounds, and "
"weekly safety audits."
)
def _build_seed_ontology() -> Ontology:
graph = RDFGraph()
graph.parse(
data="""
@prefix ex: <https://example.com/health#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:health a owl:Ontology ;
rdfs:label "Healthcare Collaboration Ontology" .
ex:Organization a rdfs:Class .
ex:Hospital a rdfs:Class ; rdfs:subClassOf ex:Organization .
ex:Company a rdfs:Class ; rdfs:subClassOf ex:Organization .
ex:Person a rdfs:Class .
ex:collaboratesWith a owl:ObjectProperty .
ex:hasLead a owl:ObjectProperty .
ex:locatedIn a owl:ObjectProperty .
""",
format="turtle",
)
return Ontology(graph=graph, iri="https://example.com/health")
def _build_content_unit(text: str, with_seed_facts: bool = False) -> ContentUnit:
unit = ContentUnit(
text=text,
index=0,
doc_iri=URIRef("https://example.com/doc/manual-live"),
)
if with_seed_facts:
unit.graph.parse(
data="""
@prefix ex: <https://example.com/health#> .
@prefix facts: <https://example.com/facts/> .
facts:acme ex:collaboratesWith facts:north_valley_hospital .
""",
format="turtle",
)
return unit
@pytest.mark.anyio
async def test_render_facts_live_llm(live_tools: ToolBox, realistic_text: str) -> None:
state = UnitFactsState(
content_unit=_build_content_unit(realistic_text),
ontology_snapshot=_build_seed_ontology(),
facts_user_instruction=(
"Extract organizations, people, timeline details, and measurable targets."
),
)
result = await render_facts(state, live_tools.get_atomic_tools())
assert result.failure_stage is None
assert result.status == Status.SUCCESS
assert len(result.content_unit.graph) > 0
assert result.budget_tracker.calls_count > 0
@pytest.mark.anyio
async def test_criticise_facts_live_llm(
live_tools: ToolBox, realistic_text: str
) -> None:
state = UnitFactsState(
content_unit=_build_content_unit(realistic_text),
ontology_snapshot=_build_seed_ontology(),
facts_user_instruction=(
"Prioritize correct entities, relations, and measurable outcomes."
),
)
rendered = await render_facts(state, live_tools.get_atomic_tools())
assert len(rendered.content_unit.graph) > 0
critiqued = await criticise_facts(rendered, live_tools.get_atomic_tools())
assert (
critiqued.failure_stage is None
or critiqued.failure_stage.name == "FACTS_CRITIQUE"
)
assert critiqued.status in (Status.SUCCESS, Status.FAILED)
assert critiqued.budget_tracker.calls_count > 0
@pytest.mark.anyio
async def test_render_ontology_live_llm(
live_tools: ToolBox, realistic_text: str
) -> None:
null_ontology = Ontology()
state = UnitOntologyState(
content_unit=_build_content_unit(realistic_text),
ontology_snapshot=null_ontology,
ontology_user_instruction=(
"Create a compact ontology for healthcare logistics collaboration."
),
)
result = await render_ontology(state, live_tools.get_atomic_tools())
assert result.failure_stage is None
assert result.status == Status.SUCCESS
assert not result.current_ontology.is_null()
assert len(result.current_ontology.graph) > 0
assert result.budget_tracker.calls_count > 0
@pytest.mark.anyio
async def test_criticise_ontology_live_llm(
live_tools: ToolBox, realistic_text: str
) -> None:
null_ontology = Ontology()
state = UnitOntologyState(
content_unit=_build_content_unit(realistic_text),
ontology_snapshot=null_ontology,
ontology_user_instruction=(
"Keep class hierarchy minimal and ensure relation naming consistency."
),
)
rendered = await render_ontology(state, live_tools.get_atomic_tools())
assert not rendered.current_ontology.is_null()
assert len(rendered.current_ontology.graph) > 0
critiqued = await criticise_ontology(rendered, live_tools.get_atomic_tools())
assert (
critiqued.failure_stage is None
or critiqued.failure_stage.name == "ONTOLOGY_CRITIQUE"
)
assert critiqued.status in (Status.SUCCESS, Status.FAILED)
assert critiqued.budget_tracker.calls_count > 0

View File

@@ -0,0 +1,181 @@
import importlib
from types import SimpleNamespace
from typing import cast
import pytest
from rdflib import URIRef
from ontocast.onto.content_unit import ContentUnit
from ontocast.onto.enum import FailureStage, Status
from ontocast.onto.model import (
FactsCritiqueReport,
FactsRenderReport,
SemanticTriplesFactsReport,
TripleFix,
)
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.unit_states import UnitFactsState
from ontocast.tool.atomic import AtomicToolBox
criticise_facts_module = importlib.import_module("ontocast.agent.criticise_facts")
render_facts_module = importlib.import_module("ontocast.agent.render_facts")
def _build_content_unit(with_graph: bool = False) -> ContentUnit:
unit = ContentUnit(
text="Alice works for ACME.",
index=0,
doc_iri=URIRef("https://example.com/doc/d1"),
)
if with_graph:
unit.graph.parse(
data="""
@prefix ex: <https://example.com/ns#> .
ex:alice ex:worksFor ex:acme .
""",
format="turtle",
)
return unit
def _build_ontology() -> Ontology:
ontology_graph = RDFGraph()
ontology_graph.parse(
data="""
@prefix onto: <https://example.com/onto#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
onto:CompanyOntology a owl:Ontology .
""",
format="turtle",
)
return Ontology(graph=ontology_graph, iri="https://example.com/onto")
def _build_tools() -> AtomicToolBox:
async def get_llm_tool(_budget_tracker):
return object()
return cast(AtomicToolBox, SimpleNamespace(get_llm_tool=get_llm_tool))
@pytest.mark.anyio
async def test_render_facts_routes_to_fresh_when_graph_is_empty(monkeypatch) -> None:
calls = {"fresh": 0, "update": 0}
async def fake_fresh(state: UnitFactsState, tools) -> UnitFactsState:
calls["fresh"] += 1
return state
async def fake_update(state: UnitFactsState, tools) -> UnitFactsState:
calls["update"] += 1
return state
monkeypatch.setattr(render_facts_module, "render_facts_fresh", fake_fresh)
monkeypatch.setattr(render_facts_module, "render_facts_update", fake_update)
state = UnitFactsState(
content_unit=_build_content_unit(with_graph=False),
ontology_snapshot=_build_ontology(),
)
result = await render_facts_module.render_facts(state, tools=_build_tools())
assert result is state
assert calls["fresh"] == 1
assert calls["update"] == 0
@pytest.mark.anyio
async def test_render_facts_fresh_sets_success_and_budget(monkeypatch) -> None:
async def fake_call_llm_with_retry(**kwargs):
rendered_graph = RDFGraph()
rendered_graph.parse(
data="""
@prefix ex: <https://example.com/ns#> .
ex:alice ex:worksFor ex:acme .
""",
format="turtle",
)
return FactsRenderReport(
facts_report=SemanticTriplesFactsReport(
semantic_graph=rendered_graph,
ontology_relevance_score=95,
triples_generation_score=94,
)
)
monkeypatch.setattr(
render_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
)
state = UnitFactsState(
content_unit=_build_content_unit(with_graph=False),
ontology_snapshot=_build_ontology(),
)
result = await render_facts_module.render_facts_fresh(state, tools=_build_tools())
assert result.status == Status.SUCCESS
assert result.failure_stage is None
assert len(result.content_unit.graph) == 1
assert result.budget_tracker.facts_operations_count == 1
assert result.budget_tracker.facts_triples_generated == 1
@pytest.mark.anyio
async def test_criticise_facts_marks_failed_and_sets_suggestions(monkeypatch) -> None:
async def fake_call_llm_with_retry(**kwargs):
return FactsCritiqueReport(
success=False,
score=35,
actionable_triple_fixes=[
TripleFix(
text_fragment="Alice works for ACME.",
action="ADD",
severity="important",
explanation="Missing employment relation triple.",
correct_value="ex:alice ex:worksFor ex:acme .",
)
],
systemic_critique_summary="Misses key relations.",
)
monkeypatch.setattr(
criticise_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
)
state = UnitFactsState(
content_unit=_build_content_unit(with_graph=True),
ontology_snapshot=_build_ontology(),
)
result = await criticise_facts_module.criticise_facts(state, tools=_build_tools())
assert result.status == Status.FAILED
assert result.failure_stage == FailureStage.FACTS_CRITIQUE
assert len(result.suggestions.actionable_fixes) == 1
assert result.failure_reason == "Facts Critic suggests improvements"
@pytest.mark.anyio
async def test_criticise_facts_accepts_high_score_even_when_success_false(
monkeypatch,
) -> None:
async def fake_call_llm_with_retry(**kwargs):
return FactsCritiqueReport(
success=False,
score=95,
actionable_triple_fixes=[],
systemic_critique_summary="",
)
monkeypatch.setattr(
criticise_facts_module, "call_llm_with_retry", fake_call_llm_with_retry
)
state = UnitFactsState(
content_unit=_build_content_unit(with_graph=True),
ontology_snapshot=_build_ontology(),
)
result = await criticise_facts_module.criticise_facts(state, tools=_build_tools())
assert result.status == Status.SUCCESS
assert result.failure_stage is None

View File

@@ -0,0 +1,16 @@
from ontocast.config import AggregationConfig, Config
def test_aggregation_config_defaults() -> None:
config = AggregationConfig()
assert config.embedding_model == "paraphrase-multilingual-MiniLM-L12-v2"
assert config.similarity_threshold == 0.80
def test_aggregation_config_reads_env(monkeypatch) -> None:
monkeypatch.setenv("AGG_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
monkeypatch.setenv("AGG_SIMILARITY_THRESHOLD", "0.73")
config = Config()
assert config.tool_config.aggregation.embedding_model == "all-MiniLM-L6-v2"
assert config.tool_config.aggregation.similarity_threshold == 0.73

View File

@@ -0,0 +1,527 @@
"""Test for GraphUpdate SPARQL query generation and execution.
This test verifies that GraphUpdate.generate_sparql_queries() generates valid SPARQL
queries that can be executed on RDFGraph instances using rdflib's update() method.
"""
from rdflib import Literal, URIRef
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.sparql_models import (
GenericSparqlQuery,
GraphUpdate,
TripleOp,
)
def test_rdfgraph_recovers_dangling_semicolon_at_eof() -> None:
"""RDFGraph should recover from common LLM-truncated Turtle at EOF."""
ttl = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Case85_968 a ex:Appeal ;
ex:appealsTo ex:Cassation ;
"""
graph = RDFGraph._from_turtle_str(ttl)
assert len(graph) == 2
assert (
URIRef("http://example.org/Case85_968"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Appeal"),
) in graph
assert (
URIRef("http://example.org/Case85_968"),
URIRef("http://example.org/appealsTo"),
URIRef("http://example.org/Cassation"),
) in graph
def test_graph_update_with_language_tags():
"""Test GraphUpdate with language-tagged literals."""
# Create initial RDFGraph
graph = RDFGraph._from_turtle_str(
"""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix ex: <http://example.org/> .
ex:Test a rdfs:Class .
"""
)
initial_triple_count = len(graph)
# Create Turtle with language-tagged literals
triples = """
@prefix ex: <http://example.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Test rdfs:label "Test Label"@en ;
rdfs:comment "Un commentaire"@fr .
"""
graph_update = GraphUpdate(
triple_operations=[
TripleOp(
type="insert",
graph=triples, # type: ignore[arg-type]
prefixes={"ex": "http://example.org/"},
)
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate one query
assert len(queries) == 1
# Execute the query on the graph
graph.update(queries[0])
# Verify new triples were added
assert len(graph) == initial_triple_count + 2
def test_graph_update_insert_operation():
"""Test GraphUpdate with TripleOp insert operations using Turtle format."""
# Create initial RDFGraph
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Person a rdfs:Class ;
rdfs:label "Person" .
"""
)
initial_triple_count = len(graph)
# Create triples in Turtle format
triples = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:John a ex:Person ;
rdfs:label "John Doe" .
"""
graph_update = GraphUpdate(
triple_operations=[
TripleOp(
type="insert",
graph=triples, # type: ignore[arg-type]
prefixes={"ex": "http://example.org/"},
)
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate one query
assert len(queries) == 1
# Execute the query on the graph
graph.update(queries[0])
# Verify new triples were added
assert len(graph) == initial_triple_count + 2
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Person"),
) in graph
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
Literal("John Doe"),
) in graph
def test_graph_update_extract_insert_graph() -> None:
"""Test GraphUpdate.extract_insert_graph returns only insert triples."""
insert_ttl = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Person a rdfs:Class .
ex:Person rdfs:label "Person" .
"""
delete_ttl = """
@prefix ex: <http://example.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Obsolete a rdfs:Class .
"""
gu = GraphUpdate(
triple_operations=[
TripleOp(type="insert", graph=insert_ttl), # type: ignore[arg-type]
TripleOp(type="delete", graph=delete_ttl), # type: ignore[arg-type]
]
)
insert_graph = gu.extract_insert_graph()
assert len(insert_graph) == 2
person_uri = URIRef("http://example.org/Person")
rdf_type = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")
rdfs_class = URIRef("http://www.w3.org/2000/01/rdf-schema#Class")
rdfs_label = URIRef("http://www.w3.org/2000/01/rdf-schema#label")
assert (person_uri, rdf_type, rdfs_class) in insert_graph
assert (person_uri, rdfs_label, Literal("Person")) in insert_graph
obsolete_uri = URIRef("http://example.org/Obsolete")
assert (obsolete_uri, rdf_type, rdfs_class) not in insert_graph
def test_graph_update_delete_operation():
"""Test GraphUpdate with TripleOp delete operations."""
# Create RDFGraph with existing triples
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Person a rdfs:Class ;
rdfs:label "Person" .
ex:John a ex:Person ;
rdfs:label "John Doe" .
ex:Jane a ex:Person ;
rdfs:label "Jane Smith" .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with TripleOp using Turtle format
triples = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:John a ex:Person ;
rdfs:label "John Doe" .
"""
graph_update = GraphUpdate(
triple_operations=[
TripleOp(
type="delete",
graph=triples, # type: ignore[arg-type]
prefixes={"ex": "http://example.org/"},
)
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate one query
assert len(queries) == 1
# Execute the query on the graph
graph.update(queries[0])
# Verify triples were removed
assert len(graph) == initial_triple_count - 2
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Person"),
) not in graph
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
Literal("John Doe"),
) not in graph
# Jane should still be there
assert (
URIRef("http://example.org/Jane"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Person"),
) in graph
def test_graph_update_with_prefixes():
"""Test GraphUpdate with TripleOp operations that declare custom prefixes."""
# Create initial RDFGraph
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Person a rdf:Class .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with custom prefixes using Turtle format
triples = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix schema: <https://schema.org/> .
ex:John a ex:Person ;
schema:name "John Doe" .
"""
graph_update = GraphUpdate(
triple_operations=[
TripleOp(
type="insert",
graph=triples, # type: ignore[arg-type]
prefixes={
"ex": "http://example.org/",
"schema": "https://schema.org/",
},
),
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate one query
assert len(queries) == 1
# Verify the query includes PREFIX declarations
assert "PREFIX schema: <https://schema.org/>" in queries[0]
# Execute the query on the graph
graph.update(queries[0])
# Verify new triples were added
assert len(graph) == initial_triple_count + 2
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Person"),
) in graph
assert (
URIRef("http://example.org/John"),
URIRef("https://schema.org/name"),
Literal("John Doe"),
) in graph
def test_graph_update_mixed_operations_ordered():
"""Test GraphUpdate with mixed operations in specific order."""
# Create initial RDFGraph
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Person a rdfs:Class ;
rdfs:label "Person" .
ex:John a ex:Person ;
rdfs:label "John Doe" .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with mixed operations using Turtle format
insert_jane = """
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix schema: <https://schema.org/> .
ex:Jane a ex:Person ;
schema:name "Jane Smith" .
"""
delete_john_label = """
@prefix ex: <http://example.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:John rdfs:label "John Doe" .
"""
insert_john_label = """
@prefix ex: <http://example.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:John rdfs:label "John Updated" .
"""
graph_update = GraphUpdate(
triple_operations=[
# First: Insert new person with custom schema prefix
TripleOp(
type="insert",
graph=insert_jane, # type: ignore[arg-type]
prefixes={
"ex": "http://example.org/",
"schema": "https://schema.org/",
},
),
# Second: Delete John's label
TripleOp(
type="delete",
graph=delete_john_label, # type: ignore[arg-type]
prefixes={"ex": "http://example.org/"},
),
# Third: Insert new label for John
TripleOp(
type="insert",
graph=insert_john_label, # type: ignore[arg-type]
prefixes={"ex": "http://example.org/"},
),
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate 3 queries (one for each TripleOp)
assert len(queries) == 3
# Execute queries in order
for query in queries:
graph.update(query)
# Verify final state
# Should have: 4 initial + 2 added (Jane) - 1 deleted (John's old label) + 1 added (John's new label) = 6 triples
assert (
len(graph) == initial_triple_count + 2
) # +2 net change: +2 for Jane, -1 for John's old label, +1 for John's new label
# Verify John's label was updated
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
Literal("John Updated"),
) in graph
assert (
URIRef("http://example.org/John"),
URIRef("http://www.w3.org/2000/01/rdf-schema#label"),
Literal("John Doe"),
) not in graph
# Verify Jane was added
assert (
URIRef("http://example.org/Jane"),
URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
URIRef("http://example.org/Person"),
) in graph
assert (
URIRef("http://example.org/Jane"),
URIRef("https://schema.org/name"),
Literal("Jane Smith"),
) in graph
def test_graph_update_generic_sparql_query():
"""Test GraphUpdate with GenericSparqlQuery operation."""
# Create initial RDFGraph
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Person a rdfs:Class ;
rdfs:label "Person" .
ex:John a ex:Person ;
rdfs:label "John Doe" .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with GenericSparqlQuery
# Note: GenericSparqlQuery handles its own prefix declarations
graph_update = GraphUpdate(
sparql_operations=[
GenericSparqlQuery(
query="PREFIX ex: <http://example.org/>\nPREFIX schema: <https://schema.org/>\nPREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>\nINSERT { ex:John schema:age 30 } WHERE { ex:John rdf:type ex:Person }"
),
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate one query
assert len(queries) == 1
# Verify the query includes the custom SPARQL with prefixes
assert "INSERT { ex:John schema:age 30 }" in queries[0]
assert "WHERE { ex:John rdf:type ex:Person }" in queries[0]
# Execute the query on the graph
graph.update(queries[0])
# Verify the custom query was executed
assert len(graph) == initial_triple_count + 1
assert (
URIRef("http://example.org/John"),
URIRef("https://schema.org/age"),
Literal(30),
) in graph
def test_graph_update_empty_operations():
"""Test GraphUpdate with empty operations list."""
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Person a rdf:Class .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with empty operations
graph_update = GraphUpdate(triple_operations=[])
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate no queries
assert len(queries) == 0
# Graph should remain unchanged
assert len(graph) == initial_triple_count
def test_graph_update_operations_with_empty_triples():
"""Test GraphUpdate with operations that have empty triples lists."""
graph = RDFGraph._from_turtle_str(
"""
@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
ex:Person a rdf:Class .
"""
)
initial_triple_count = len(graph)
# Create GraphUpdate with operations that have empty triples
graph_update = GraphUpdate(
triple_operations=[
TripleOp(type="insert", graph=RDFGraph()),
TripleOp(type="delete", graph=RDFGraph()),
]
)
# Generate SPARQL queries
queries = graph_update.generate_sparql_queries()
# Should generate no queries (empty triples are skipped)
assert len(queries) == 0
# Graph should remain unchanged
assert len(graph) == initial_triple_count

View File

@@ -0,0 +1,285 @@
"""Tests for ontology merging functionality."""
import logging
from datetime import datetime, timezone
import pytest
from rdflib import DCTERMS, OWL, PROV, RDF, RDFS, Literal, URIRef
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool.ontology_manager import OntologyManager
logger = logging.getLogger(__name__)
@pytest.fixture
def ontology_manager():
"""Create an ontology manager for testing."""
return OntologyManager()
@pytest.fixture
def base_ontology():
"""Create a base ontology for testing."""
graph = RDFGraph()
iri = URIRef("http://example.org/test")
graph.add((iri, RDF.type, OWL.Ontology))
graph.add((iri, RDFS.label, Literal("Test Ontology")))
# Add some classes
class1 = URIRef("http://example.org/test#Class1")
graph.add((class1, RDF.type, OWL.Class))
graph.add((class1, RDFS.label, Literal("Class 1")))
ontology = Ontology(
graph=graph,
iri=str(iri),
ontology_id="test",
title="Test Ontology",
version="1.0.0",
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
# Ensure hash is computed
if not ontology.hash:
ontology._compute_and_set_hash()
return ontology
@pytest.fixture
def branch1_ontology(base_ontology):
"""Create a branch 1 ontology (child of base)."""
# Create a copy of the base graph
graph = base_ontology.graph.copy()
# Add new class
class2 = URIRef("http://example.org/test#Class2")
graph.add((class2, RDF.type, OWL.Class))
graph.add((class2, RDFS.label, Literal("Class 2")))
ontology = Ontology(
graph=graph,
iri=base_ontology.iri,
ontology_id=base_ontology.ontology_id,
title=base_ontology.title,
version="1.1.0",
parent_hashes=[base_ontology.hash] if base_ontology.hash else [],
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
return ontology
@pytest.fixture
def branch2_ontology(base_ontology):
"""Create a branch 2 ontology (child of base)."""
# Create a copy of the base graph
graph = base_ontology.graph.copy()
# Add different new class
class3 = URIRef("http://example.org/test#Class3")
graph.add((class3, RDF.type, OWL.Class))
graph.add((class3, RDFS.label, Literal("Class 3")))
ontology = Ontology(
graph=graph,
iri=base_ontology.iri,
ontology_id=base_ontology.ontology_id,
title=base_ontology.title,
version="1.2.0",
parent_hashes=[base_ontology.hash] if base_ontology.hash else [],
created_at=datetime(2024, 1, 3, tzinfo=timezone.utc),
)
return ontology
def test_merge_ontologies_basic(ontology_manager, branch1_ontology, branch2_ontology):
"""Test basic ontology merging."""
from ontocast.onto.ontology_operations import merge_ontologies
# Ensure hashes are computed
if not branch1_ontology.hash:
branch1_ontology._compute_and_set_hash()
if not branch2_ontology.hash:
branch2_ontology._compute_and_set_hash()
# Merge
merged = merge_ontologies(branch1_ontology, branch2_ontology)
# Check that merged ontology has both parents
assert merged.parent_hashes == [branch1_ontology.hash, branch2_ontology.hash]
assert merged.iri == branch1_ontology.iri
assert merged.created_at is not None
assert merged.hash is not None
# Check that merged graph contains content triples from both (excluding metadata)
# Metadata (version, title, description, created_at, hash, parent_hash) is not compared
# as it may differ in the merged ontology
def get_content_triples(graph, onto_iri):
"""Get content triples (excluding metadata) from a graph."""
content_triples = set()
onto_iri_ref = URIRef(onto_iri)
for s, p, o in graph:
# Skip metadata triples for the ontology IRI
if s == onto_iri_ref:
if (
p == DCTERMS.identifier
and isinstance(o, Literal)
and str(o).startswith("hash:")
):
continue
if p == PROV.wasDerivedFrom:
continue
if p == DCTERMS.created:
continue
if p == OWL.versionInfo:
continue
if p == RDFS.label:
continue
if p == DCTERMS.title:
continue
if p == DCTERMS.description:
continue
if p == RDFS.comment:
continue
content_triples.add((s, p, o))
return content_triples
branch1_content = get_content_triples(branch1_ontology.graph, branch1_ontology.iri)
branch2_content = get_content_triples(branch2_ontology.graph, branch2_ontology.iri)
merged_content = get_content_triples(merged.graph, merged.iri)
# All content triples from both branches should be in merged
assert branch1_content.issubset(merged_content), (
f"Missing triples from branch1: {branch1_content - merged_content}"
)
assert branch2_content.issubset(merged_content), (
f"Missing triples from branch2: {branch2_content - merged_content}"
)
def test_merge_ontologies_with_contradictions(ontology_manager):
"""Test merging ontologies with contradictions."""
from ontocast.onto.ontology_operations import merge_ontologies
# Create two ontologies with conflicting property values
graph1 = RDFGraph()
iri = URIRef("http://example.org/test")
graph1.add((iri, RDF.type, OWL.Ontology))
class1 = URIRef("http://example.org/test#Class1")
graph1.add((class1, RDF.type, OWL.Class))
graph1.add((class1, RDFS.label, Literal("Class One"))) # Different label
graph2 = RDFGraph()
graph2.add((iri, RDF.type, OWL.Ontology))
graph2.add((class1, RDF.type, OWL.Class))
graph2.add((class1, RDFS.label, Literal("Class 1"))) # Different label
onto1 = Ontology(
graph=graph1,
iri=str(iri),
ontology_id="test",
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
onto2 = Ontology(
graph=graph2,
iri=str(iri),
ontology_id="test",
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
# Merge should succeed (both values kept in RDF)
merged = merge_ontologies(onto1, onto2)
# Both label values should be in merged graph
labels = [o for _, _, o in merged.graph.triples((class1, RDFS.label, None))]
assert len(labels) == 2
label_strings = {str(label) for label in labels}
assert "Class One" in label_strings or '"Class One"' in label_strings
assert "Class 1" in label_strings or '"Class 1"' in label_strings
def test_merge_terminal_ontologies_pairwise(
ontology_manager, base_ontology, branch1_ontology, branch2_ontology
):
"""Test merging terminal ontologies pair-wise."""
from ontocast.onto.ontology_operations import merge_ontologies
# Add all ontologies to manager
ontology_manager.add_ontology(base_ontology)
ontology_manager.add_ontology(branch1_ontology)
ontology_manager.add_ontology(branch2_ontology)
# Get terminal ontologies (should be branch1 and branch2)
terminals = ontology_manager.get_terminal_ontologies_by_iri(base_ontology.iri)
assert len(terminals) == 2
# Sort by created_at
terminals.sort(
key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc)
)
# Merge the two terminals
merged = merge_ontologies(terminals[0], terminals[1])
# Add merged to manager
ontology_manager.add_ontology(merged)
# Check that we now have one terminal
new_terminals = ontology_manager.get_terminal_ontologies_by_iri(base_ontology.iri)
assert len(new_terminals) == 1
assert new_terminals[0].hash == merged.hash
def test_merge_ontologies_preserves_namespaces(ontology_manager):
"""Test that merging preserves namespace bindings."""
from ontocast.onto.ontology_operations import merge_ontologies
graph1 = RDFGraph()
graph1.bind("ex", "http://example.org/")
iri = URIRef("http://example.org/test")
graph1.add((iri, RDF.type, OWL.Ontology))
graph2 = RDFGraph()
graph2.bind("test", "http://test.org/")
graph2.add((iri, RDF.type, OWL.Ontology))
onto1 = Ontology(graph=graph1, iri=str(iri), created_at=datetime.now(timezone.utc))
onto2 = Ontology(graph=graph2, iri=str(iri), created_at=datetime.now(timezone.utc))
merged = merge_ontologies(onto1, onto2)
# Check that both namespaces are present
namespaces = dict(merged.graph.namespaces())
assert "ex" in namespaces
assert "test" in namespaces
def test_merge_ontologies_created_at_set(ontology_manager):
"""Test that merged ontology has created_at set to merge time."""
from ontocast.onto.ontology_operations import merge_ontologies
graph1 = RDFGraph()
iri = URIRef("http://example.org/test")
graph1.add((iri, RDF.type, OWL.Ontology))
graph2 = RDFGraph()
graph2.add((iri, RDF.type, OWL.Ontology))
onto1 = Ontology(
graph=graph1,
iri=str(iri),
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
onto2 = Ontology(
graph=graph2,
iri=str(iri),
created_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
before_merge = datetime.now(timezone.utc)
merged = merge_ontologies(onto1, onto2)
after_merge = datetime.now(timezone.utc)
# Created_at should be set to merge time (between before and after)
assert merged.created_at is not None
assert before_merge <= merged.created_at <= after_merge

View File

@@ -0,0 +1,108 @@
from datetime import datetime
from typing import cast
from rdflib import DCTERMS, OWL, RDF, XSD, Literal, URIRef
from ontocast.agent.normalize_ontology import normalize_ontology_units
from ontocast.onto.constants import PROV
from ontocast.onto.content_unit import ContentUnit, OutputType
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.toolbox import ToolBox
def _make_base_ontology() -> Ontology:
base_iri = URIRef("https://example.org/onto")
graph = RDFGraph()
graph.add((base_iri, RDF.type, OWL.Ontology))
graph.add((URIRef(f"{base_iri}#Person"), RDF.type, OWL.Class))
return Ontology(graph=graph, iri=str(base_iri))
def test_derive_updated_version_refreshes_lineage_metadata() -> None:
base = _make_base_ontology()
assert base.hash is not None
base_hash = base.hash
onto_iri = URIRef(base.iri)
updated_graph = base.graph.copy()
updated_graph.add((URIRef(f"{base.iri}#Organization"), RDF.type, OWL.Class))
updated_graph.add((onto_iri, PROV.wasDerivedFrom, URIRef("urn:hash:stale-parent")))
updated_graph.add((onto_iri, DCTERMS.identifier, Literal("hash:stale-hash")))
updated_graph.add(
(
onto_iri,
DCTERMS.created,
Literal("2001-01-01T00:00:00+00:00", datatype=XSD.dateTime),
)
)
updated = base.derive_updated_version(updated_graph)
assert updated.hash is not None
assert updated.hash != base_hash
assert updated.parent_hashes == [base_hash]
assert updated.created_at is not None
hash_identifiers = {
str(obj)
for _, _, obj in updated.graph.triples((onto_iri, DCTERMS.identifier, None))
if str(obj).startswith("hash:")
}
parent_uris = {
str(obj)
for _, _, obj in updated.graph.triples((onto_iri, PROV.wasDerivedFrom, None))
}
created_values = [
str(obj)
for _, _, obj in updated.graph.triples((onto_iri, DCTERMS.created, None))
]
assert hash_identifiers == {f"hash:{updated.hash}"}
assert "hash:stale-hash" not in hash_identifiers
assert parent_uris == {f"urn:hash:{base_hash}"}
assert "urn:hash:stale-parent" not in parent_uris
assert len(created_values) == 1
assert datetime.fromisoformat(created_values[0]) == updated.created_at
class _DummyTools:
pass
def test_normalize_ontology_units_refreshes_lineage_for_updated_base() -> None:
base = _make_base_ontology()
assert base.hash is not None
base_hash = base.hash
doc_iri = URIRef("https://example.org/doc/alpha")
delta_graph = RDFGraph()
delta_graph.add((URIRef(f"{base.iri}#Case"), RDF.type, OWL.Class))
unit = ContentUnit(
text="delta",
index=0,
doc_iri=doc_iri,
graph=delta_graph,
type=OutputType.ONTOLOGIES,
)
normalized, applied, provenance = normalize_ontology_units(
units=[unit],
tools=cast(ToolBox, _DummyTools()),
base_ontology=base,
require_base=True,
)
onto_iri = URIRef(base.iri)
assert len(applied) == 1
assert normalized.hash is not None
assert normalized.hash != base_hash
assert normalized.parent_hashes == [base_hash]
assert normalized.created_at is not None
assert len(provenance) == 0
assert (URIRef(f"{base.iri}#Case"), RDF.type, OWL.Class) in normalized.graph
assert (
onto_iri,
PROV.wasDerivedFrom,
URIRef(f"urn:hash:{base_hash}"),
) in normalized.graph

View File

@@ -0,0 +1,659 @@
"""Test suite for OntologyManager.
This test suite ensures that:
1. Every ontology in the manager has a created_at field set (not None)
2. Version tracking works correctly
3. Terminal ontology detection works
4. Freshest ontology selection works
5. Lineage graphs are built correctly
"""
from datetime import datetime, timezone
import pytest
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.tool import OntologyManager
@pytest.fixture
def ontology_manager():
"""Create a fresh OntologyManager for each test."""
return OntologyManager()
@pytest.fixture
def sample_ontology():
"""Create a sample ontology with minimal required fields."""
graph = RDFGraph()
graph.parse(
data="""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
<https://example.org/test> a owl:Ontology ;
rdfs:label "Test Ontology" .
""",
format="turtle",
)
ontology = Ontology(
graph=graph,
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology",
version="1.0.0",
)
# Compute hash if not set
if not ontology.hash:
ontology._compute_and_set_hash()
return ontology
@pytest.fixture
def ontology_with_parent(sample_ontology):
"""Create an ontology that has sample_ontology as parent."""
graph = RDFGraph()
graph.parse(
data="""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
<https://example.org/test> a owl:Ontology ;
rdfs:label "Test Ontology v2" .
<https://example.org/test#NewClass> a owl:Class ;
rdfs:label "New Class" .
""",
format="turtle",
)
ontology = Ontology(
graph=graph,
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology v2",
version="2.0.0",
parent_hashes=[sample_ontology.hash] if sample_ontology.hash else [],
)
if not ontology.hash:
ontology._compute_and_set_hash()
return ontology
class TestOntologyManagerCreatedAt:
"""Test that created_at is always set when adding ontologies."""
def test_add_ontology_sets_created_at_if_missing(
self, ontology_manager, sample_ontology
):
"""Test that add_ontology sets created_at if it's None."""
assert sample_ontology.created_at is None
ontology_manager.add_ontology(sample_ontology)
# Check that created_at was set
assert sample_ontology.created_at is not None
assert isinstance(sample_ontology.created_at, datetime)
# Check that it's in the manager
versions = ontology_manager.get_ontology_versions("test")
assert len(versions) == 1
assert versions[0].created_at is not None
def test_add_ontology_preserves_existing_created_at(
self, ontology_manager, sample_ontology
):
"""Test that add_ontology preserves existing created_at."""
original_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
sample_ontology.created_at = original_time
ontology_manager.add_ontology(sample_ontology)
# Check that created_at was preserved
assert sample_ontology.created_at == original_time
versions = ontology_manager.get_ontology_versions("test")
assert versions[0].created_at == original_time
def test_all_ontologies_have_created_at(self, ontology_manager, sample_ontology):
"""Test that all ontologies in manager have created_at set."""
ontology_manager.add_ontology(sample_ontology)
# Check all ontologies property
ontologies = ontology_manager.ontologies
assert len(ontologies) > 0
for ontology in ontologies:
assert ontology.created_at is not None, (
f"Ontology {ontology.ontology_id} (hash: {ontology.hash}) "
"should have created_at set"
)
def test_get_ontology_versions_all_have_created_at(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that all versions returned have created_at set."""
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
versions = ontology_manager.get_ontology_versions("test")
assert len(versions) == 2
for version in versions:
assert version.created_at is not None, (
f"Version with hash {version.hash} should have created_at set"
)
class TestOntologyManagerVersionTracking:
"""Test version tracking functionality."""
def test_add_ontology_creates_version_tree(self, ontology_manager, sample_ontology):
"""Test that adding an ontology creates a version tree."""
ontology_manager.add_ontology(sample_ontology)
assert sample_ontology.iri in ontology_manager.ontology_versions
assert len(ontology_manager.ontology_versions[sample_ontology.iri]) == 1
def test_add_duplicate_hash_not_added(self, ontology_manager, sample_ontology):
"""Test that adding the same ontology twice doesn't create duplicates."""
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(sample_ontology)
versions = ontology_manager.get_ontology_versions("test")
assert len(versions) == 1
def test_add_ontology_without_hash_rejected(self, ontology_manager):
"""Test that adding ontology without hash is rejected."""
ontology = Ontology(
ontology_id="test",
iri="https://example.org/test",
)
assert ontology.hash is None
ontology_manager.add_ontology(ontology)
# Should not be added
assert ontology.iri not in ontology_manager.ontology_versions
def test_add_ontology_without_iri_rejected(self, ontology_manager, sample_ontology):
"""Test that adding ontology without valid IRI is rejected."""
sample_ontology.iri = None
ontology_manager.add_ontology(sample_ontology)
# Should not be added
assert len(ontology_manager.ontology_versions) == 0
class TestTerminalOntologies:
"""Test terminal ontology detection."""
def test_single_ontology_is_terminal(self, ontology_manager, sample_ontology):
"""Test that a single ontology is terminal."""
ontology_manager.add_ontology(sample_ontology)
terminals = ontology_manager.get_terminal_ontologies("test")
assert len(terminals) == 1
assert terminals[0].hash == sample_ontology.hash
def test_parent_is_not_terminal_when_child_exists(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that parent is not terminal when child exists."""
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
terminals = ontology_manager.get_terminal_ontologies("test")
assert len(terminals) == 1
assert terminals[0].hash == ontology_with_parent.hash
assert sample_ontology.hash not in [t.hash for t in terminals]
def test_multiple_terminals_for_different_ontology_ids(
self, ontology_manager, sample_ontology
):
"""Test that we can have terminals for different ontology_ids."""
# Create second ontology with different ID
graph2 = RDFGraph()
graph2.parse(
data="""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
<https://example.org/test2> a owl:Ontology ;
rdfs:label "Test Ontology 2" .
""",
format="turtle",
)
ontology2 = Ontology(
graph=graph2,
ontology_id="test2",
iri="https://example.org/test2",
)
if not ontology2.hash:
ontology2._compute_and_set_hash()
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology2)
terminals = ontology_manager.get_terminal_ontologies()
assert len(terminals) == 2
assert {t.ontology_id for t in terminals} == {"test", "test2"}
class TestFreshestTerminalOntology:
"""Test freshest terminal ontology selection."""
def test_freshest_single_ontology(self, ontology_manager, sample_ontology):
"""Test that freshest returns the only ontology when there's one."""
ontology_manager.add_ontology(sample_ontology)
freshest = ontology_manager.get_freshest_terminal_ontology("test")
assert freshest is not None
assert freshest.hash == sample_ontology.hash
def test_freshest_selects_most_recent(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that freshest selects the most recently created ontology."""
# Set explicit timestamps
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ontology_with_parent.created_at = datetime(
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
)
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
freshest = ontology_manager.get_freshest_terminal_ontology("test")
assert freshest is not None
assert freshest.hash == ontology_with_parent.hash
def test_freshest_handles_no_timestamps(self, ontology_manager, sample_ontology):
"""Test that freshest falls back when no timestamps."""
# This shouldn't happen in practice since add_ontology sets created_at,
# but test the fallback logic
sample_ontology.created_at = None
# Manually add to bypass add_ontology's created_at setting
if sample_ontology.iri not in ontology_manager.ontology_versions:
ontology_manager.ontology_versions[sample_ontology.iri] = []
ontology_manager.ontology_versions[sample_ontology.iri].append(sample_ontology)
freshest = ontology_manager.get_freshest_terminal_ontology("test")
# Should still return something (fallback to first)
assert freshest is not None
def test_freshest_returns_none_when_no_ontologies(self, ontology_manager):
"""Test that freshest returns None when no ontologies exist."""
freshest = ontology_manager.get_freshest_terminal_ontology("nonexistent")
assert freshest is None
class TestOntologiesProperty:
"""Test the ontologies property."""
def test_ontologies_returns_freshest_per_ontology_id(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that ontologies property returns one per ontology_id."""
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ontology_with_parent.created_at = datetime(
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
)
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
ontologies = ontology_manager.ontologies
assert len(ontologies) == 1 # One per ontology_id
assert ontologies[0].hash == ontology_with_parent.hash
def test_ontologies_all_have_created_at(self, ontology_manager, sample_ontology):
"""Test that all ontologies returned have created_at."""
ontology_manager.add_ontology(sample_ontology)
ontologies = ontology_manager.ontologies
for ontology in ontologies:
assert ontology.created_at is not None
def test_ontologies_cache_is_updated_incrementally(
self, ontology_manager, sample_ontology
):
"""Test that cache is updated incrementally when adding ontologies."""
# Initially empty
assert not ontology_manager.has_ontologies
assert len(ontology_manager.ontologies) == 0
# Add first ontology
ontology_manager.add_ontology(sample_ontology)
assert ontology_manager.has_ontologies
assert len(ontology_manager.ontologies) == 1
assert sample_ontology.iri in ontology_manager._cached_ontologies
assert (
ontology_manager._cached_ontologies[sample_ontology.iri]
== sample_ontology.hash
)
# Add second ontology with different ID
graph2 = RDFGraph()
graph2.parse(
data="""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
<https://example.org/test2> a owl:Ontology .
""",
format="turtle",
)
ontology2 = Ontology(
graph=graph2,
ontology_id="test2",
iri="https://example.org/test2",
)
if not ontology2.hash:
ontology2._compute_and_set_hash()
ontology_manager.add_ontology(ontology2)
assert len(ontology_manager.ontologies) == 2
assert sample_ontology.iri in ontology_manager._cached_ontologies
assert ontology2.iri in ontology_manager._cached_ontologies
assert ontology_manager._cached_ontologies[ontology2.iri] == ontology2.hash
def test_ontologies_cache_updates_when_new_version_added(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that cache is updated when a new version is added for existing ontology_id."""
# Add initial ontology
sample_ontology.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ontology_manager.add_ontology(sample_ontology)
# Check cache has initial hash
assert (
ontology_manager._cached_ontologies[sample_ontology.iri]
== sample_ontology.hash
)
# Add newer version (same IRI)
ontology_with_parent.created_at = datetime(
2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc
)
ontology_manager.add_ontology(ontology_with_parent)
# Cache should be updated to newer hash
assert (
ontology_manager._cached_ontologies[sample_ontology.iri]
== ontology_with_parent.hash
)
assert (
ontology_manager._cached_ontologies[sample_ontology.iri]
!= sample_ontology.hash
)
class TestHasOntologies:
"""Test the has_ontologies property."""
def test_has_ontologies_false_when_empty(self, ontology_manager):
"""Test that has_ontologies returns False when no ontologies."""
assert not ontology_manager.has_ontologies
def test_has_ontologies_true_when_ontologies_exist(
self, ontology_manager, sample_ontology
):
"""Test that has_ontologies returns True when ontologies exist."""
ontology_manager.add_ontology(sample_ontology)
assert ontology_manager.has_ontologies
def test_has_ontologies_works_with_cache(self, ontology_manager, sample_ontology):
"""Test that has_ontologies works correctly with caching."""
# Initially false
assert not ontology_manager.has_ontologies
# Add ontology
ontology_manager.add_ontology(sample_ontology)
assert ontology_manager.has_ontologies
# Should still be true after accessing ontologies property
_ = ontology_manager.ontologies
assert ontology_manager.has_ontologies
class TestLineageGraph:
"""Test lineage graph building."""
def test_get_lineage_graph_creates_graph(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that lineage graph is created correctly."""
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
lineage = ontology_manager.get_lineage_graph("test")
assert lineage is not None
import networkx as nx
assert isinstance(lineage, nx.DiGraph)
# Check nodes exist
assert sample_ontology.hash in lineage.nodes()
assert ontology_with_parent.hash in lineage.nodes()
# Check edge from child to parent
assert lineage.has_edge(ontology_with_parent.hash, sample_ontology.hash)
def test_get_lineage_graph_returns_none_for_missing_id(self, ontology_manager):
"""Test that lineage graph returns None for missing ontology_id."""
lineage = ontology_manager.get_lineage_graph("nonexistent")
assert lineage is None
class TestGetOntology:
"""Test get_ontology method."""
def test_get_ontology_by_hash(self, ontology_manager, sample_ontology):
"""Test getting ontology by hash."""
ontology_manager.add_ontology(sample_ontology)
retrieved = ontology_manager.get_ontology(hash=sample_ontology.hash)
assert retrieved.hash == sample_ontology.hash
assert retrieved.created_at is not None
def test_get_ontology_by_ontology_id_returns_terminal(
self, ontology_manager, sample_ontology, ontology_with_parent
):
"""Test that getting by ontology_id returns terminal."""
ontology_manager.add_ontology(sample_ontology)
ontology_manager.add_ontology(ontology_with_parent)
retrieved = ontology_manager.get_ontology(ontology_id="test")
assert retrieved.hash == ontology_with_parent.hash
assert retrieved.created_at is not None
def test_get_ontology_by_iri(self, ontology_manager, sample_ontology):
"""Test getting ontology by IRI."""
ontology_manager.add_ontology(sample_ontology)
retrieved = ontology_manager.get_ontology(ontology_iri=sample_ontology.iri)
assert retrieved.iri == sample_ontology.iri
assert retrieved.created_at is not None
class TestGetOntologyNames:
"""Test get_ontology_names method."""
def test_get_ontology_names_returns_all_ids(
self, ontology_manager, sample_ontology
):
"""Test that get_ontology_names returns all ontology IDs."""
ontology_manager.add_ontology(sample_ontology)
# Create second ontology
graph2 = RDFGraph()
graph2.parse(
data="""
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
<https://example.org/test2> a owl:Ontology .
""",
format="turtle",
)
ontology2 = Ontology(
graph=graph2,
ontology_id="test2",
iri="https://example.org/test2",
)
if not ontology2.hash:
ontology2._compute_and_set_hash()
ontology_manager.add_ontology(ontology2)
names = ontology_manager.get_ontology_names()
assert "test" in names
assert "test2" in names
assert len(names) == 2
class TestContains:
"""Test __contains__ method."""
def test_contains_by_ontology_id(self, ontology_manager, sample_ontology):
"""Test checking containment by ontology_id."""
ontology_manager.add_ontology(sample_ontology)
assert "test" in ontology_manager
assert "nonexistent" not in ontology_manager
def test_contains_by_iri(self, ontology_manager, sample_ontology):
"""Test checking containment by IRI."""
ontology_manager.add_ontology(sample_ontology)
assert sample_ontology.iri in ontology_manager
assert "https://example.org/nonexistent" not in ontology_manager
class TestRecreateFromRDFGraph:
"""Test recreating Ontology from RDF graph with parent_hashes and created_at."""
def test_recreate_ontology_with_parent_hashes_and_created_at(self):
"""Test that parent_hashes and created_at are correctly read from RDF graph."""
# Create an ontology with parent_hashes and created_at
original_ontology = Ontology(
graph=RDFGraph(),
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology",
version="1.0.0",
)
if not original_ontology.hash:
original_ontology._compute_and_set_hash()
# Set parent_hashes and created_at
parent_hash = "parent1234567890abcdef"
original_ontology.parent_hashes = [parent_hash]
original_ontology.created_at = datetime(
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
)
# Sync to graph to add triples
original_ontology.sync_properties_to_graph()
# Now recreate ontology from the graph
# This simulates loading from a triple store or file
recreated_ontology = Ontology(graph=original_ontology.graph)
# Verify parent_hashes was read correctly
assert len(recreated_ontology.parent_hashes) == 1
assert parent_hash in recreated_ontology.parent_hashes
# Verify created_at was read correctly
assert recreated_ontology.created_at is not None
assert recreated_ontology.created_at == datetime(
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
)
def test_recreate_ontology_with_multiple_parent_hashes(self):
"""Test that multiple parent_hashes are correctly read from RDF graph."""
# Create an ontology with multiple parent_hashes
original_ontology = Ontology(
graph=RDFGraph(),
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology",
version="1.0.0",
)
if not original_ontology.hash:
original_ontology._compute_and_set_hash()
# Set multiple parent_hashes (simulating a merge)
parent_hashes = ["parent1", "parent2", "parent3"]
original_ontology.parent_hashes = parent_hashes
original_ontology.created_at = datetime(
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
)
# Sync to graph
original_ontology.sync_properties_to_graph()
# Recreate from graph
recreated_ontology = Ontology(graph=original_ontology.graph)
# Verify all parent_hashes were read
assert len(recreated_ontology.parent_hashes) == 3
assert set(recreated_ontology.parent_hashes) == set(parent_hashes)
def test_recreate_ontology_with_empty_parent_hashes(self):
"""Test that empty parent_hashes (root ontology) is correctly handled."""
# Create a root ontology (no parents)
original_ontology = Ontology(
graph=RDFGraph(),
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology",
version="1.0.0",
)
if not original_ontology.hash:
original_ontology._compute_and_set_hash()
# Ensure parent_hashes is empty (root ontology)
original_ontology.parent_hashes = []
original_ontology.created_at = datetime(
2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc
)
# Sync to graph
original_ontology.sync_properties_to_graph()
# Recreate from graph
recreated_ontology = Ontology(graph=original_ontology.graph)
# Verify parent_hashes is empty
assert recreated_ontology.parent_hashes == []
assert len(recreated_ontology.parent_hashes) == 0
def test_recreate_ontology_preserves_existing_created_at(self):
"""Test that existing created_at is preserved when recreating from graph."""
# Create ontology with created_at
original_ontology = Ontology(
graph=RDFGraph(),
ontology_id="test",
iri="https://example.org/test",
title="Test Ontology",
version="1.0.0",
)
if not original_ontology.hash:
original_ontology._compute_and_set_hash()
original_time = datetime(2023, 12, 25, 15, 45, 0, tzinfo=timezone.utc)
original_ontology.created_at = original_time
# Sync to graph
original_ontology.sync_properties_to_graph()
# Recreate from graph
recreated_ontology = Ontology(graph=original_ontology.graph)
# Verify created_at was preserved
assert recreated_ontology.created_at is not None
assert recreated_ontology.created_at == original_time

View File

@@ -0,0 +1,711 @@
import importlib
from types import SimpleNamespace
from typing import cast
import pytest
from rdflib import OWL, RDF, BNode, Literal, URIRef
from ontocast.agent.normalize_ontology import normalize_ontology_units
from ontocast.onto.constants import ONTOLOGY_NULL_IRI, PROV, RDF_REIFIES, SCHEMA
from ontocast.onto.content_unit import ContentUnit, OutputType
from ontocast.onto.enum import RenderMode, Status, WorkflowNode
from ontocast.onto.model import (
ExternalEvidenceCacheEntry,
ExternalEvidencePlan,
ExternalEvidenceRequest,
GraphUpdateRenderReport,
OntologyCritiqueReport,
)
from ontocast.onto.ontology import Ontology
from ontocast.onto.rdfgraph import RDFGraph
from ontocast.onto.sparql_models import GenericSparqlQuery, GraphUpdate
from ontocast.onto.state import AgentState
from ontocast.onto.unit_states import UnitFactsState, UnitOntologyState
from ontocast.stategraph.node_factories import make_normalize_ontology_node
from ontocast.stategraph.routing import route_after_ontology_consolidation
from ontocast.tool.aggregate import EmbeddingBasedAggregator
from ontocast.tool.atomic import AtomicToolBox, SearchHit
from ontocast.toolbox import ToolBox
render_ontology_module = importlib.import_module("ontocast.agent.render_ontology")
criticise_ontology_module = importlib.import_module("ontocast.agent.criticise_ontology")
select_ontology_module = importlib.import_module("ontocast.agent.select_ontology")
unit_loops = importlib.import_module("ontocast.stategraph.atomic")
external_evidence_module = importlib.import_module("ontocast.agent.external_evidence")
def _build_content_unit() -> ContentUnit:
return ContentUnit(
text="Alice works for ACME.",
index=0,
doc_iri=URIRef("https://example.com/doc/d1"),
)
def _build_ontology() -> Ontology:
graph = RDFGraph()
graph.parse(
data="""
@prefix onto: <https://example.com/onto#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
onto:CompanyOntology a owl:Ontology .
""",
format="turtle",
)
return Ontology(graph=graph, iri="https://example.com/onto")
def test_unit_facts_loop_isolates_input_state() -> None:
"""Unit loop uses model_copy(deep=True), so input state is not mutated."""
state = UnitFactsState(
content_unit=_build_content_unit(), ontology_snapshot=_build_ontology()
)
original_text = state.content_unit.text
# Simulate what the loop does: it copies before processing
copied = state.model_copy(deep=True)
copied.content_unit.text = "MUTATED"
assert state.content_unit.text == original_text
@pytest.mark.anyio
async def test_run_unit_facts_loop_uses_dedicated_state(monkeypatch) -> None:
async def fake_render(state: UnitFactsState, tools) -> UnitFactsState:
state.status = Status.SUCCESS
return state
async def fake_critic(state: UnitFactsState, tools) -> UnitFactsState:
state.status = Status.SUCCESS
return state
monkeypatch.setattr(unit_loops, "render_facts", fake_render)
monkeypatch.setattr(unit_loops, "criticise_facts", fake_critic)
state = UnitFactsState(
content_unit=_build_content_unit(), ontology_snapshot=_build_ontology()
)
tools = cast(AtomicToolBox, object())
result = await unit_loops.facts_loop(state, tools=tools)
assert result.status == Status.SUCCESS
assert result.content_unit.hid == state.content_unit.hid
@pytest.mark.anyio
async def test_run_unit_ontology_loop_emits_updates(monkeypatch) -> None:
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
state.status = Status.SUCCESS
state.ontology_updates = [GraphUpdate()]
state.current_ontology = Ontology(
graph=RDFGraph(), iri="https://example.com/onto"
)
return state
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
state.status = Status.SUCCESS
return state
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
)
tools = cast(AtomicToolBox, object())
result = await unit_loops.ontology_loop(state, tools=tools)
assert result.status == Status.SUCCESS
assert len(result.all_updates) == 1
def test_reduce_ontology_units_returns_ontology_when_no_units() -> None:
tools = ToolBox.__new__(ToolBox)
tools.aggregator = EmbeddingBasedAggregator()
reduced, applied, provenance = normalize_ontology_units(units=[], tools=tools)
assert reduced is not None
assert reduced.iri is not None
assert applied == []
assert len(provenance) == 0
def test_reduce_ontology_units_merges_unit_graphs_without_aggregator() -> None:
tools = ToolBox.__new__(ToolBox)
tools.aggregator = EmbeddingBasedAggregator()
unit1 = ContentUnit(
text="Alice works at ACME",
index=0,
doc_iri=URIRef("https://example.com/doc/d1"),
graph=_build_ontology().graph,
type=OutputType.ONTOLOGIES,
)
reduced, applied, provenance = normalize_ontology_units(units=[unit1], tools=tools)
assert reduced is not None
assert len(reduced.graph) > 0
assert len(applied) == 1
assert len(applied[0].triple_operations) == 1
assert len(provenance) == 0
assert isinstance(applied, list)
def test_reduce_ontology_units_creates_base_when_required() -> None:
tools = cast(ToolBox, ToolBox.__new__(ToolBox))
tools.aggregator = EmbeddingBasedAggregator()
delta_graph = RDFGraph()
delta_graph.parse(
data="""
@prefix ex: <https://example.com/onto#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Company rdf:type rdfs:Class .
""",
format="turtle",
)
unit = ContentUnit(
text="Company ontology snippet",
index=0,
doc_iri=URIRef("https://example.com/doc/d1"),
graph=delta_graph,
type=OutputType.ONTOLOGIES,
)
reduced, applied, provenance = normalize_ontology_units(
units=[unit],
tools=tools,
base_ontology=None,
require_base=True,
)
assert not reduced.is_null()
assert len(reduced.graph) > 0
assert len(provenance) == 0
assert isinstance(applied, list)
def test_reduce_ontology_units_strips_provenance_and_stores_artifact() -> None:
tools = ToolBox.__new__(ToolBox)
tools.aggregator = EmbeddingBasedAggregator()
doc_iri = URIRef("https://growgraph.dev/doc/test")
court = URIRef("https://growgraph.dev/fcaont#Court")
appeal_court = URIRef("https://growgraph.dev/fcaont#AppealCourt")
reifier = BNode()
source_chunk = URIRef(f"{doc_iri}/chunk-1")
graph = RDFGraph(store="oxigraph")
graph.add((appeal_court, RDF.type, court))
graph.add((appeal_court, OWL.sameAs, court))
graph.add((source_chunk, RDF.type, PROV.Entity))
graph.add((source_chunk, SCHEMA.identifier, Literal("chunk-1")))
graph.add((reifier, RDF_REIFIES, Literal("quoted-triple")))
graph.add((reifier, PROV.wasDerivedFrom, source_chunk))
unit = ContentUnit(
text="Appeal court ontology unit",
index=0,
doc_iri=doc_iri,
graph=graph,
type=OutputType.ONTOLOGIES,
)
reduced, _, provenance = normalize_ontology_units(units=[unit], tools=tools)
assert (appeal_court, RDF.type, court) in reduced.graph
assert (appeal_court, OWL.sameAs, court) not in reduced.graph
assert (source_chunk, SCHEMA.identifier, Literal("chunk-1")) not in reduced.graph
assert (appeal_court, OWL.sameAs, court) in provenance
assert list(provenance.triples((None, RDF_REIFIES, None)))
assert list(provenance.triples((None, PROV.wasDerivedFrom, source_chunk)))
def test_normalize_ontology_node_feeds_clean_graph_to_consolidation() -> None:
class DummyTools:
aggregator = EmbeddingBasedAggregator()
normalize_node = make_normalize_ontology_node(cast(ToolBox, DummyTools()))
doc_iri = URIRef("https://growgraph.dev/doc/test-node")
class_uri = URIRef("https://growgraph.dev/fcaont#Judgement")
source_chunk = URIRef(f"{doc_iri}/chunk-1")
graph = RDFGraph()
graph.add(
(class_uri, RDF.type, URIRef("http://www.w3.org/2000/01/rdf-schema#Class"))
)
graph.add((source_chunk, RDF.type, PROV.Entity))
graph.add((source_chunk, SCHEMA.identifier, Literal("chunk-1")))
graph.add((class_uri, OWL.sameAs, URIRef("https://growgraph.dev/fcaont#Judgment")))
state = AgentState(render_mode=RenderMode.ONTOLOGY)
state.current_ontology = _build_ontology()
state.ontology_units = [
ContentUnit(
text="Ontology delta",
index=0,
doc_iri=doc_iri,
graph=graph,
type=OutputType.ONTOLOGIES,
)
]
updated = normalize_node(state)
ontology_ttl = updated.current_ontology.graph.serialize(format="turtle")
assert "rdf:reifies" not in ontology_ttl
assert f"{doc_iri}/chunk-1" not in ontology_ttl
assert "owl:sameAs" not in ontology_ttl
assert len(updated.ontology_provenance_artifact) > 0
@pytest.mark.anyio
async def test_select_ontology_none_keeps_success_status(monkeypatch) -> None:
class SelectorResult:
answer_index = 0
async def fake_call_llm_with_retry(**kwargs):
return SelectorResult()
monkeypatch.setattr(
select_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
)
state = AgentState()
state.content_units = [_build_content_unit()]
tools = SimpleNamespace(
llm=object(),
ontology_manager=SimpleNamespace(
has_ontologies=True, ontologies=[_build_ontology()]
),
)
result = await select_ontology_module.select_ontology(state, tools) # type: ignore[arg-type]
assert result.status == Status.SUCCESS
assert result.current_ontology.is_null()
@pytest.mark.anyio
async def test_render_ontology_uses_update_when_snapshot_exists(monkeypatch) -> None:
calls = {"fresh": 0, "update": 0}
async def fake_fresh(state: UnitOntologyState, tools) -> UnitOntologyState:
calls["fresh"] += 1
return state
async def fake_update(state: UnitOntologyState, tools) -> UnitOntologyState:
calls["update"] += 1
return state
monkeypatch.setattr(render_ontology_module, "render_ontology_fresh", fake_fresh)
monkeypatch.setattr(render_ontology_module, "render_ontology_update", fake_update)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=_build_ontology(),
)
# Simulate accidental null current ontology while a valid snapshot exists.
state.current_ontology = Ontology(iri=ONTOLOGY_NULL_IRI)
result = await render_ontology_module.render_ontology(
state, tools=cast(AtomicToolBox, object())
)
assert result is state
assert calls["update"] == 1
assert calls["fresh"] == 0
@pytest.mark.anyio
async def test_render_ontology_update_adds_external_evidence_when_enabled(
monkeypatch,
) -> None:
captured_prompt_kwargs: dict[str, object] = {}
async def fake_call_llm_with_retry(**kwargs):
captured_prompt_kwargs.update(kwargs["prompt_kwargs"])
return GraphUpdateRenderReport(graph_update=GraphUpdate())
async def fake_get_llm_tool(_budget_tracker):
return object()
monkeypatch.setattr(
render_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
)
tools = cast(
AtomicToolBox,
SimpleNamespace(
get_llm_tool=fake_get_llm_tool,
),
)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=_build_ontology(),
)
state.external_evidence_text = (
"### EXTERNAL EVIDENCE (WEB SEARCH)\n"
"1. Ontology engineering patterns | https://example.org/ontology\n"
" Use consistent subclass hierarchies and explicit domains."
)
await render_ontology_module.render_ontology_update(state, tools=tools)
external_evidence = str(captured_prompt_kwargs.get("external_evidence", ""))
assert "EXTERNAL EVIDENCE" in external_evidence
assert "https://example.org/ontology" in external_evidence
@pytest.mark.anyio
async def test_criticise_ontology_skips_external_evidence_when_disabled(
monkeypatch,
) -> None:
captured_prompt_kwargs: dict[str, object] = {}
async def fake_call_llm_with_retry(**kwargs):
captured_prompt_kwargs.update(kwargs["prompt_kwargs"])
return OntologyCritiqueReport(
success=True,
score=95,
systemic_critique_summary="Looks good.",
actionable_ontology_fixes=[],
)
async def fake_get_llm_tool(_budget_tracker):
return object()
monkeypatch.setattr(
criticise_ontology_module, "call_llm_with_retry", fake_call_llm_with_retry
)
tools = cast(
AtomicToolBox,
SimpleNamespace(
get_llm_tool=fake_get_llm_tool,
),
)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=_build_ontology(),
)
await criticise_ontology_module.criticise_ontology(state, tools=tools)
assert captured_prompt_kwargs.get("external_evidence") == ""
@pytest.mark.anyio
async def test_plan_external_evidence_uses_fallback_when_planner_disabled() -> None:
tools = cast(
AtomicToolBox,
SimpleNamespace(
web_grounding_enabled_for_node=lambda _node: True,
web_search_reuse_evidence_across_attempt=False,
web_search_planner_enabled=False,
web_search_planner_min_query_chars=8,
web_search_planner_max_queries=3,
web_search_planner_min_confidence=0.35,
),
)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=_build_ontology(),
ontology_user_instruction="Clarify company ontology terms.",
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_ONTOLOGY,
ExternalEvidenceRequest(
initiate_search=True,
rationale="Need targeted terminology lookup for ontology refinement.",
),
)
planned = await external_evidence_module.plan_external_evidence_for_node(
state, tools, WorkflowNode.TEXT_TO_ONTOLOGY
)
assert planned.external_evidence_plan.should_search is True
assert planned.external_evidence_plan.queries
assert planned.external_evidence_planned_at_node == WorkflowNode.TEXT_TO_ONTOLOGY
@pytest.mark.anyio
async def test_fetch_external_evidence_filters_domains_and_dedupes() -> None:
async def fake_search(query: str, max_results: int | None = None):
_ = query, max_results
return [
SearchHit(
title="Good result",
url="https://example.org/ontology",
snippet="This is a sufficiently detailed snippet for ontology guidance.",
),
SearchHit(
title="Duplicate URL",
url="https://example.org/ontology",
snippet="Different text but same URL should be deduped.",
),
SearchHit(
title="Other domain",
url="https://noise.test/entry",
snippet="This snippet is long enough but should be filtered by allowlist.",
),
]
tools = cast(
AtomicToolBox,
SimpleNamespace(
web_grounding_enabled_for_node=lambda _node: True,
search=fake_search,
web_search_allowed_domains={"example.org"},
web_search_blocked_domains=set(),
web_search_min_snippet_chars=20,
web_search_max_snippet_chars=180,
web_search_max_total_chars=1200,
),
)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=_build_ontology(),
)
state.set_external_evidence_request(
WorkflowNode.TEXT_TO_ONTOLOGY,
ExternalEvidenceRequest(
initiate_search=True,
rationale="Need clarification",
query_hints=["ontology engineering patterns"],
confidence=0.9,
),
)
state.set_external_evidence_cache_entry(
WorkflowNode.TEXT_TO_ONTOLOGY,
ExternalEvidenceCacheEntry(
plan=ExternalEvidencePlan(
should_search=True,
rationale="Need clarification",
intent="definition",
confidence=0.9,
queries=["ontology engineering patterns"],
),
),
)
fetched = await external_evidence_module.fetch_external_evidence_for_node(
state, tools, WorkflowNode.TEXT_TO_ONTOLOGY
)
assert fetched.external_evidence_source_count == 1
assert fetched.external_evidence_domains == ["example.org"]
assert "https://example.org/ontology" in fetched.external_evidence_text
@pytest.mark.anyio
async def test_ontology_loop_runs_external_evidence_nodes(monkeypatch) -> None:
called_nodes: list[WorkflowNode] = []
async def fake_plan(state: UnitOntologyState, tools, target_node: WorkflowNode):
_ = tools
called_nodes.append(target_node)
return state
async def fake_fetch(state: UnitOntologyState, tools, target_node: WorkflowNode):
_ = tools, target_node
return state
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
_ = tools
state.status = Status.SUCCESS
return state
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
_ = tools
state.status = Status.SUCCESS
return state
monkeypatch.setattr(unit_loops, "plan_external_evidence_for_node", fake_plan)
monkeypatch.setattr(unit_loops, "fetch_external_evidence_for_node", fake_fetch)
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
)
tools = cast(AtomicToolBox, object())
result = await unit_loops.ontology_loop(state, tools=tools)
assert result.status == Status.SUCCESS
assert called_nodes == []
@pytest.mark.anyio
async def test_ontology_loop_plans_search_when_critic_requests_it(monkeypatch) -> None:
called_nodes: list[WorkflowNode] = []
async def fake_plan(state: UnitOntologyState, tools, target_node: WorkflowNode):
_ = tools
called_nodes.append(target_node)
return state
async def fake_fetch(state: UnitOntologyState, tools, target_node: WorkflowNode):
_ = tools
called_nodes.append(target_node)
return state
async def fake_render(state: UnitOntologyState, tools) -> UnitOntologyState:
_ = tools
state.status = Status.SUCCESS
return state
critic_calls = {"count": 0}
async def fake_critic(state: UnitOntologyState, tools) -> UnitOntologyState:
_ = tools
critic_calls["count"] += 1
if critic_calls["count"] == 1:
state.status = Status.FAILED
state.set_external_evidence_request(
WorkflowNode.CRITICISE_ONTOLOGY,
ExternalEvidenceRequest(
initiate_search=True,
rationale="Need domain standard disambiguation.",
query_hints=["ontology modeling standard pattern"],
),
)
return state
state.status = Status.SUCCESS
return state
monkeypatch.setattr(unit_loops, "plan_external_evidence_for_node", fake_plan)
monkeypatch.setattr(unit_loops, "fetch_external_evidence_for_node", fake_fetch)
monkeypatch.setattr(unit_loops, "render_ontology", fake_render)
monkeypatch.setattr(unit_loops, "criticise_ontology", fake_critic)
state = UnitOntologyState(
content_unit=_build_content_unit(),
ontology_snapshot=Ontology(iri=ONTOLOGY_NULL_IRI),
)
tools = cast(AtomicToolBox, object())
result = await unit_loops.ontology_loop(state, tools=tools)
assert result.status == Status.SUCCESS
assert called_nodes == [
WorkflowNode.CRITICISE_ONTOLOGY,
WorkflowNode.CRITICISE_ONTOLOGY,
]
def test_agent_state_render_mode_properties() -> None:
facts_only = AgentState(render_mode=RenderMode.FACTS)
assert facts_only.render_mode == RenderMode.FACTS
assert facts_only.render_facts is True
assert facts_only.render_ontology is False
ontology_only = AgentState(render_mode=RenderMode.ONTOLOGY)
assert ontology_only.render_mode == RenderMode.ONTOLOGY
assert ontology_only.render_facts is False
assert ontology_only.render_ontology is True
both = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
assert both.render_mode == RenderMode.ONTOLOGY_AND_FACTS
assert both.render_facts is True
assert both.render_ontology is True
def test_route_after_ontology_consolidation_respects_ontology_only_mode() -> None:
ontology_only = AgentState(render_mode=RenderMode.ONTOLOGY)
assert route_after_ontology_consolidation(ontology_only) == WorkflowNode.SERIALIZE
ontology_and_facts = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
assert (
route_after_ontology_consolidation(ontology_and_facts)
== WorkflowNode.RENDER_FACTS
)
def test_toolbox_serialize_skips_facts_in_ontology_only_mode() -> None:
class RecordingOntologyManager:
def __init__(self) -> None:
self.added = 0
def add_ontology(self, ontology: Ontology) -> None:
self.added += 1
class RecordingStore:
def __init__(self) -> None:
self.calls: list[tuple[object, str | None]] = []
def serialize(self, payload: object, graph_uri: str | None = None) -> None:
self.calls.append((payload, graph_uri))
state = AgentState(render_mode=RenderMode.ONTOLOGY)
state.current_ontology = _build_ontology()
store = RecordingStore()
toolbox = SimpleNamespace(
ontology_manager=RecordingOntologyManager(),
filesystem_manager=store,
triple_store_manager=None,
)
ToolBox.serialize(cast(ToolBox, toolbox), state)
assert len(store.calls) == 1
assert isinstance(store.calls[0][0], Ontology)
assert store.calls[0][1] is None
def test_toolbox_serialize_includes_facts_when_render_facts_enabled() -> None:
class RecordingOntologyManager:
def add_ontology(self, ontology: Ontology) -> None:
return None
class RecordingStore:
def __init__(self) -> None:
self.calls: list[tuple[object, str | None]] = []
def serialize(self, payload: object, graph_uri: str | None = None) -> None:
self.calls.append((payload, graph_uri))
state = AgentState(render_mode=RenderMode.ONTOLOGY_AND_FACTS)
state.current_ontology = _build_ontology()
store = RecordingStore()
toolbox = SimpleNamespace(
ontology_manager=RecordingOntologyManager(),
filesystem_manager=store,
triple_store_manager=None,
)
ToolBox.serialize(cast(ToolBox, toolbox), state)
assert len(store.calls) == 2
assert isinstance(store.calls[0][0], Ontology)
assert isinstance(store.calls[1][0], RDFGraph)
assert store.calls[1][1] == state.graph_uri
def test_render_updated_graph_splits_compound_sparql_insert_updates() -> None:
graph = RDFGraph()
graph.parse(
data="""
@prefix ex: <http://example.org/> .
ex:Existing ex:kept ex:Value .
""",
format="turtle",
)
update = GraphUpdate(
sparql_operations=[
GenericSparqlQuery(
query=(
"PREFIX ex: <http://example.org/>\n"
"INSERT DATA { ex:Person ex:label ex:Alice }\n"
"INSERT DATA { ex:Person ex:status ex:Active }"
)
)
]
)
updated_graph, was_applied = AgentState.render_updated_graph(graph, [update])
assert was_applied is True
assert (
URIRef("http://example.org/Person"),
URIRef("http://example.org/label"),
URIRef("http://example.org/Alice"),
) in updated_graph
assert (
URIRef("http://example.org/Person"),
URIRef("http://example.org/status"),
URIRef("http://example.org/Active"),
) in updated_graph

View File

@@ -0,0 +1,148 @@
"""Test for RDFGraph __iadd__ method.
This test verifies that the __iadd__ method properly reuses __add__ and binds prefixes.
"""
from rdflib import Graph, Literal, Namespace, URIRef
from ontocast.onto.rdfgraph import RDFGraph
def test_rdfgraph_iadd_reuses_add_and_binds_prefixes():
"""Test that __iadd__ reuses __add__ and properly binds prefixes."""
# Create two RDFGraph instances with different namespaces
graph1 = RDFGraph()
graph2 = RDFGraph()
# Define namespacesЙ
ns1 = Namespace("http://example.org/ns1/")
ns2 = Namespace("http://example.org/ns2/")
# Add some triples to graph1 with ns1 namespace
graph1.add((ns1.subject1, ns1.predicate1, Literal("value1")))
graph1.add((ns1.subject2, ns1.predicate2, Literal("value2")))
graph1.bind("ns1", ns1)
# Add some triples to graph2 with ns2 namespace
graph2.add((ns2.subject1, ns2.predicate1, Literal("value3")))
graph2.add((ns2.subject2, ns2.predicate2, Literal("value4")))
graph2.bind("ns2", ns2)
# Test __iadd__ method
graph1 += graph2
# Verify that all triples are present
assert len(graph1) == 4
assert (ns1.subject1, ns1.predicate1, Literal("value1")) in graph1
assert (ns1.subject2, ns1.predicate2, Literal("value2")) in graph1
assert (ns2.subject1, ns2.predicate1, Literal("value3")) in graph1
assert (ns2.subject2, ns2.predicate2, Literal("value4")) in graph1
# Verify that namespace bindings are preserved
namespaces = dict(graph1.namespaces())
assert "ns1" in namespaces
assert "ns2" in namespaces
assert str(namespaces["ns1"]) == "http://example.org/ns1/"
assert str(namespaces["ns2"]) == "http://example.org/ns2/"
def test_rdfgraph_iadd_with_regular_graph():
"""Test that __iadd__ works with regular rdflib.Graph objects."""
# Create RDFGraph and regular Graph
rdf_graph = RDFGraph()
regular_graph = Graph()
# Define namespace
ns = Namespace("http://example.org/test/")
# Add triples to both graphs
rdf_graph.add((ns.subject1, ns.predicate1, Literal("value1")))
rdf_graph.bind("test", ns)
regular_graph.add((ns.subject2, ns.predicate2, Literal("value2")))
# Test __iadd__ method
rdf_graph += regular_graph
# Verify that all triples are present
assert len(rdf_graph) == 2
assert (ns.subject1, ns.predicate1, Literal("value1")) in rdf_graph
assert (ns.subject2, ns.predicate2, Literal("value2")) in rdf_graph
# Verify that namespace binding is preserved
namespaces = dict(rdf_graph.namespaces())
assert "test" in namespaces
assert str(namespaces["test"]) == "http://example.org/test/"
def test_rdfgraph_iadd_returns_self():
"""Test that __iadd__ returns self for chaining."""
graph1 = RDFGraph()
graph2 = RDFGraph()
# Add some triples
graph1.add(
(
URIRef("http://example.org/subject1"),
URIRef("http://example.org/predicate1"),
Literal("value1"),
)
)
graph2.add(
(
URIRef("http://example.org/subject2"),
URIRef("http://example.org/predicate2"),
Literal("value2"),
)
)
# Test that __iadd__ returns self
result = graph1.__iadd__(graph2)
# Verify that result is the same object as graph1
assert result is graph1
assert len(graph1) == 2
def test_rdfgraph_iadd_equivalent_to_add():
"""Test that __iadd__ produces the same result as __add__."""
# Create two graphs
graph1 = RDFGraph()
graph2 = RDFGraph()
# Define namespaces
ns1 = Namespace("http://example.org/ns1/")
ns2 = Namespace("http://example.org/ns2/")
# Add triples and bind namespaces
graph1.add((ns1.subject1, ns1.predicate1, Literal("value1")))
graph1.bind("ns1", ns1)
graph2.add((ns2.subject1, ns2.predicate1, Literal("value2")))
graph2.bind("ns2", ns2)
# Create copies for comparison
graph1_copy = RDFGraph()
for triple in graph1:
graph1_copy.add(triple)
for prefix, uri in graph1.namespaces():
graph1_copy.bind(prefix, uri)
# Test __add__ method
result_add = graph1_copy + graph2
# Test __iadd__ method
graph1 += graph2
# Verify that both methods produce the same result
assert len(graph1) == len(result_add)
assert set(graph1) == set(result_add)
# Verify namespace bindings are the same
namespaces1 = dict(graph1.namespaces())
namespaces_add = dict(result_add.namespaces())
assert namespaces1 == namespaces_add

View File

@@ -0,0 +1,185 @@
"""Test suite for SemanticChunker.
This test suite ensures that:
1. Chunks, when joined, reproduce the original text (length and content)
2. If max_size and min_size are provided, all chunks are >= min_size and <= max_size
"""
import json
import re
from pathlib import Path
import pytest
from langchain_core.embeddings import Embeddings
from ontocast.config import ChunkConfig
from ontocast.tool.chunk.util import SENTENCE_SPLIT_REGEX, SemanticChunker
class TestSemanticChunker:
"""Core tests for SemanticChunker focusing on text reconstruction and size constraints."""
def test_chunks_reproduce_original_text_when_joined(
self, embeddings: Embeddings, sample_text: str
):
"""Test that chunks, when joined, reproduce the original text."""
chunk_config = ChunkConfig(
min_size=1, # Very small min_size to allow any chunk size
max_size=100000, # Very large max_size to allow any chunk size
)
chunker = SemanticChunker(
embeddings=embeddings,
chunk_config=chunk_config,
sentence_split_regex=SENTENCE_SPLIT_REGEX,
)
chunks = chunker.split_text(sample_text)
joined_text = "".join(chunks)
# Verify length is approximately the same
length_diff = abs(len(joined_text) - len(sample_text))
assert length_diff <= len(chunks), (
f"Joined text length difference ({length_diff}) is too large. "
f"Original: {len(sample_text)}, Joined: {len(joined_text)}"
)
# Verify content is preserved (normalize whitespace for comparison)
original_normalized = re.sub(r"\s+", " ", sample_text.strip())
joined_normalized = re.sub(r"\s+", " ", joined_text.strip())
# Check word coverage
original_words = set(re.findall(r"\b\w+\b", original_normalized.lower()))
joined_words = set(re.findall(r"\b\w+\b", joined_normalized.lower()))
missing_words = original_words - joined_words
coverage = (
1 - (len(missing_words) / len(original_words)) if original_words else 1
)
assert coverage >= 0.95, (
f"Word coverage too low: {coverage:.1%}. "
f"Missing {len(missing_words)} words: {list(missing_words)[:10]}"
)
def test_chunks_respect_min_and_max_size(
self, embeddings: Embeddings, long_text: str
):
"""Test that chunks respect both min_size and max_size constraints."""
min_size = 200
max_size = 1000
chunk_config = ChunkConfig(
min_size=min_size,
max_size=max_size,
)
chunker = SemanticChunker(
embeddings=embeddings,
chunk_config=chunk_config,
sentence_split_regex=SENTENCE_SPLIT_REGEX,
)
chunks = chunker.split_text(long_text)
assert len(chunks) > 0, "Should produce at least one chunk"
for i, chunk in enumerate(chunks):
# All chunks must respect max_size
assert len(chunk) <= max_size, (
f"Chunk {i} has length {len(chunk)} which exceeds max_size {max_size}"
)
# All but last chunk should meet min_size
if i < len(chunks) - 1:
assert len(chunk) >= min_size, (
f"Chunk {i} has length {len(chunk)} which is less than min_size {min_size}"
)
# Verify joined text exactly reproduces original
joined_text = "".join(chunks)
assert joined_text == long_text, (
f"Joined text does not exactly match original text. "
f"Length difference: {abs(len(joined_text) - len(long_text))} characters. "
f"Original length: {len(long_text)}, Joined length: {len(joined_text)}. "
f"First difference at position: {next((i for i, (a, b) in enumerate(zip(long_text, joined_text)) if a != b), min(len(long_text), len(joined_text)))}"
)
def test_chunker_test_json_with_strict_size_constraints(
self, embeddings: Embeddings
):
"""Test with chunker.test.json using strict size constraints (min_size=2000, max_size=4000).
This test reproduces a bug where:
1. Chunks smaller than min_size are produced
2. Chunks are almost exactly max_size (suggesting brute force cutting)
"""
# Load test data
json_file = Path(__file__).parent / "data" / "chunker.test.json"
if not json_file.exists():
pytest.skip(f"Test data file not found: {json_file}")
data = json.load(open(json_file))
text = data.get("text", "")
if not text:
pytest.skip("No text found in test data")
min_size = 2000
max_size = 4000
chunk_config = ChunkConfig(
min_size=min_size,
max_size=max_size,
)
chunker = SemanticChunker(
embeddings=embeddings,
chunk_config=chunk_config,
sentence_split_regex=SENTENCE_SPLIT_REGEX,
)
chunks = chunker.split_text(text)
chunk_sizes = [len(c) for c in chunks]
# Verify all chunks respect max_size
for i, chunk in enumerate(chunks):
assert len(chunk) <= max_size, (
f"Chunk {i} has length {len(chunk)} which exceeds max_size {max_size}. "
f"Chunk sizes: {chunk_sizes}"
)
# Verify chunks meet min_size (except possibly the last one)
# All but the last chunk should meet min_size
# The last chunk may be smaller if remaining text is less than min_size
if len(chunks) > 1:
for i in range(len(chunks) - 1):
assert len(chunks[i]) >= min_size, (
f"Chunk {i} (not last) has length {len(chunks[i])} which is less than "
f"min_size {min_size}. Chunk sizes: {chunk_sizes}"
)
# Even the last chunk should be reasonably sized (at least 50% of min_size)
# unless the total remaining text is very small
if len(chunks) > 0:
last_chunk_size = len(chunks[-1])
if last_chunk_size < min_size * 0.5 and len(chunks) > 1:
# Check if this is really the last chunk or if there's a problem
total_remaining = sum(len(c) for c in chunks if len(c) < min_size)
if total_remaining >= min_size:
pytest.fail(
f"Last chunk has length {last_chunk_size} which is too small. "
f"Total size of small chunks: {total_remaining} >= {min_size}, "
f"so they should have been merged. Chunk sizes: {chunk_sizes}"
)
# Check for brute force cutting - chunks should not all be clustered near max_size
chunks_near_max = sum(1 for size in chunk_sizes if size >= max_size * 0.98)
ratio_near_max = chunks_near_max / len(chunks) if chunks else 0
# If more than 60% of chunks are near max_size, it suggests brute force cutting
assert ratio_near_max < 0.6, (
f"Too many chunks ({chunks_near_max}/{len(chunks)} = {ratio_near_max:.1%}) "
f"are near max_size ({max_size * 0.98:.0f}), suggesting brute force cutting. "
f"Chunk sizes: {chunk_sizes}"
)
# Verify joined text exactly reproduces original
joined_text = "".join(chunks)
assert joined_text == text, (
f"Joined text does not exactly match original text. "
f"Length difference: {abs(len(joined_text) - len(text))} characters. "
f"Original length: {len(text)}, Joined length: {len(joined_text)}. "
f"First difference at position: {next((i for i, (a, b) in enumerate(zip(text, joined_text)) if a != b), min(len(text), len(joined_text)))}"
)