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

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#") 

8 

9from lode.models import * 

10from lode.helpers.rdf import iri_local_name, iri_namespace 

11 

12class BaseLogic(ABC): 

13 """ 

14 Logica base comune per parsing RDF. 

15 """ 

16 

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 = [] 

29 

30 def add_warning(self, code, subject, message): 

31 self._warnings.append({'code': code, 'subject': str(subject), 'message': message}) 

32 

33 def get_warnings(self): 

34 if not getattr(self, '_warnings_enabled', False): # self qui è la LOGIC 

35 return [] 

36 

37 def save_warnings(self, filepath: str = None): 

38 """Save warnings to JSON file.""" 

39 import json 

40 from pathlib import Path 

41 

42 if not self._warnings: 

43 return 

44 

45 if filepath is None: 

46 filepath = Path(__file__).parent.parent.parent / 'warnings.json' 

47 

48 with open(filepath, 'w', encoding='utf-8') as f: 

49 json.dump(self._warnings, f, indent=2, ensure_ascii=False) 

50 

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} 

55 

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', [])) 

62 

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()} 

66 

67 @abstractmethod 

68 def phase1_classify_from_predicates(self): 

69 pass 

70 

71 @abstractmethod 

72 def phase2_create_from_types(self): 

73 pass 

74 

75 @abstractmethod 

76 def phase3_populate_properties(self): 

77 pass 

78 

79 @abstractmethod 

80 def phase4_process_group_axioms(self): 

81 pass 

82 

83 @abstractmethod 

84 def phase5_fallback(self): 

85 pass 

86 

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) 

102 

103 # ========== VALIDAZIONE CONFIG -> LOGIC ========== 

104 

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 = [] 

112 

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 ) 

120 

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 ) 

127 

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 ) 

133 

134 # ========== RISOLUZIONE CLASSI AMMESSE ========== 

135 

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 

147 

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 

152 

153 for parent_class in python_class.__mro__[1:]: 

154 if parent_class in self._allowed_classes: 

155 return parent_class 

156 

157 print(f" [WARN] {python_class.__name__} -> Resource (fallback finale)") 

158 return Resource 

159 

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 

167 

168 # ========== Namespaces resolution ========== 

169 

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) 

177 

178 # ========== PROVENANCE ========== (nota: _triples_map viene scritto solo da questi tre metodi.) 

179 

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) 

183 

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) 

189 

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)) 

194 

195 # ========== UTILITIES ========== 

196 

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. 

207 

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). 

225 

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 

231 

232 while queue: 

233 current = queue.pop(0) 

234 if id(current) in visited: 

235 continue 

236 visited.add(id(current)) 

237 

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) 

245 

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) 

255 

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) 

268 

269 return None 

270 

271 # def is_in_range_or_domain_of_property(self, property_getter, property_getter_inverse, property_instance): 

272 

273 # if isinstance(object, (Concept, Individual, Datatype)): 

274 # obj_inst = property_instance.property_getter() 

275 # obj_inst.property_getter_inverse() 

276 

277 

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])) 

282 

283 literal = Literal() 

284 literal.set_has_value(str(rdflib_literal)) 

285 

286 if rdflib_literal.language: 

287 literal.set_has_language(rdflib_literal.language) 

288 

289 if rdflib_literal.datatype: 

290 dt = self.get_or_create(rdflib_literal.datatype, Datatype) 

291 if dt: 

292 literal.set_has_type(dt) 

293 

294 self._instance_cache[literal_key] = {literal} 

295 return literal 

296 

297 def _is_rdf_collection(self, node: Node) -> bool: 

298 return (node, RDF.first, None) in self.graph 

299 

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 

308 

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)() 

344 

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) 

356 

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}") 

366 

367 def clear_cache(self): 

368 self._instance_cache.clear() 

369 

370 # ========== LOGIC CORE ========== 

371 

372 def get_or_create(self, id: Node, python_class: type = None, populate: bool = True): 

373 try: 

374 

375 if isinstance(id, RDFlibLiteral): 

376 return self._create_literal(id) 

377 

378 DATATYPE_BUILTINS = frozenset({ 

379 RDFS.Literal, RDF.XMLLiteral, RDF.HTML, 

380 RDF.PlainLiteral, RDF.langString, RDF.JSON, 

381 OWL.real, OWL.rational 

382 }) 

383 

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 

390 

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 

396 

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 

404 

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 

424 

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)) 

430 

431 if populate: 

432 self.populate_instance(instance, id) 

433 

434 return instance 

435 

436 except Exception as e: 

437 print(f"Cannot create {python_class.__name__ if python_class else 'Unknown'} for {id}: {e}") 

438 return None 

439 

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) 

445 

446 is_subordinate = self._is_punning_subordinate(instance, uri) 

447 

448 for predicate, obj in self.graph.predicate_objects(uri): 

449 if iri_namespace(str(predicate)) not in self._allowed_namespaces: 

450 continue 

451 

452 handled = False 

453 config = self._property_mapping.get(predicate) 

454 

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 

461 

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}") 

474 

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 

478 

479 if handled and not (predicate == RDF.type and obj in self._strategy.get_type_mapping()): 

480 self._track(instance, (uri, predicate, obj)) 

481 

482 # ========== HELPERS ========== 

483 

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 

494 

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 

500 

501 container = Container() 

502 container.set_has_identifier(str(collection_uri)) 

503 

504 if collection_uri not in self._instance_cache: 

505 self._instance_cache[collection_uri] = set() 

506 self._instance_cache[collection_uri].add(container) 

507 

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}") 

521 

522 return container 

523 

524 def _create_statement_for_triple(self, subj, pred, obj): 

525 

526 if pred in self._property_mapping: 

527 return 

528 

529 statement = Statement() 

530 stmt_bnode = BNode() 

531 statement.set_has_identifier(str(stmt_bnode)) 

532 

533 self._track(statement, (subj, pred, obj)) 

534 

535 subj_obj = self.get_or_create(subj, Resource) 

536 if subj_obj: 

537 statement.set_has_subject(subj_obj) 

538 

539 pred_inst = self.get_or_create(pred, Property) 

540 if pred_inst: 

541 statement.set_has_predicate(pred_inst) 

542 

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) 

549 

550 if obj_inst: 

551 statement.set_has_object(obj_inst) 

552 

553 if stmt_bnode not in self._instance_cache: 

554 self._instance_cache[stmt_bnode] = set() 

555 self._instance_cache[stmt_bnode].add(statement) 

556 

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 

580 

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)) 

587 

588 if node not in self._instance_cache: 

589 self._instance_cache[node] = set() 

590 self._instance_cache[node].add(statement) 

591 

592 for p, o in self.graph.predicate_objects(node): 

593 self._track(statement, (node, p, o)) 

594 

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) 

607 

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) 

617 

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]) 

635 

636 return statement 

637 

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)) 

652 

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 

656 

657 # ========== SWRL ========== 

658 

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) 

673 

674 if not rule: 

675 return 

676 

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}") 

686 

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}") 

696 

697 def _parse_swrl_atom(self, atom_node): 

698 atom = self.get_or_create(atom_node, Atom) 

699 if not atom: 

700 return None 

701 

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)) 

706 

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)) 

716 

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)) 

721 

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)) 

730 

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)) 

735 

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)) 

740 

741 arg2 = self.graph.value(atom_node, SWRL_NS.argument2) 

742 if arg2: 

743 atom.set_has_arguments(self._resolve_swrl_arg(arg2)) 

744 

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}") 

753 

754 return atom 

755 

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) 

762 

763 # ========== PROVENANCE SUBGRAPH ========== 

764 

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 

775 

776 sub = Graph() 

777 for prefix, ns in self.graph.namespaces(): 

778 sub.bind(prefix, ns, override=True, replace=True) 

779 

780 # 1. direct triples 

781 source_triples = self._triples_map.get(instance, set()) 

782 for t in source_triples: 

783 sub.add(t) 

784 

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) 

790 

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) 

801 

802 # 4. strategy-specific axioms (hook) 

803 self._add_axiom_provenance(instance, sub) 

804 

805 return sub 

806 

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) 

820 

821 def _add_axiom_provenance(self, instance, sub): 

822 """Hook for subclasses. Default: no strategy-specific axioms.""" 

823 pass 

824