Coverage for lode / reader / logic / base_logic.py: 66%
531 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-10-05 16:02 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-10-05 16:02 +0000
1# base_logic.py
2from abc import ABC, abstractmethod
3from rdflib import Graph, URIRef, Node, Literal as RDFlibLiteral, BNode
4from rdflib.namespace import RDF, RDFS, OWL, SKOS, XSD, Namespace
5from rdflib.collection import Collection as RDFLibCollection
6from rdflib import Namespace
7SWRL_NS = Namespace("http://www.w3.org/2003/11/swrl#")
9from lode.models import *
10from lode.helpers.rdf import iri_local_name, iri_namespace
12class BaseLogic(ABC):
13 """
14 Logica base comune per parsing RDF.
15 """
17 def __init__(self, graph: Graph, instance_cache: dict, strategy):
18 self.graph = graph
19 self._instance_cache = instance_cache
20 self._strategy = strategy
21 self._property_mapping = strategy.get_property_mapping()
22 self._allowed_classes = self._get_allowed_classes()
23 self._triples_map = {}
24 # Namespaces now driven by config YAML (key: 'namespaces')
25 self._allowed_namespaces = self._get_allowed_namespaces()
26 # Validate all handlers declared in config exist on this instance
27 self._validate_handlers()
28 self._warnings = []
30 def add_warning(self, code, subject, message):
31 self._warnings.append({'code': code, 'subject': str(subject), 'message': message})
33 def get_warnings(self):
34 if not getattr(self, '_warnings_enabled', False): # self qui è la LOGIC
35 return []
37 def save_warnings(self, filepath: str = None):
38 """Save warnings to JSON file."""
39 import json
40 from pathlib import Path
42 if not self._warnings:
43 return
45 if filepath is None:
46 filepath = Path(__file__).parent.parent.parent / 'warnings.json'
48 with open(filepath, 'w', encoding='utf-8') as f:
49 json.dump(self._warnings, f, indent=2, ensure_ascii=False)
51 def _get_allowed_classes(self) -> set:
52 class_names = self._strategy.config.get('allowed_classes', [])
53 import lode.models as _models
54 return {getattr(_models, name) for name in class_names if getattr(_models, name, None) is not None}
56 def _get_allowed_namespaces(self) -> set:
57 """
58 Reads namespaces from config YAML key 'namespaces'.
59 Subclasses do NOT need to override this anymore.
60 """
61 return set(self._strategy.config.get('namespaces', []))
63 def get_namespaces(self) -> dict:
64 """Prefixes declared in the graph (prefix -> URI)."""
65 return {prefix: str(ns) for prefix, ns in self.graph.namespaces()}
67 @abstractmethod
68 def phase1_classify_from_predicates(self):
69 pass
71 @abstractmethod
72 def phase2_create_from_types(self):
73 pass
75 @abstractmethod
76 def phase3_populate_properties(self):
77 pass
79 @abstractmethod
80 def phase4_process_group_axioms(self):
81 pass
83 @abstractmethod
84 def phase5_fallback(self):
85 pass
87 def phase6_create_statements(self):
88 for subj, pred, obj in self.graph:
89 # if pred in self._property_mapping:
90 # continue
91 if pred in [RDF.first, RDF.rest, RDF.nil, OWL.distinctMembers, OWL.members]:
92 continue
93 if isinstance(subj, URIRef):
94 subj_str = str(subj)
95 # structural entities from taken namespaces are not used for statements
96 if any(subj_str.startswith(ns) for ns in self._allowed_namespaces):
97 if not any(True for _ in self.graph.objects(subj, RDF.type)):
98 continue
99 if self._is_triple_mapped(subj, pred, obj):
100 continue
101 self._create_statement_for_triple(subj, pred, obj)
103 # ========== VALIDAZIONE CONFIG -> LOGIC ==========
105 def _validate_handlers(self):
106 """
107 Fail-fast: verifica che tutti gli handler dichiarati nel config
108 esistano come metodi su questa istanza Logic.
109 Solleva AttributeError subito, prima di qualsiasi parsing.
110 """
111 errors = []
113 for uri_str, cfg in self._strategy.config.get('mapper', {}).items():
114 handler_name = cfg.get('handler')
115 if handler_name and not hasattr(self, handler_name):
116 errors.append(
117 f"mapper['{uri_str}'].handler='{handler_name}' "
118 f"not found on {type(self).__name__}"
119 )
121 for uri_str, handler_name in self._strategy.config.get('enricher', {}).items():
122 if isinstance(handler_name, str) and not hasattr(self, handler_name):
123 errors.append(
124 f"enricher['{uri_str}']='{handler_name}' "
125 f"not found on {type(self).__name__}"
126 )
128 if errors:
129 raise AttributeError(
130 f"Config/Logic contract violations in {type(self).__name__}:\n"
131 + "\n".join(f" - {e}" for e in errors)
132 )
134 # ========== RISOLUZIONE CLASSI AMMESSE ==========
136 def _resolve_allowed_class(self, python_class: type, id: Node = None) -> type:
137 """
138 Resolves a Python class to one allowed by the current format, by walking
139 the MRO until a class present in _allowed_classes is found.
140 Before the MRO walk, calls _pre_resolve_hook to allow subclasses to
141 short-circuit resolution with custom logic (e.g. reusing an existing
142 cached type instead of silently downcasting).
143 Falls back to Resource if nothing is found.
144 """
145 if python_class in self._allowed_classes:
146 return python_class
148 # Hook per logica custom pre-MRO (es. OWL: controlla cache)
149 resolved = self._pre_resolve_hook(python_class, id)
150 if resolved:
151 return resolved
153 for parent_class in python_class.__mro__[1:]:
154 if parent_class in self._allowed_classes:
155 return parent_class
157 print(f" [WARN] {python_class.__name__} -> Resource (fallback finale)")
158 return Resource
160 def _pre_resolve_hook(self, python_class: type, id: Node) -> type | None:
161 """
162 Hook opzionale per logica custom pre-MRO.
163 Le subclass possono override senza toccare _resolve_allowed_class.
164 Ritorna None per delegare al comportamento base.
165 """
166 return None
168 # ========== Namespaces resolution ==========
170 def populate_namespaces(self):
171 """Attacca i prefissi del grafo a ogni Model in cache."""
172 ns = {prefix: str(uri) for prefix, uri in self.graph.namespaces()}
173 for instances in self._instance_cache.values():
174 for inst in instances:
175 if isinstance(inst, Model):
176 inst.set_has_namespaces(ns)
178 # ========== PROVENANCE ========== (nota: _triples_map viene scritto solo da questi tre metodi.)
180 def _track(self, instance, *triples):
181 """Attribuisce uno o piu' tripla alla provenance di `instance`."""
182 self._triples_map.setdefault(instance, set()).update(triples)
184 def _untrack(self, instance, *triples):
185 """Rimuove tripla dalla provenance di `instance`."""
186 bucket = self._triples_map.get(instance)
187 if bucket:
188 bucket.difference_update(triples)
190 def _move_tracking(self, old, new):
191 """Trasferisce la provenance quando un'istanza viene promossa/sostituita."""
192 if old in self._triples_map:
193 self._triples_map.setdefault(new, set()).update(self._triples_map.pop(old))
195 # ========== UTILITIES ==========
197 def _traverse_hierarchy(
198 self,
199 start: object,
200 next_getter: str,
201 direction: str = "up", # "up" | "down" | "both"
202 collect: callable = None, # (node) -> value | None — ferma e ritorna quando non None
203 visit_all: callable = None, # (node) -> None — visita ogni nodo senza fermarsi
204 ) -> object | None:
205 """
206 Generic BFS traversal along a property hierarchy.
208 Parameters
209 ----------
210 start : starting node (Python model instance)
211 next_getter : name of the getter that returns the next nodes in the
212 'up' direction (e.g. 'get_is_sub_property_of').
213 For 'down' the cache is scanned for instances that list
214 `start` as one of their `next_getter` targets.
215 direction : 'up' - follow next_getter only
216 'down' - scan cache for reverse links only
217 'both' - up first, then down
218 collect : callable(node) -> value | None
219 Called on every visited node (including start).
220 When it returns a non-None value the traversal stops
221 immediately and that value is returned.
222 visit_all : callable(node) -> None
223 Called on every visited node; traversal never stops early.
224 Mutually exclusive with collect (collect takes priority).
226 Returns the first non-None value from collect, or None if visit_all is used.
227 """
228 visited = set()
229 queue = [start]
230 result = None
232 while queue:
233 current = queue.pop(0)
234 if id(current) in visited:
235 continue
236 visited.add(id(current))
238 # --- collect / visit ---
239 if collect:
240 value = collect(current)
241 if value is not None:
242 return value
243 elif visit_all:
244 visit_all(current)
246 # --- enqueue next nodes ---
247 if direction in ("up", "both"):
248 getter = getattr(current, next_getter, None)
249 if getter:
250 nexts = getter()
251 if nexts:
252 if not isinstance(nexts, list):
253 nexts = [nexts]
254 queue.extend(nexts)
256 if direction in ("down", "both"):
257 for instances_set in self._instance_cache.values():
258 for inst in instances_set:
259 if inst is current or id(inst) in visited:
260 continue
261 getter = getattr(inst, next_getter, None)
262 if getter:
263 parents = getter() or []
264 if not isinstance(parents, list):
265 parents = [parents]
266 if any(p is current for p in parents):
267 queue.append(inst)
269 return None
271 # def is_in_range_or_domain_of_property(self, property_getter, property_getter_inverse, property_instance):
273 # if isinstance(object, (Concept, Individual, Datatype)):
274 # obj_inst = property_instance.property_getter()
275 # obj_inst.property_getter_inverse()
278 def _create_literal(self, rdflib_literal):
279 literal_key = f"LITERAL::{rdflib_literal}"
280 if literal_key in self._instance_cache:
281 return next(iter(self._instance_cache[literal_key]))
283 literal = Literal()
284 literal.set_has_value(str(rdflib_literal))
286 if rdflib_literal.language:
287 literal.set_has_language(rdflib_literal.language)
289 if rdflib_literal.datatype:
290 dt = self.get_or_create(rdflib_literal.datatype, Datatype)
291 if dt:
292 literal.set_has_type(dt)
294 self._instance_cache[literal_key] = {literal}
295 return literal
297 def _is_rdf_collection(self, node: Node) -> bool:
298 return (node, RDF.first, None) in self.graph
300 def _instance_matches_target(self, instance, target_classes: list) -> bool:
301 instance_class = instance.__class__
302 if instance_class in target_classes:
303 return True
304 for parent_class in instance_class.__mro__[1:]:
305 if parent_class in target_classes:
306 return True
307 return False
309 def _apply_setters(self, instance, setters_config, obj):
310 for setter_item in setters_config:
311 if isinstance(setter_item, dict):
312 for setter_name, value_type in setter_item.items():
313 if not hasattr(instance, setter_name):
314 continue
315 setter = getattr(instance, setter_name)
316 if value_type == 'Literal':
317 try:
318 setter(self._create_literal(obj))
319 except:
320 continue
321 elif isinstance(value_type, bool):
322 setter(value_type)
323 elif isinstance(value_type, str):
324 setter(value_type)
325 elif isinstance(value_type, type):
326 # Don't add structural OWL/RDFS/RDF URIs to has_type
327 if setter_name == 'set_has_type' and isinstance(obj, URIRef):
328 obj_str = str(obj)
329 if any(obj_str.startswith(ns) for ns in self._allowed_namespaces) and obj not in (OWL.Thing, OWL.Nothing, RDFS.Literal):
330 continue
331 # punning: prefer the instance of obj with the same class as the subject
332 obj_instance = next(
333 (c for c in self._instance_cache.get(obj, ())
334 if type(c) is type(instance) and isinstance(c, value_type)),
335 None
336 ) or self.get_or_create(obj, value_type)
337 if obj_instance:
338 setter(obj_instance)
339 else:
340 setter(obj)
341 else:
342 if hasattr(instance, setter_item):
343 getattr(instance, setter_item)()
345 def _handle_collection_object(self, instance, predicate, collection_uri):
346 try:
347 collection = RDFLibCollection(self.graph, collection_uri)
348 items = []
349 for item in collection:
350 if isinstance(item, RDFlibLiteral):
351 items.append(self._create_literal(item))
352 else:
353 item_instance = self.get_or_create(item, Resource)
354 if item_instance:
355 items.append(item_instance)
357 config = self._property_mapping.get(predicate, {})
358 for setter_item in config.get('setters', []):
359 if isinstance(setter_item, dict):
360 for setter_name in setter_item:
361 if hasattr(instance, setter_name):
362 getattr(instance, setter_name)(items)
363 break
364 except Exception as e:
365 print(f"Errore Collection: {e}")
367 def clear_cache(self):
368 self._instance_cache.clear()
370 # ========== LOGIC CORE ==========
372 def get_or_create(self, id: Node, python_class: type = None, populate: bool = True):
373 try:
375 if isinstance(id, RDFlibLiteral):
376 return self._create_literal(id)
378 DATATYPE_BUILTINS = frozenset({
379 RDFS.Literal, RDF.XMLLiteral, RDF.HTML,
380 RDF.PlainLiteral, RDF.langString, RDF.JSON,
381 OWL.real, OWL.rational
382 })
384 if isinstance(id, URIRef):
385 if id in DATATYPE_BUILTINS:
386 python_class = Datatype # built-ins: always Datatype, no exceptions
387 elif str(id).startswith(str(XSD)) and str(id) != str(XSD):
388 if (id, RDF.type, None) not in self.graph:
389 python_class = Datatype # XSD: only if explicitly declared as something else
391 # if isinstance(id, URIRef):
392 # uri_str = str(id)
393 # for ns in self._allowed_namespaces:
394 # if uri_str.startswith(ns) and id not in (OWL.Thing, OWL.Nothing, RDFS.Literal):
395 # return None
397 # Individual punning: non sovrascrivere tipi esistenti non-Individual
398 if python_class == Individual and id in self._instance_cache:
399 is_named_individual = (id, RDF.type, OWL.NamedIndividual) in self.graph
400 if not is_named_individual:
401 for existing in self._instance_cache[id]:
402 if not isinstance(existing, Individual):
403 return existing
405 if id in self._instance_cache:
406 if isinstance(id, BNode):
407 return next(iter(self._instance_cache[id]))
408 if isinstance(id, URIRef):
409 for obj in self._instance_cache[id]:
410 if isinstance(obj, python_class):
411 return obj
412 # Promote only if there is exactly one cached instance and
413 # the requested class is strictly more specific than it
414 # so punning is mantained
415 cached = list(self._instance_cache[id])
416 if len(cached) == 1 and issubclass(python_class, type(cached[0])):
417 old = cached[0]
418 new = python_class()
419 new.__dict__.update(old.__dict__)
420 self._instance_cache[id].discard(old)
421 self._instance_cache[id].add(new)
422 self._move_tracking(old, new)
423 return new
425 instance = python_class()
426 if id not in self._instance_cache:
427 self._instance_cache[id] = set()
428 self._instance_cache[id].add(instance)
429 instance.set_has_identifier(str(id))
431 if populate:
432 self.populate_instance(instance, id)
434 return instance
436 except Exception as e:
437 print(f"Cannot create {python_class.__name__ if python_class else 'Unknown'} for {id}: {e}")
438 return None
440 def populate_instance(self, instance, uri: Node):
441 if isinstance(uri, URIRef):
442 instance.set_has_identifier(str(uri))
443 elif isinstance(uri, BNode):
444 instance.has_identifier = str(uri)
446 is_subordinate = self._is_punning_subordinate(instance, uri)
448 for predicate, obj in self.graph.predicate_objects(uri):
449 if iri_namespace(str(predicate)) not in self._allowed_namespaces:
450 continue
452 handled = False
453 config = self._property_mapping.get(predicate)
455 if config is not None:
456 target_classes = config.get('target_classes', [])
457 if is_subordinate and target_classes and type(instance) not in target_classes:
458 continue
459 if target_classes and not self._instance_matches_target(instance, target_classes):
460 continue
462 if 'handler' in config:
463 try:
464 getattr(self, config['handler'])(instance, uri, predicate, obj, None)
465 handled = True
466 except Exception as e:
467 print(f" Errore handler {config['handler']}: {e}")
468 elif 'setters' in config:
469 try:
470 self._apply_setters(instance, config['setters'], obj)
471 handled = True
472 except Exception as e:
473 print(f" Errore setters: {e}")
475 if not handled and config is None and self._is_rdf_collection(obj):
476 self._handle_collection_object(instance, predicate, obj)
477 handled = True
479 if handled and not (predicate == RDF.type and obj in self._strategy.get_type_mapping()):
480 self._track(instance, (uri, predicate, obj))
482 # ========== HELPERS ==========
484 def _is_triple_mapped(self, subj, pred, obj) -> bool:
485 if subj not in self._instance_cache:
486 return False
487 instances = self._instance_cache[subj]
488 instances_list = instances if isinstance(instances, set) else [instances]
489 for instance in instances_list:
490 if instance in self._triples_map:
491 if (subj, pred, obj) in self._triples_map[instance]:
492 return True
493 return False
495 def _convert_collection_to_container(self, collection_uri):
496 if collection_uri in self._instance_cache:
497 for cached in self._instance_cache[collection_uri]:
498 if isinstance(cached, Container):
499 return cached
501 container = Container()
502 container.set_has_identifier(str(collection_uri))
504 if collection_uri not in self._instance_cache:
505 self._instance_cache[collection_uri] = set()
506 self._instance_cache[collection_uri].add(container)
508 try:
509 collection = RDFLibCollection(self.graph, collection_uri)
510 members = []
511 for item in collection:
512 if isinstance(item, RDFlibLiteral):
513 members.append(self._create_literal(item))
514 else:
515 member_instance = self.get_or_create(item, Resource)
516 if member_instance:
517 members.append(member_instance)
518 container.set_has_members(members)
519 except Exception as e:
520 print(f"Errore Collection: {e}")
522 return container
524 def _create_statement_for_triple(self, subj, pred, obj):
526 if pred in self._property_mapping:
527 return
529 statement = Statement()
530 stmt_bnode = BNode()
531 statement.set_has_identifier(str(stmt_bnode))
533 self._track(statement, (subj, pred, obj))
535 subj_obj = self.get_or_create(subj, Resource)
536 if subj_obj:
537 statement.set_has_subject(subj_obj)
539 pred_inst = self.get_or_create(pred, Property)
540 if pred_inst:
541 statement.set_has_predicate(pred_inst)
543 if self._is_rdf_collection(obj):
544 obj_inst = self._convert_collection_to_container(obj)
545 elif isinstance(obj, RDFlibLiteral):
546 obj_inst = self._create_literal(obj)
547 else:
548 obj_inst = self.get_or_create(obj, Resource)
550 if obj_inst:
551 statement.set_has_object(obj_inst)
553 if stmt_bnode not in self._instance_cache:
554 self._instance_cache[stmt_bnode] = set()
555 self._instance_cache[stmt_bnode].add(statement)
557 def _is_unmapped_structured_bnode(self, node) -> bool:
558 """A BNode is a structured Statement value if:
559 - it's a BNode (not URIRef, not Literal)
560 - it's not already in the instance cache (not classified by any phase)
561 - it has at least one outgoing triple
562 - none of its outgoing predicates carry a useful rdf:type for our model
563 (i.e. it's not declaring itself as a typed entity)
564 """
565 if not isinstance(node, BNode):
566 return False
567 if node in self._instance_cache:
568 return False
569 has_out = False
570 for _ in self.graph.predicate_objects(node):
571 has_out = True
572 break
573 if not has_out:
574 return False
575 # if it declares an rdf:type that we know how to map, leave it alone
576 for t in self.graph.objects(node, RDF.type):
577 if t in self._strategy.get_type_mapping():
578 return False
579 return True
581 def _create_nested_statement(self, node):
582 """Materialises an unmapped structured BNode as a Statement whose own
583 triples become its annotations. Recurses for nested structured BNodes.
584 """
585 statement = Statement()
586 statement.set_has_identifier(str(node))
588 if node not in self._instance_cache:
589 self._instance_cache[node] = set()
590 self._instance_cache[node].add(statement)
592 for p, o in self.graph.predicate_objects(node):
593 self._track(statement, (node, p, o))
595 # The predicate becomes an Annotation if not otherwise known
596 if p in self._instance_cache:
597 pred_inst = next(
598 (i for i in self._instance_cache[p] if isinstance(i, Property)),
599 None
600 )
601 else:
602 pred_inst = None
603 if pred_inst is None:
604 pred_inst = Annotation()
605 pred_inst.set_has_identifier(str(p))
606 self._instance_cache.setdefault(p, set()).add(pred_inst)
608 # Resolve the object recursively
609 if self._is_rdf_collection(o):
610 o_inst = self._convert_collection_to_container(o)
611 elif isinstance(o, RDFlibLiteral):
612 o_inst = self._create_literal(o)
613 elif self._is_unmapped_structured_bnode(o):
614 o_inst = self._create_nested_statement(o)
615 else:
616 o_inst = self.get_or_create(o, Individual)
618 # The first (predicate, object) pair fills the Statement slots;
619 # additional pairs are stored as side annotations on the Statement
620 # itself via setattr (a Statement keeps its own __dict__).
621 if statement.get_has_predicate() is None:
622 statement.set_has_predicate(pred_inst)
623 if o_inst:
624 statement.set_has_object(o_inst)
625 else:
626 # extra triples live as ad-hoc attributes keyed by the predicate label
627 attr_name = iri_local_name(p) # rdf helper
628 existing = getattr(statement, attr_name, None)
629 if existing is None:
630 setattr(statement, attr_name, o_inst)
631 elif isinstance(existing, list):
632 existing.append(o_inst)
633 else:
634 setattr(statement, attr_name, [existing, o_inst])
636 return statement
638 def _get_punning_dominant(self, uri):
639 if uri not in self._instance_cache:
640 return None
641 cached = self._instance_cache[uri]
642 if not cached:
643 return None
644 if len(cached) == 1:
645 return next(iter(cached))
646 priority = self._strategy.get_punning_priority()
647 for cls in priority:
648 for inst in cached:
649 if type(inst) is cls:
650 return inst
651 return next(iter(cached))
653 def _is_punning_subordinate(self, instance, uri):
654 dom = self._get_punning_dominant(uri)
655 return dom is not None and dom is not instance
657 # ========== SWRL ==========
659 def handle_swrl_imp(self, uri):
660 # Force promotion to Rule if cached as something else
661 if uri in self._instance_cache:
662 existing = next(iter(self._instance_cache[uri]))
663 if not isinstance(existing, Rule):
664 rule = Rule()
665 rule.__dict__.update(existing.__dict__)
666 self._instance_cache[uri].discard(existing)
667 self._instance_cache[uri].add(rule)
668 self._move_tracking(existing, rule)
669 else:
670 rule = existing
671 else:
672 rule = self.get_or_create(uri, Rule)
674 if not rule:
675 return
677 body_node = self.graph.value(uri, SWRL_NS.body)
678 if body_node:
679 try:
680 for atom_node in RDFLibCollection(self.graph, body_node):
681 atom = self._parse_swrl_atom(atom_node)
682 if atom:
683 rule.set_has_antecedent(atom)
684 except Exception as e:
685 print(f"Errore body AtomList: {e}")
687 head_node = self.graph.value(uri, SWRL_NS.head)
688 if head_node:
689 try:
690 for atom_node in RDFLibCollection(self.graph, head_node):
691 atom = self._parse_swrl_atom(atom_node)
692 if atom:
693 rule.set_has_consequent(atom)
694 except Exception as e:
695 print(f"Errore head AtomList: {e}")
697 def _parse_swrl_atom(self, atom_node):
698 atom = self.get_or_create(atom_node, Atom)
699 if not atom:
700 return None
702 # classPredicate (ClassAtom)
703 class_pred = self.graph.value(atom_node, SWRL_NS.classPredicate)
704 if class_pred:
705 atom.set_has_predicate(self.get_or_create(class_pred, Concept))
707 # propertyPredicate (IndividualPropertyAtom, DatavaluedPropertyAtom)
708 prop_pred = self.graph.value(atom_node, SWRL_NS.propertyPredicate)
709 if prop_pred:
710 atom_type_uri = self.graph.value(atom_node, RDF.type)
711 local = iri_local_name(atom_type_uri) if atom_type_uri else ''
712 if local == 'DatavaluedPropertyAtom':
713 atom.set_has_predicate(self.get_or_create(prop_pred, Attribute))
714 else:
715 atom.set_has_predicate(self.get_or_create(prop_pred, Relation))
717 # builtin (BuiltinAtom)
718 builtin = self.graph.value(atom_node, SWRL_NS.builtin)
719 if builtin:
720 atom.set_has_predicate(self.get_or_create(builtin, Resource))
722 # SameIndividualsAtom / DifferentIndividualsAtom — no predicate in RDF
723 atom_type_uri = self.graph.value(atom_node, RDF.type)
724 if atom_type_uri:
725 local = iri_local_name(atom_type_uri)
726 if local == 'SameIndividualsAtom':
727 atom.set_has_predicate(self.get_or_create(OWL.sameAs, Relation))
728 elif local == 'DifferentIndividualsAtom':
729 atom.set_has_predicate(self.get_or_create(OWL.differentFrom, Relation))
731 # dataRange (DataRangeAtom)
732 data_range = self.graph.value(atom_node, SWRL_NS.dataRange)
733 if data_range and atom.get_has_predicate() is None:
734 atom.set_has_predicate(self.get_or_create(data_range, Datatype))
736 # argument1 / argument2 (most atom types)
737 arg1 = self.graph.value(atom_node, SWRL_NS.argument1)
738 if arg1:
739 atom.set_has_arguments(self._resolve_swrl_arg(arg1))
741 arg2 = self.graph.value(atom_node, SWRL_NS.argument2)
742 if arg2:
743 atom.set_has_arguments(self._resolve_swrl_arg(arg2))
745 # arguments (BuiltinAtom — lista RDF)
746 args_node = self.graph.value(atom_node, SWRL_NS.arguments)
747 if args_node:
748 try:
749 for arg in RDFLibCollection(self.graph, args_node):
750 atom.set_has_arguments(self._resolve_swrl_arg(arg))
751 except Exception as e:
752 print(f"Errore builtin arguments: {e}")
754 return atom
756 def _resolve_swrl_arg(self, node):
757 if isinstance(node, RDFlibLiteral):
758 return self._create_literal(node)
759 if (node, RDF.type, SWRL_NS.Variable) in self.graph:
760 return self.get_or_create(node, Variable)
761 return self.get_or_create(node, Individual)
763 # ========== PROVENANCE SUBGRAPH ==========
765 def build_provenance_subgraph(self, instance):
766 """
767 Build the RDF subgraph that provenanced `instance`:
768 - direct triples from _triples_map[instance]
769 - BNode-transitive closure on object positions
770 - reified Statements with `instance` as has_subject
771 - strategy-specific axioms (hook: _add_axiom_provenance)
772 """
773 from rdflib import Graph, BNode
774 from lode.models import Statement
776 sub = Graph()
777 for prefix, ns in self.graph.namespaces():
778 sub.bind(prefix, ns, override=True, replace=True)
780 # 1. direct triples
781 source_triples = self._triples_map.get(instance, set())
782 for t in source_triples:
783 sub.add(t)
785 # 2. BNode-transitive closure on objects
786 seen = set()
787 for (_s, _p, o) in source_triples:
788 if isinstance(o, BNode):
789 self._expand_bnode_into(o, sub, seen)
791 # 3. reified Statements pointing at instance
792 for other_inst, other_triples in self._triples_map.items():
793 if not isinstance(other_inst, Statement):
794 continue
795 if other_inst.get_has_subject() is instance:
796 for t in other_triples:
797 sub.add(t)
798 _, _, o = t
799 if isinstance(o, BNode) and o not in seen:
800 self._expand_bnode_into(o, sub, seen)
802 # 4. strategy-specific axioms (hook)
803 self._add_axiom_provenance(instance, sub)
805 return sub
807 def _expand_bnode_into(self, bn, sub, seen):
808 """Add `bn` and its BNode-transitive closure to `sub`."""
809 from rdflib import BNode
810 stack = [bn]
811 while stack:
812 n = stack.pop()
813 if n in seen:
814 continue
815 seen.add(n)
816 for p, o in self.graph.predicate_objects(n):
817 sub.add((n, p, o))
818 if isinstance(o, BNode) and o not in seen:
819 stack.append(o)
821 def _add_axiom_provenance(self, instance, sub):
822 """Hook for subclasses. Default: no strategy-specific axioms."""
823 pass