참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

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)