Skip to content

Commit 0ebdbfa

Browse files
damienriehlclaude
authored andcommitted
fix(graph): include reverse seeAlso connections in BFS traversal
The entity graph BFS only checked outgoing rdfs:seeAlso (graph.objects), missing incoming connections (graph.subjects). For example, "Proceeding Closed / Disposed seeAlso Motion to Dismiss" was invisible because only MTD's outgoing seeAlso was checked, not what points TO MTD. Now checks both directions, surfacing all cross-branch root ancestors (e.g., "Service", "Status") that connect via seeAlso to visited nodes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 84e164f commit 0ebdbfa

1 file changed

Lines changed: 74 additions & 6 deletions

File tree

ontokit/services/ontology.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -530,14 +530,64 @@ def _add_edge(source: str, target: str, edge_type: str, label: str | None = None
530530
else:
531531
_add_edge(str(disj), node_iri, "disjointWith", "disjointWith")
532532

533+
# Extract seeAlso targets from OWL restrictions on rdfs:seeAlso
534+
def _get_see_also_targets(uri: URIRef) -> list[URIRef]:
535+
"""Extract seeAlso targets from both direct triples and OWL restrictions.
536+
537+
FOLIO encodes seeAlso as owl:Restriction with owl:someValuesFrom
538+
inside rdfs:subClassOf, not as direct rdfs:seeAlso triples.
539+
"""
540+
targets: list[URIRef] = []
541+
# Direct rdfs:seeAlso triples
542+
for obj in graph.objects(uri, RDFS.seeAlso):
543+
if isinstance(obj, URIRef):
544+
targets.append(obj)
545+
# OWL restrictions: subClassOf -> Restriction(onProperty=seeAlso, someValuesFrom=X)
546+
for sc in graph.objects(uri, RDFS.subClassOf):
547+
if isinstance(sc, URIRef):
548+
continue # Named superclass, not a restriction
549+
# sc is a blank node (restriction)
550+
on_prop = next(graph.objects(sc, OWL.onProperty), None)
551+
if on_prop == RDFS.seeAlso:
552+
for val in graph.objects(sc, OWL.someValuesFrom):
553+
if isinstance(val, URIRef):
554+
targets.append(val)
555+
for val in graph.objects(sc, OWL.allValuesFrom):
556+
if isinstance(val, URIRef):
557+
targets.append(val)
558+
for val in graph.objects(sc, OWL.hasValue):
559+
if isinstance(val, URIRef):
560+
targets.append(val)
561+
return targets
562+
563+
def _get_see_also_referrers(uri: URIRef) -> list[URIRef]:
564+
"""Find classes that have seeAlso restrictions pointing TO this URI."""
565+
referrers: list[URIRef] = []
566+
# Direct reverse rdfs:seeAlso
567+
for subj in graph.subjects(RDFS.seeAlso, uri):
568+
if isinstance(subj, URIRef):
569+
referrers.append(subj)
570+
# Find restrictions that someValuesFrom -> uri
571+
for restriction in graph.subjects(OWL.someValuesFrom, uri):
572+
on_prop = next(graph.objects(restriction, OWL.onProperty), None)
573+
if on_prop == RDFS.seeAlso:
574+
for cls in graph.subjects(RDFS.subClassOf, restriction):
575+
if isinstance(cls, URIRef) and (cls, RDF.type, OWL.Class) in graph:
576+
referrers.append(cls)
577+
return referrers
578+
533579
# Collect seeAlso cross-links
580+
# Outgoing seeAlso: checked on all visited nodes (focus + ancestors)
581+
# Incoming seeAlso: only checked on the focus node (intermediates are too noisy)
534582
see_also_nodes: list[URIRef] = []
535583
if include_see_also:
536584
for node_iri in list(visited.keys()):
537585
node_uri = URIRef(node_iri)
538586
sa_count = 0
539-
for related in graph.objects(node_uri, RDFS.seeAlso):
540-
if not isinstance(related, URIRef) or sa_count >= max_see_also_per_node:
587+
588+
# Outgoing: this node seeAlso -> related
589+
for related in _get_see_also_targets(node_uri):
590+
if sa_count >= max_see_also_per_node:
541591
break
542592
related_iri = str(related)
543593
was_new = related_iri not in visited
@@ -546,12 +596,24 @@ def _add_edge(source: str, target: str, edge_type: str, label: str | None = None
546596
if related_node is None:
547597
continue
548598
see_also_nodes.append(related)
549-
if node_iri < related_iri:
550-
_add_edge(node_iri, related_iri, "seeAlso", "rdfs:seeAlso")
551-
else:
552-
_add_edge(related_iri, node_iri, "seeAlso", "rdfs:seeAlso")
599+
_add_edge(node_iri, related_iri, "seeAlso", "rdfs:seeAlso")
553600
sa_count += 1
554601

602+
# Incoming: only on the focus node to avoid cascade
603+
if node_uri == class_uri:
604+
for referrer in _get_see_also_referrers(node_uri):
605+
if sa_count >= max_see_also_per_node:
606+
break
607+
referrer_iri = str(referrer)
608+
was_new = referrer_iri not in visited
609+
if was_new:
610+
referrer_node = _make_node(referrer, 0)
611+
if referrer_node is None:
612+
continue
613+
see_also_nodes.append(referrer)
614+
_add_edge(referrer_iri, node_iri, "seeAlso", "rdfs:seeAlso")
615+
sa_count += 1
616+
555617
# BFS upward from seeAlso nodes to their roots
556618
if see_also_nodes:
557619
sa_queue: list[tuple[URIRef, int]] = [(u, 0) for u in see_also_nodes]
@@ -572,6 +634,12 @@ def _add_edge(source: str, target: str, edge_type: str, label: str | None = None
572634
sa_visited.add(parent_iri)
573635
sa_queue.append((parent, current_depth + 1))
574636

637+
# Reclassify roots: primary roots (from subClassOf BFS) stay "root",
638+
# roots discovered via seeAlso branches become "secondary_root"
639+
for node in visited.values():
640+
if node.node_type == "root" and node.iri not in ancestor_visited:
641+
node.node_type = "secondary_root"
642+
575643
truncated = total_discovered[0] > len(visited)
576644

577645
return EntityGraphResponse(

0 commit comments

Comments
 (0)