framework-agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,1614 @@
1
+ """Semantic graph layer. Parity: packages/typescript/src/semantic/index.ts +
2
+ entity-linking.ts + ontology-evolution.ts (merge into one module; ontology
3
+ review lives in ontology_review.py, remote SPARQL in remote_sparql.py).
4
+
5
+ In-memory store: RDFS-subset + OWL property axioms entailment with proofs,
6
+ SHACL-subset validation, versioned CONSTRUCT-style rules, SPARQL-subset SELECT
7
+ tools, entity linking + JSON-LD I/O, TBox evolution, and GraphMemory.
8
+
9
+ Pinned semantics (deviations from TS are deliberate, documented, and tested):
10
+
11
+ - Quad identity includes the named graph: the same s/p/o in two graphs are TWO
12
+ quads.
13
+ - add() validates provenance BEFORE writing: a missing source is a violation,
14
+ never a fact. Functional-property conflicts and SHACL violations are ERRORS
15
+ with rollback — a conflict is never committed as a second fact.
16
+ - Literals are written in the COMPACT Turtle form ("x"^^xsd:date, "x"@en) — the
17
+ TS store code uses single-quoted-less compact form with NO angle brackets;
18
+ the term parsers accept both compact and bracketed datatypes.
19
+ - TBox migration writes the new version's axioms to the version-named graph
20
+ ``urn:framework:ontology:<to>`` (TS uses a personal-name prefix
21
+ a personal-name prefix — the Python port uses version-graph naming WITHOUT
22
+ any personal name).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import json
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Iterable, Literal, Mapping, Protocol, runtime_checkable
32
+
33
+ from pydantic import BaseModel, ConfigDict, Field
34
+
35
+ from .memory import Memory
36
+ from .errors import AgentError
37
+ from .tools import Tool, ToolContext, ToolResult, define_tool
38
+ from .types import ChatMessage, assistant
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Terms, quads, literals
43
+ # ---------------------------------------------------------------------------
44
+
45
+ RDF_TYPE = {"rdf:type", "a", "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}
46
+ SUB_CLASS = {"rdfs:subClassOf", "http://www.w3.org/2000/01/rdf-schema#subClassOf"}
47
+ SUB_PROP = {"rdfs:subPropertyOf", "http://www.w3.org/2000/01/rdf-schema#subPropertyOf"}
48
+ DOMAIN = {"rdfs:domain", "http://www.w3.org/2000/01/rdf-schema#domain"}
49
+ RANGE = {"rdfs:range", "http://www.w3.org/2000/01/rdf-schema#range"}
50
+ OWL_TRANSITIVE = {"owl:TransitiveProperty", "http://www.w3.org/2002/07/owl#TransitiveProperty"}
51
+ OWL_SYMMETRIC = {"owl:SymmetricProperty", "http://www.w3.org/2002/07/owl#SymmetricProperty"}
52
+ OWL_FUNCTIONAL = {"owl:FunctionalProperty", "http://www.w3.org/2002/07/owl#FunctionalProperty"}
53
+ OWL_INVERSE = {"owl:inverseOf", "http://www.w3.org/2002/07/owl#inverseOf"}
54
+
55
+
56
+ @dataclass
57
+ class Quad:
58
+ """One statement. Identity is subject|predicate|object|graph."""
59
+
60
+ subject: str
61
+ predicate: str
62
+ object: str
63
+ graph: str | None = None
64
+ #: Provenance: who vouched / source URI. Required on explicit writes.
65
+ source: str | None = None
66
+ derived_from: str | None = None
67
+ rule: str | None = None
68
+
69
+
70
+ @dataclass
71
+ class Violation:
72
+ message: str
73
+ node: str | None = None
74
+ path: str | None = None
75
+
76
+
77
+ @dataclass
78
+ class ShapeReport:
79
+ valid: bool
80
+ violations: list[Violation] = field(default_factory=list)
81
+
82
+
83
+ @dataclass
84
+ class Derivation:
85
+ """Proof chain for an entailed fact: premises + rule id."""
86
+
87
+ premises: list[Quad]
88
+ rule: str
89
+
90
+
91
+ _LITERAL_TERM_RE = re.compile(r"^(-?\d+(\.\d+)?|true|false)$")
92
+ _TYPED_RE = re.compile(r"\^\^<?([\w:/#.-]+)>?$", re.ASCII)
93
+ _LANG_RE = re.compile(r'^".*"@[\w-]+$', re.ASCII)
94
+ _PLAIN_RE = re.compile(r'^".*"$')
95
+ _INT_RE = re.compile(r"^-?\d+$")
96
+ _DECIMAL_RE = re.compile(r"^-?\d*\.\d+$")
97
+ _BOOL_RE = re.compile(r"^(true|false)$")
98
+ _PREFIXED_RE = re.compile(r"^[a-zA-Z][\w-]*:", re.ASCII)
99
+
100
+
101
+ def is_literal(term: str) -> bool:
102
+ """A term is a literal when quoted, numeric, boolean, or un-IRI-able.
103
+
104
+ Mirrors the TS isLiteral: <iris> and prefixed names are IRIs; everything
105
+ else (an un-resolvable bare word) is treated as a literal.
106
+ """
107
+ if term.startswith('"'):
108
+ return True
109
+ if _LITERAL_TERM_RE.match(term):
110
+ return True
111
+ if term.startswith("<") or _PREFIXED_RE.match(term):
112
+ return False
113
+ return True
114
+
115
+
116
+ def term_datatype(term: str) -> str | None:
117
+ """Datatype of a literal term (typed / langString / string / integer /
118
+ decimal / boolean). Accepts both ``"x"^^xsd:date`` and ``"x"^^<xsd:date>``."""
119
+ m = _TYPED_RE.search(term)
120
+ if m:
121
+ return m.group(1)
122
+ if _LANG_RE.match(term):
123
+ return "rdf:langString"
124
+ if _PLAIN_RE.match(term):
125
+ return "xsd:string"
126
+ if _INT_RE.match(term):
127
+ return "xsd:integer"
128
+ if _DECIMAL_RE.match(term):
129
+ return "xsd:decimal"
130
+ if _BOOL_RE.match(term):
131
+ return "xsd:boolean"
132
+ return None
133
+
134
+
135
+ @runtime_checkable
136
+ class GraphStore(Protocol):
137
+ """Minimal quad-store contract — in-memory first, remote SPARQL later."""
138
+
139
+ async def add(self, quads: list[Quad]) -> None: ...
140
+ async def match(
141
+ self,
142
+ s: str | None = None,
143
+ p: str | None = None,
144
+ o: str | None = None,
145
+ g: str | None = None,
146
+ ) -> list[Quad]: ...
147
+ async def validate(self, quads: list[Quad]) -> ShapeReport: ...
148
+ async def why(self, quad: Quad) -> Derivation | None: ...
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # SHACL-subset shapes + versioned CONSTRUCT-style rules
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ @dataclass
157
+ class PropertyShape:
158
+ path: str
159
+ min_count: int | None = None
160
+ max_count: int | None = None
161
+ datatype: str | None = None
162
+ node_kind: Literal["iri", "literal"] | None = None
163
+ has_value: str | None = None
164
+
165
+
166
+ @dataclass
167
+ class NodeShape:
168
+ target_class: str
169
+ properties: list[PropertyShape] = field(default_factory=list)
170
+
171
+
172
+ @dataclass
173
+ class TriplePattern:
174
+ subject: str
175
+ predicate: str
176
+ object: str
177
+
178
+
179
+ @dataclass
180
+ class GraphRule:
181
+ """Versioned CONSTRUCT-style rule over triple patterns (?vars)."""
182
+
183
+ id: str
184
+ where: list[TriplePattern] = field(default_factory=list)
185
+ construct: list[TriplePattern] = field(default_factory=list)
186
+ version: str | None = None
187
+
188
+
189
+ @dataclass
190
+ class StoreOptions:
191
+ shapes: list[NodeShape] = field(default_factory=list)
192
+ rules: list[GraphRule] = field(default_factory=list)
193
+
194
+
195
+ class InMemoryGraphStore:
196
+ """Closure-entailing store: RDFS subset + OWL axioms + versioned rules."""
197
+
198
+ def __init__(
199
+ self,
200
+ opts: StoreOptions | None = None,
201
+ *,
202
+ shapes: list[NodeShape] | None = None,
203
+ rules: list[GraphRule] | None = None,
204
+ ) -> None:
205
+ if opts is not None:
206
+ shapes = shapes if shapes is not None else opts.shapes
207
+ rules = rules if rules is not None else opts.rules
208
+ self.shapes: list[NodeShape] = list(shapes or [])
209
+ self.rules: list[GraphRule] = list(rules or [])
210
+ self.quads: list[Quad] = []
211
+ self.derivations: dict[str, Derivation] = {}
212
+
213
+ def add_shapes(self, *shapes: NodeShape) -> "InMemoryGraphStore":
214
+ self.shapes.extend(shapes)
215
+ return self
216
+
217
+ def add_rules(self, *rules: GraphRule) -> "InMemoryGraphStore":
218
+ self.rules.extend(rules)
219
+ return self
220
+
221
+ def ontology_shapes(self) -> list[NodeShape]:
222
+ """Registered SHACL shapes (carried to new stores by TBox migrations)."""
223
+ return list(self.shapes)
224
+
225
+ def ontology_rules(self) -> list[GraphRule]:
226
+ """Registered CONSTRUCT rules (versioned with the ontology they encode)."""
227
+ return list(self.rules)
228
+
229
+ def _key(self, q: Quad) -> str:
230
+ return f"{q.subject}|{q.predicate}|{q.object}|{q.graph or ''}"
231
+
232
+ async def add(self, quads: list[Quad]) -> None:
233
+ report = await self.validate(quads)
234
+ if not report.valid:
235
+ raise ValueError(f"SHACL violation: {'; '.join(v.message for v in report.violations)}")
236
+ known = {self._key(q) for q in self.quads}
237
+ added_keys: list[str] = []
238
+ for q in quads:
239
+ k = self._key(q)
240
+ if k not in known:
241
+ known.add(k)
242
+ added_keys.append(k)
243
+ self.quads.append(q)
244
+
245
+ def rollback() -> None:
246
+ doomed = set(added_keys)
247
+ self.quads = [q for q in self.quads if self._key(q) not in doomed]
248
+ for k in doomed:
249
+ self.derivations.pop(k, None)
250
+
251
+ conflicts = self._check_functional()
252
+ if conflicts:
253
+ rollback()
254
+
255
+ raise ValueError(f"Functional property violation: {'; '.join(conflicts)}")
256
+ shape_report = self.check_shapes(self.quads)
257
+ if not shape_report.valid:
258
+ rollback()
259
+
260
+ raise ValueError(f"SHACL violation: {'; '.join(v.message for v in shape_report.violations)}")
261
+ self._entail_to_fixpoint(known)
262
+
263
+ async def match(
264
+ self,
265
+ s: str | None = None,
266
+ p: str | None = None,
267
+ o: str | None = None,
268
+ g: str | None = None,
269
+ ) -> list[Quad]:
270
+ """Match by pattern; None = wildcard (including the graph slot).
271
+
272
+ Queries the entailment closure. A ``g`` value selects ONE named graph
273
+ — the same s/p/o CAN live in several graphs; identity includes it.
274
+ """
275
+
276
+ def keep(q: Quad) -> bool:
277
+ return (
278
+ (not s or q.subject == s)
279
+ and (not p or q.predicate == p)
280
+ and (not o or q.object == o)
281
+ and (not g or q.graph == g)
282
+ )
283
+
284
+ return [q for q in self.quads if keep(q)]
285
+
286
+ async def validate(self, quads: list[Quad]) -> ShapeReport:
287
+ """Structural + provenance check: incomplete triples and source-less
288
+ writes are violations — a fact without provenance never enters."""
289
+ violations = [
290
+ Violation(message=f"Incomplete triple: {json.dumps(_quad_json(q))}")
291
+ for q in quads
292
+ if not q.subject or not q.predicate or not q.object
293
+ ]
294
+ no_source = [
295
+ Violation(message=f"Missing provenance (source) on {q.subject} {q.predicate} {q.object}")
296
+ for q in quads
297
+ if not q.source
298
+ ]
299
+ return ShapeReport(valid=not (violations or no_source), violations=[*violations, *no_source])
300
+
301
+ async def why(self, quad: Quad) -> Derivation | None:
302
+ """Proof chain for an entailed fact: premises + rule id. None for explicit facts."""
303
+ return self.derivations.get(self._key(quad))
304
+
305
+ def _check_functional(self) -> list[str]:
306
+ """Functional properties: at most one value per subject (a conflict is
307
+ an ERROR rolled back, never a second fact)."""
308
+ functional_props = {
309
+ q.subject for q in self.quads if q.predicate in RDF_TYPE and q.object in OWL_FUNCTIONAL
310
+ }
311
+ conflicts: list[str] = []
312
+ for prop in functional_props:
313
+ by_subject: dict[str, set[str]] = {}
314
+ for q in self.quads:
315
+ if q.predicate != prop:
316
+ continue
317
+ by_subject.setdefault(q.subject, set()).add(q.object)
318
+ for subject, values in by_subject.items():
319
+ if len(values) > 1:
320
+ conflicts.append(f"{subject} has {len(values)} values for functional {prop}")
321
+ return conflicts
322
+
323
+ def check_shapes(self, quads: list[Quad]) -> ShapeReport:
324
+ """SHACL-subset validation over a quad set (pure — no entailment)."""
325
+ violations: list[Violation] = []
326
+ for shape in self.shapes:
327
+ targets = [q.subject for q in quads if q.predicate in RDF_TYPE and q.object == shape.target_class]
328
+ for node in dict.fromkeys(targets):
329
+ for prop in shape.properties:
330
+ values = [q.object for q in quads if q.subject == node and q.predicate == prop.path]
331
+ if prop.min_count is not None and len(values) < prop.min_count:
332
+ violations.append(
333
+ Violation(f"{node} needs >= {prop.min_count} {prop.path}", node, prop.path)
334
+ )
335
+ if prop.max_count is not None and len(values) > prop.max_count:
336
+ violations.append(
337
+ Violation(f"{node} allows <= {prop.max_count} {prop.path}", node, prop.path)
338
+ )
339
+ for v in values:
340
+ if prop.datatype and term_datatype(v) != prop.datatype and not (
341
+ prop.datatype == "xsd:string" and is_literal(v)
342
+ ):
343
+ violations.append(
344
+ Violation(f"{node} {prop.path}={v} is not {prop.datatype}", node, prop.path)
345
+ )
346
+ if prop.node_kind == "iri" and is_literal(v):
347
+ violations.append(
348
+ Violation(f"{node} {prop.path}={v} must be an IRI", node, prop.path)
349
+ )
350
+ if prop.node_kind == "literal" and not is_literal(v):
351
+ violations.append(
352
+ Violation(f"{node} {prop.path}={v} must be a literal", node, prop.path)
353
+ )
354
+ if prop.has_value is not None and v != prop.has_value:
355
+ violations.append(
356
+ Violation(f"{node} {prop.path} must be {prop.has_value}", node, prop.path)
357
+ )
358
+ return ShapeReport(valid=not violations, violations=violations)
359
+
360
+ def _insert(self, q: Quad, premises: list[Quad], rule: str, known: set[str]) -> bool:
361
+ k = self._key(q)
362
+ if k in known:
363
+ return False
364
+ known.add(k)
365
+ self.quads.append(q)
366
+ self.derivations[k] = Derivation(premises=list(premises), rule=rule)
367
+ return True
368
+
369
+ def _entail_to_fixpoint(self, known: set[str]) -> None:
370
+ """RDFS subset + OWL property axioms + versioned rules, to fixpoint:
371
+
372
+ - RDFS-SUB-TRANS / RDFS-TYPE / RDFS-SUBPROP-TRANS / RDFS-SUBPROP /
373
+ RDFS-DOMAIN / RDFS-RANGE
374
+ - OWL-TRANSITIVE / OWL-SYMMETRIC / OWL-INVERSE (inverseOf both directions)
375
+ - ``<ruleId>@<version>`` for each applied GraphRule (premises -> why())
376
+ """
377
+ while True:
378
+ added = False
379
+ snapshot = list(self.quads)
380
+
381
+ def by_pred(ps: set[str]) -> list[Quad]:
382
+ return [q for q in snapshot if q.predicate in ps]
383
+
384
+ def insert(q: Quad, premises: list[Quad], rule: str) -> bool:
385
+ nonlocal added
386
+ one = self._insert(q, premises, rule, known)
387
+ added = one or added
388
+ return one
389
+
390
+ for ab in by_pred(SUB_CLASS):
391
+ for bc in [q for q in by_pred(SUB_CLASS) if q.subject == ab.object]:
392
+ added = (
393
+ self._insert(
394
+ Quad(ab.subject, ab.predicate, bc.object, source="entailment", rule="RDFS-SUB-TRANS"),
395
+ [ab, bc],
396
+ "RDFS-SUB-TRANS",
397
+ known,
398
+ )
399
+ or added
400
+ )
401
+ for t in by_pred(RDF_TYPE):
402
+ for sc in [q for q in by_pred(SUB_CLASS) if q.subject == t.object]:
403
+ added = (
404
+ self._insert(
405
+ Quad(t.subject, t.predicate, sc.object, source="entailment", rule="RDFS-TYPE"),
406
+ [t, sc],
407
+ "RDFS-TYPE",
408
+ known,
409
+ )
410
+ or added
411
+ )
412
+ for ab in by_pred(SUB_PROP):
413
+ for bc in [q for q in by_pred(SUB_PROP) if q.subject == ab.object]:
414
+ added = (
415
+ self._insert(
416
+ Quad(
417
+ ab.subject, ab.predicate, bc.object, source="entailment", rule="RDFS-SUBPROP-TRANS"
418
+ ),
419
+ [ab, bc],
420
+ "RDFS-SUBPROP-TRANS",
421
+ known,
422
+ )
423
+ or added
424
+ )
425
+ for use in [q for q in snapshot if q.predicate == ab.subject]:
426
+ added = (
427
+ self._insert(
428
+ Quad(ab.subject, ab.object, use.object, source="entailment", rule="RDFS-SUBPROP"),
429
+ [ab, use],
430
+ "RDFS-SUBPROP",
431
+ known,
432
+ )
433
+ or added
434
+ )
435
+ for d in by_pred(DOMAIN):
436
+ for use in [q for q in snapshot if q.predicate == d.subject]:
437
+ added = (
438
+ self._insert(
439
+ Quad(use.subject, "rdf:type", d.object, source="entailment", rule="RDFS-DOMAIN"),
440
+ [d, use],
441
+ "RDFS-DOMAIN",
442
+ known,
443
+ )
444
+ or added
445
+ )
446
+ for r in by_pred(RANGE):
447
+ for use in [q for q in snapshot if q.predicate == r.subject]:
448
+ added = (
449
+ self._insert(
450
+ Quad(use.object, "rdf:type", r.object, source="entailment", rule="RDFS-RANGE"),
451
+ [r, use],
452
+ "RDFS-RANGE",
453
+ known,
454
+ )
455
+ or added
456
+ )
457
+ added = self._apply_owl(snapshot, insert) or added
458
+ added = self._apply_rules(snapshot, insert) or added
459
+ if not added:
460
+ return
461
+
462
+ def _apply_owl(self, snapshot: list[Quad], insert: Any) -> bool:
463
+ added = False
464
+ transitive = {
465
+ q.subject for q in snapshot if q.predicate in RDF_TYPE and q.object in OWL_TRANSITIVE
466
+ }
467
+ symmetric = {q.subject for q in snapshot if q.predicate in RDF_TYPE and q.object in OWL_SYMMETRIC}
468
+ for p in transitive:
469
+ uses = [q for q in snapshot if q.predicate == p]
470
+ for xy in uses:
471
+ for yz in [q for q in uses if q.subject == xy.object]:
472
+ if insert(
473
+ Quad(xy.subject, p, yz.object, source="entailment", rule="OWL-TRANSITIVE"),
474
+ [xy, yz],
475
+ "OWL-TRANSITIVE",
476
+ ):
477
+ added = True
478
+ for p in symmetric:
479
+ for xy in [q for q in snapshot if q.predicate == p]:
480
+ if insert(
481
+ Quad(xy.object, p, xy.subject, source="entailment", rule="OWL-SYMMETRIC"),
482
+ [xy],
483
+ "OWL-SYMMETRIC",
484
+ ):
485
+ added = True
486
+ for inv in [q for q in snapshot if q.predicate in OWL_INVERSE]:
487
+ for use in [q for q in snapshot if q.predicate == inv.subject or q.predicate == inv.object]:
488
+ flipped = inv.object if use.predicate == inv.subject else inv.subject
489
+ if insert(
490
+ Quad(use.object, flipped, use.subject, source="entailment", rule="OWL-INVERSE"),
491
+ [inv, use],
492
+ "OWL-INVERSE",
493
+ ):
494
+ added = True
495
+ return added
496
+
497
+ def _apply_rules(self, snapshot: list[Quad], insert: Any) -> bool:
498
+ added = False
499
+ for rule in self.rules:
500
+ tag = f"{rule.id}@{rule.version}" if rule.version else rule.id
501
+ for binding in match_patterns(rule.where, snapshot):
502
+ for pattern in rule.construct:
503
+ built = TriplePattern(
504
+ instantiate(pattern.subject, binding.vars),
505
+ instantiate(pattern.predicate, binding.vars),
506
+ instantiate(pattern.object, binding.vars),
507
+ )
508
+ if not built.subject or not built.predicate or not built.object:
509
+ continue
510
+ if insert(
511
+ Quad(
512
+ built.subject,
513
+ built.predicate,
514
+ built.object,
515
+ source="entailment",
516
+ rule=tag,
517
+ derived_from=tag,
518
+ ),
519
+ binding.quads,
520
+ tag,
521
+ ):
522
+ added = True
523
+ return added
524
+
525
+
526
+ def _quad_json(q: Quad) -> dict[str, Any]:
527
+ out = dataclasses.asdict(q)
528
+ return {k: v for k, v in out.items() if v is not None}
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # Pattern matching + SPARQL-subset (SELECT with conjunctive triple patterns)
533
+ # ---------------------------------------------------------------------------
534
+
535
+
536
+ @dataclass
537
+ class PatternBinding:
538
+ vars: dict[str, str]
539
+ quads: list[Quad]
540
+
541
+
542
+ def _is_var(term: str) -> bool:
543
+ return term.startswith("?")
544
+
545
+
546
+ def _match_term(pattern: str, value: str, vars: dict[str, str]) -> dict[str, str] | None:
547
+ if _is_var(pattern):
548
+ bound = vars.get(pattern)
549
+ if bound is None:
550
+ nxt = dict(vars)
551
+ nxt[pattern] = value
552
+ return nxt
553
+ return vars if bound == value else None
554
+ return vars if pattern == value else None
555
+
556
+
557
+ def _match_one(pattern: TriplePattern, quad: Quad, vars: dict[str, str]) -> dict[str, str] | None:
558
+ v = _match_term(pattern.subject, quad.subject, vars)
559
+ if v is None:
560
+ return None
561
+ v = _match_term(pattern.predicate, quad.predicate, v)
562
+ if v is None:
563
+ return None
564
+ return _match_term(pattern.object, quad.object, v)
565
+
566
+
567
+ def match_patterns(patterns: list[TriplePattern], quads: list[Quad]) -> list[PatternBinding]:
568
+ """Conjunctive matching of patterns against quads (nested-loop join)."""
569
+ bindings: list[PatternBinding] = [PatternBinding(vars={}, quads=[])]
570
+ for pattern in patterns:
571
+ nxt: list[PatternBinding] = []
572
+ for b in bindings:
573
+ for q in quads:
574
+ vars = _match_one(pattern, q, b.vars)
575
+ if vars is not None:
576
+ nxt.append(PatternBinding(vars=vars, quads=[*b.quads, q]))
577
+ bindings = nxt
578
+ if not bindings:
579
+ break
580
+ return bindings
581
+
582
+
583
+ def instantiate(term: str, vars: dict[str, str]) -> str:
584
+ return vars.get(term, term) if _is_var(term) else term
585
+
586
+
587
+ @dataclass
588
+ class SparqlSelect:
589
+ variables: list[str]
590
+ patterns: list[TriplePattern]
591
+
592
+
593
+ _TOKEN_RE = re.compile(r'"[^"]*"(?:\^\^<?[\w:/#.-]+>?|@[\w-]+)?|<[^>]*>|[{}().;]|\S+', re.ASCII)
594
+
595
+
596
+ def _tokenize_sparql(text: str) -> list[str]:
597
+ return [m.group(0) for m in _TOKEN_RE.finditer(text)]
598
+
599
+
600
+ def parse_select(query: str) -> SparqlSelect:
601
+ """Minimal SELECT parser: ``SELECT ?a ?b WHERE { s p o . ... }``.
602
+
603
+ Terms: ?vars, <iris>, prefixed names, "literals" (^^datatype/@lang ok).
604
+ Anything else raises ValueError — this is a documented subset, not SPARQL.
605
+ """
606
+ tokens = _tokenize_sparql(query)
607
+ i = 0
608
+
609
+ def expect(want: str) -> None:
610
+ nonlocal i
611
+ got = tokens[i] if i < len(tokens) else "end"
612
+ if got.upper() != want:
613
+ raise ValueError(f"SPARQL: expected {want}, got {got}")
614
+ i += 1
615
+
616
+ expect("SELECT")
617
+ variables: list[str] = []
618
+ while i < len(tokens) and tokens[i].startswith("?"):
619
+ variables.append(tokens[i])
620
+ i += 1
621
+ if not variables:
622
+ raise ValueError("SPARQL: SELECT needs at least one variable")
623
+ expect("WHERE")
624
+ expect("{")
625
+ patterns: list[TriplePattern] = []
626
+ while i < len(tokens) and tokens[i] != "}":
627
+ s = tokens[i]
628
+ i += 1
629
+ p = tokens[i] if i < len(tokens) else None
630
+ i += 1
631
+ o = tokens[i] if i < len(tokens) else None
632
+ i += 1
633
+ if s is None or p is None or o is None or s == "." or p == "." or o == ".":
634
+ raise ValueError("SPARQL: malformed triple pattern")
635
+ patterns.append(TriplePattern(s, p, o))
636
+ if i < len(tokens) and tokens[i] == ".":
637
+ i += 1
638
+ expect("}")
639
+ return SparqlSelect(variables=variables, patterns=patterns)
640
+
641
+
642
+ async def execute_select(store: GraphStore, query: str) -> list[dict[str, str]]:
643
+ """Execute a SELECT against the store closure. Returns variable bindings."""
644
+ parsed = parse_select(query)
645
+ quads = await store.match()
646
+ return [
647
+ {v: b.vars.get(v, "") for v in parsed.variables}
648
+ for b in match_patterns(parsed.patterns, quads)
649
+ ]
650
+
651
+
652
+ # ---------------------------------------------------------------------------
653
+ # Agent tools: read, write (autonomous, validated), verify
654
+ # ---------------------------------------------------------------------------
655
+
656
+
657
+ class _SparqlSelectArgs(BaseModel):
658
+ query: str = Field(min_length=1, max_length=4000)
659
+
660
+
661
+ def sparql_select_tool(store: GraphStore) -> Tool:
662
+ """Read-only SPARQL SELECT over the entailment closure."""
663
+
664
+ async def execute(args: _SparqlSelectArgs, ctx: ToolContext | None):
665
+ # Same contract as graph_add: query errors are repair feedback (the
666
+ # model fixes the query), not a thrown tool error that kills the run.
667
+ try:
668
+ return await execute_select(store, args.query)
669
+ except AgentError:
670
+ raise
671
+ except Exception as err: # noqa: BLE001 - documented feedback contract
672
+ return f"Query error — fix the syntax and retry: {err}"
673
+
674
+ return define_tool(
675
+ name="sparql_select",
676
+ description="Query the knowledge graph. Subset: SELECT ?vars WHERE { s p o . ... } over the inferred closure.",
677
+ schema=_SparqlSelectArgs,
678
+ execute=execute,
679
+ )
680
+
681
+
682
+ class _OntologyLookupArgs(BaseModel):
683
+ term: str = Field(min_length=1, max_length=200)
684
+ limit: int = Field(default=8, ge=1, le=20)
685
+
686
+
687
+ def ontology_lookup_tool(store: GraphStore) -> Tool:
688
+ """Ontology lookup: find classes/properties/labels matching a term."""
689
+
690
+ async def execute(args: _OntologyLookupArgs, ctx: ToolContext | None) -> list[dict[str, str]]:
691
+ needle = args.term.lower()
692
+ quads = await store.match()
693
+ return [
694
+ {"subject": q.subject, "predicate": q.predicate, "object": q.object}
695
+ for q in quads
696
+ if needle in f"{q.subject} {q.predicate} {q.object}".lower()
697
+ ][: args.limit]
698
+
699
+ return define_tool(
700
+ name="ontology_lookup",
701
+ description="Look up ontology terms (classes, properties, labels) instead of inventing predicates.",
702
+ schema=_OntologyLookupArgs,
703
+ execute=execute,
704
+ )
705
+
706
+
707
+ class _TurtleQuadModel(BaseModel):
708
+ subject: str = Field(min_length=1)
709
+ predicate: str = Field(min_length=1)
710
+ object: str = Field(min_length=1)
711
+ source: str = Field(min_length=1, description="Provenance URI (required — writes without it are rejected)")
712
+
713
+
714
+ class _GraphAddArgs(BaseModel):
715
+ quads: list[_TurtleQuadModel] = Field(min_length=1, max_length=50)
716
+
717
+
718
+ async def _graph_add_to_result(output: Any) -> ToolResult:
719
+ """Validation failures are model-visible repair feedback, not hidden artifacts."""
720
+ if isinstance(output, str):
721
+ return ToolResult(content=output)
722
+ return ToolResult(content=json.dumps(output), artifact={"committed": output["committed"]})
723
+
724
+
725
+ def graph_add_tool(store: GraphStore) -> Tool:
726
+ """Autonomous validated write: SHACL + provenance + functional checks run
727
+ inside the store. Violations return as tool results (not raises) so the
728
+ model repairs and retries on its own — autonomy without anarchy."""
729
+
730
+ async def execute(args: _GraphAddArgs, ctx: ToolContext | None) -> Any:
731
+ try:
732
+ await store.add([Quad(q.subject, q.predicate, q.object, source=q.source) for q in args.quads])
733
+ inferred = [q for q in await store.match() if q.source == "entailment"]
734
+ return {"committed": len(args.quads), "inferred": len(inferred)}
735
+ except Exception as err: # noqa: BLE001 - feedback contract
736
+ return f"Validation failed — fix and retry: {err}"
737
+
738
+ return define_tool(
739
+ name="graph_add",
740
+ description="Write facts to the knowledge graph. Validated (SHACL/provenance/functional); violations return repair feedback.",
741
+ schema=_GraphAddArgs,
742
+ execute=execute,
743
+ to_result=_graph_add_to_result,
744
+ )
745
+
746
+
747
+ ClaimVerdict = Literal["grounded", "unknown", "contradicted"]
748
+
749
+
750
+ class _ClaimCheckArgs(BaseModel):
751
+ subject: str = Field(min_length=1)
752
+ predicate: str = Field(min_length=1)
753
+ object: str = Field(min_length=1)
754
+
755
+
756
+ def claim_check_tool(store: GraphStore) -> Tool:
757
+ """Anti-hallucination primitive: is this triple in the closure (grounded),
758
+ absent (unknown — do not assert), or clashing with a functional value
759
+ (contradicted)? Ask before answering from memory."""
760
+
761
+ async def execute(args: _ClaimCheckArgs, ctx: ToolContext | None) -> dict[str, Any]:
762
+ direct = await store.match(args.subject, args.predicate, args.object)
763
+ if direct:
764
+ why = await store.why(direct[0])
765
+ return {
766
+ "verdict": "grounded",
767
+ "proof": None
768
+ if why is None
769
+ else {
770
+ "premises": [_quad_json(p) for p in why.premises],
771
+ "rule": why.rule,
772
+ },
773
+ }
774
+ siblings = await store.match(args.subject, args.predicate, None)
775
+ declarations = await store.match(args.predicate, None, None)
776
+ functional = any(q.predicate in RDF_TYPE and q.object in OWL_FUNCTIONAL for q in declarations)
777
+ clash = [q for q in siblings if q.object != args.object]
778
+ if functional and clash:
779
+ return {"verdict": "contradicted", "proof": {"existing": [q.object for q in clash]}}
780
+ return {
781
+ "verdict": "unknown",
782
+ "proof": {"related": [q.object for q in siblings]} if siblings else None,
783
+ }
784
+
785
+ return define_tool(
786
+ name="claim_check",
787
+ description="Verify a fact against the graph before asserting it. Returns grounded | unknown | contradicted with proof.",
788
+ schema=_ClaimCheckArgs,
789
+ execute=execute,
790
+ )
791
+
792
+
793
+ # ---------------------------------------------------------------------------
794
+ # GraphMemory: closure-aware session memory + long-term store
795
+ # ---------------------------------------------------------------------------
796
+
797
+
798
+ def snake_tokenize(text: str) -> list[str]:
799
+ """Local keyword tokenizer for the GraphMemory fact selector (TS skills tokenize)."""
800
+ return [w for w in re.split(r"[^a-z0-9]+", text.lower()) if len(w) > 1]
801
+
802
+
803
+ @dataclass
804
+ class GraphMemory:
805
+ """Session memory + long-term store in one:
806
+
807
+ - load(): recent messages + top-k closure facts relevant to the latest user
808
+ turn (keyword overlap), rendered as a knowledge message.
809
+ - save(): delegates to inner memory (extraction of triples from prose stays
810
+ model-driven via graph_add — never silent parsing).
811
+ """
812
+
813
+ store: GraphStore
814
+ inner: Memory
815
+ facts: int = 8
816
+
817
+ async def load(self) -> list[ChatMessage]:
818
+ messages = await self.inner.load()
819
+ last_user = next((m for m in reversed(messages) if m.role == "user"), None)
820
+ if last_user is None:
821
+ return messages
822
+ content = last_user.content if isinstance(last_user.content, str) else ""
823
+ terms = set(snake_tokenize(content))
824
+ if not terms:
825
+ return messages
826
+ quads = await self.store.match()
827
+ scored = [
828
+ (q, score)
829
+ for q, score in (
830
+ (q, sum(1 for t in terms if t in f"{q.subject} {q.predicate} {q.object}".lower()))
831
+ for q in quads
832
+ )
833
+ if score > 0
834
+ ]
835
+ if not scored:
836
+ return messages
837
+ scored.sort(key=lambda item: -item[1])
838
+ top = scored[: self.facts]
839
+ lines = "\n".join(f"- {q.subject} {q.predicate} {q.object}" for q, _ in top)
840
+ return [
841
+ *messages,
842
+ assistant(f"Relevant known facts (ground answers in these):\n{lines}"),
843
+ ]
844
+
845
+ async def save(self, messages: list[ChatMessage]) -> None:
846
+ await self.inner.save(messages)
847
+
848
+ async def clear(self) -> None:
849
+ await self.inner.clear()
850
+
851
+ async def export_state(self) -> dict[str, list[Quad]]:
852
+ """Serialize the closure for snapshots / long-term persistence."""
853
+ return {"quads": await self.store.match()}
854
+
855
+ async def import_state(self, state: Mapping[str, Any] | Any) -> None:
856
+ """Restore a previously exported closure (facts become explicit again)."""
857
+ raw = state.get("quads", []) if hasattr(state, "get") else []
858
+ fresh: list[Quad] = []
859
+ for q in raw:
860
+ qq = q if isinstance(q, Quad) else Quad(**q)
861
+ if qq.source != "entailment":
862
+ fresh.append(qq)
863
+ if fresh:
864
+ await self.store.add(fresh)
865
+
866
+
867
+ # ---------------------------------------------------------------------------
868
+ # Entity linking + sameAs canonicalization (docs/07 rule 2)
869
+ # ---------------------------------------------------------------------------
870
+
871
+ LABEL_PREDICATES = {
872
+ "rdfs:label",
873
+ "http://www.w3.org/2000/01/rdf-schema#label",
874
+ "skos:prefLabel",
875
+ "http://www.w3.org/2004/02/skos/core#prefLabel",
876
+ "skos:altLabel",
877
+ "http://www.w3.org/2004/02/skos/core#altLabel",
878
+ }
879
+ SAME_AS = {"owl:sameAs", "http://www.w3.org/2002/07/owl#sameAs"}
880
+
881
+ #: Prefixes to expand/compact IRIs for JSON-LD I/O (nullable values pass through).
882
+ JSONLD_PREFIXES: dict[str, str] = {
883
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
884
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
885
+ "owl": "http://www.w3.org/2002/07/owl#",
886
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
887
+ "skos": "http://www.w3.org/2004/02/skos/core#",
888
+ "dct": "http://purl.org/dc/terms/",
889
+ "prov": "http://www.w3.org/ns/prov#",
890
+ "schema": "https://schema.org/",
891
+ "ex": "urn:example:",
892
+ }
893
+
894
+
895
+ @dataclass
896
+ class LinkedEntity:
897
+ #: Canonical (sameAs-group representative) IRI, in the store's term form.
898
+ iri: str
899
+ label: str | None = None
900
+ score: float = 0
901
+ #: Other IRIs the same entity is known as (the sameAs group).
902
+ same_as_group: list[str] = field(default_factory=list)
903
+
904
+
905
+ def strip_literal(term: str) -> str:
906
+ m = re.match(r'^"(.*)"(?:\^\^<?[\w:/#.-]+>?|@[\w-]+)?$', term, re.ASCII)
907
+ return m.group(1) if m else term
908
+
909
+
910
+ def _local_name(iri: str) -> str:
911
+ tail = re.split(r"[#/:]", iri)[-1] if iri else ""
912
+ return tail.lower()
913
+
914
+
915
+ class EntityLinker:
916
+ """Mention -> graph IRIs over the store closure, canonicalized through
917
+ owl:sameAs groups — a mention linked through any alias resolves to ONE
918
+ representative IRI."""
919
+
920
+ def __init__(self, store: GraphStore, label_predicates: Iterable[str] | None = None) -> None:
921
+ self.store = store
922
+ self.label_predicates = set(label_predicates) if label_predicates is not None else set(LABEL_PREDICATES)
923
+
924
+ async def _same_as_groups(self) -> dict[str, str]:
925
+ """Union-find over owl:sameAs pairs: every alias cluster -> one representative."""
926
+ pairs = [q for q in await self.store.match() if q.predicate in SAME_AS]
927
+ parent: dict[str, str] = {}
928
+
929
+ def find(x: str) -> str:
930
+ root = x
931
+ while root in parent and parent[root] != root:
932
+ root = parent[root]
933
+ return root
934
+
935
+ def union(a: str, b: str) -> None:
936
+ ra, rb = find(a), find(b)
937
+ if ra != rb:
938
+ parent[ra] = rb
939
+
940
+ for q in pairs:
941
+ parent.setdefault(q.subject, q.subject)
942
+ parent.setdefault(q.object, q.object)
943
+ union(q.subject, q.object)
944
+ return {member: find(member) for member in list(parent.keys())}
945
+
946
+ async def link(self, mention: str, k: int = 5) -> list[LinkedEntity]:
947
+ """Mention -> candidate entities, best first, canonicalized via sameAs."""
948
+ needle = mention.strip().lower()
949
+ if not needle:
950
+ return []
951
+ quads = await self.store.match()
952
+ canonical = await self._same_as_groups()
953
+ labels: dict[str, str] = {}
954
+ label_score: dict[str, int] = {}
955
+
956
+ candidates: list[dict[str, Any]] = []
957
+ for q in quads:
958
+ local_part = _local_name(q.subject)
959
+ if "/" in q.subject or "#" in q.subject:
960
+ full_tail = re.split(r"[#/]", q.subject)[-1] or ""
961
+ else:
962
+ full_tail = local_part
963
+ if local_part == needle:
964
+ candidates.append({"iri": q.subject, "label": None, "score": 40})
965
+ elif local_part.startswith(needle):
966
+ candidates.append({"iri": q.subject, "label": None, "score": 25})
967
+ elif full_tail.lower() == needle:
968
+ candidates.append({"iri": q.subject, "label": None, "score": 40})
969
+
970
+ if q.predicate in self.label_predicates:
971
+ label = strip_literal(q.object)
972
+ low = label.lower()
973
+ trust = 2 if "altLabel" in q.predicate else (0 if "prefLabel" in q.predicate else 1)
974
+ if low == needle:
975
+ candidates.append({"iri": q.subject, "label": label, "score": 100})
976
+ elif low.startswith(needle):
977
+ candidates.append({"iri": q.subject, "label": label, "score": 60})
978
+ elif needle in low:
979
+ candidates.append({"iri": q.subject, "label": label, "score": 30})
980
+ prev = label_score.get(q.subject)
981
+ if prev is None or trust < prev:
982
+ labels[q.subject] = label
983
+ label_score[q.subject] = trust
984
+
985
+ merged: dict[str, LinkedEntity] = {}
986
+ for c in candidates:
987
+ rep = canonical.get(c["iri"], c["iri"])
988
+ existing = merged.get(rep)
989
+ if existing is not None:
990
+ existing.score = max(existing.score, c["score"])
991
+ if c["label"] and not existing.label:
992
+ existing.label = c["label"]
993
+ if c["iri"] not in existing.same_as_group:
994
+ existing.same_as_group.append(c["iri"])
995
+ else:
996
+ merged[rep] = LinkedEntity(
997
+ iri=rep,
998
+ label=c["label"] or labels.get(rep),
999
+ score=c["score"],
1000
+ same_as_group=[c["iri"], *([rep] if rep != c["iri"] else [])],
1001
+ )
1002
+ for group in merged.values():
1003
+ if group.label is None:
1004
+ group.label = labels.get(group.iri)
1005
+ if len(group.same_as_group) < 2:
1006
+ members = [m for m in canonical if canonical[m] == canonical.get(group.iri)]
1007
+ if len(members) > 1:
1008
+ group.same_as_group = members
1009
+ ranked = sorted(merged.values(), key=lambda e: -e.score)
1010
+ return ranked[:k]
1011
+
1012
+
1013
+ class _LinkEntityArgs(BaseModel):
1014
+ mention: str = Field(min_length=1, max_length=200)
1015
+ k: int = Field(default=5, ge=1, le=10)
1016
+
1017
+
1018
+ def link_entity_tool(linker: EntityLinker) -> Tool:
1019
+ """link_entity agent tool: mention -> canonical IRIs with scores."""
1020
+
1021
+ async def execute(args: _LinkEntityArgs, ctx: ToolContext | None) -> list[dict[str, Any]]:
1022
+ return [dataclasses.asdict(e) for e in await linker.link(args.mention, args.k)]
1023
+
1024
+ return define_tool(
1025
+ name="link_entity",
1026
+ description="Link a text mention to known graph entities (labels + sameAs canonicalized). Returns canonical IRIs with scores — never invent IRIs.",
1027
+ schema=_LinkEntityArgs,
1028
+ execute=execute,
1029
+ )
1030
+
1031
+
1032
+ # ---------------------------------------------------------------------------
1033
+ # Mapping proposals: LLM proposes; autonomy commits by default, review opts out
1034
+ # ---------------------------------------------------------------------------
1035
+
1036
+ MappingDecision = Literal["approved", "rejected"]
1037
+
1038
+
1039
+ @dataclass
1040
+ class MappingProposal:
1041
+ id: str
1042
+ #: The source-side term: usually a raw column header / mention string.
1043
+ from_: str
1044
+ #: The proposed target: a canonical ontology IRI.
1045
+ to: str
1046
+ #: Provenance for the eventual commit (required by GraphStore.validate).
1047
+ source: str
1048
+ status: str = "pending"
1049
+ #: True when autonomy committed it on proposal (no human turn).
1050
+ committed_by_autonomy: bool | None = None
1051
+ proposed_at: str = ""
1052
+
1053
+
1054
+ @dataclass
1055
+ class MappingProposals:
1056
+ """Autonomy-first mapping commits. Autonomy: proposals commit IMMEDIATELY on
1057
+ proposal (default ON — the agent's whole write path is autonomous). Set
1058
+ ``autonomy=False`` (or bind no store) to queue proposals for decide()."""
1059
+
1060
+ store: "GraphStore | None" = None
1061
+ autonomy: bool = True
1062
+
1063
+ def __post_init__(self) -> None:
1064
+ self.seq = 0 # per-instance sequence, like the TS private seq
1065
+ self.items: dict[str, MappingProposal] = {}
1066
+
1067
+ @property
1068
+ def is_autonomous(self) -> bool:
1069
+ """True when proposals commit themselves (autonomy on AND a store bound)."""
1070
+ return self.autonomy and self.store is not None
1071
+
1072
+ async def _commit(self, store: GraphStore, proposal: MappingProposal) -> None:
1073
+ # Promotion = a provenance-carrying alias the linker can see forever.
1074
+ await store.add(
1075
+ [
1076
+ Quad(
1077
+ proposal.to,
1078
+ "skos:altLabel",
1079
+ f'"{proposal.from_}"',
1080
+ source=proposal.source,
1081
+ derived_from=f"mapping:{proposal.id}",
1082
+ )
1083
+ ]
1084
+ )
1085
+
1086
+ async def propose(self, from_: str, to: str, source: str) -> MappingProposal:
1087
+ """Record an LLM-proposed mapping. With autonomy (default) + a bound
1088
+ store it commits right away, provenance required; with autonomy=False
1089
+ (or no store bound) it queues as pending until decide()."""
1090
+ existing = next(
1091
+ (
1092
+ p
1093
+ for p in self.items.values()
1094
+ if p.from_ == from_
1095
+ and p.to == to
1096
+ and (p.status == "pending" or (self.is_autonomous and p.status == "approved"))
1097
+ ),
1098
+ None,
1099
+ )
1100
+ if existing is not None:
1101
+ return existing
1102
+ self.seq += 1
1103
+ proposal = MappingProposal(
1104
+ id=f"map-{self.seq}",
1105
+ from_=from_,
1106
+ to=to,
1107
+ source=source,
1108
+ status="pending",
1109
+ )
1110
+ proposal.proposed_at = _now_iso()
1111
+ if self.is_autonomous and self.store is not None:
1112
+ await self._commit(self.store, proposal)
1113
+ proposal.status = "approved"
1114
+ proposal.committed_by_autonomy = True
1115
+ self.items[proposal.id] = proposal
1116
+ return proposal
1117
+
1118
+ def list(self, status: str | None = None) -> list[MappingProposal]:
1119
+ return [p for p in self.items.values() if status is None or p.status == status]
1120
+
1121
+ async def decide(self, store: GraphStore, id: str, decision: MappingDecision) -> MappingProposal | None:
1122
+ """Human decision. ``approved`` also commits the alias into the graph."""
1123
+ proposal = self.items.get(id)
1124
+ if proposal is None or proposal.status != "pending":
1125
+ return None
1126
+ proposal.status = decision
1127
+ if decision == "approved":
1128
+ await self._commit(store, proposal)
1129
+ return proposal
1130
+
1131
+
1132
+ class _ProposeMappingArgs(BaseModel):
1133
+ model_config = ConfigDict(populate_by_name=True)
1134
+
1135
+ from_: str = Field(min_length=1, max_length=200, alias="from")
1136
+ to: str = Field(min_length=1, max_length=500)
1137
+ source: str = Field(min_length=1, description="Provenance URI for the committed alias")
1138
+
1139
+
1140
+ def propose_mapping_tool(proposals: MappingProposals) -> Tool:
1141
+ """Autonomy mode commits the alias immediately (provenance required);
1142
+ review mode queues it for human approval. Validation failures are repair
1143
+ feedback, not a thrown tool error that would kill the run."""
1144
+
1145
+ async def execute(args: _ProposeMappingArgs, ctx: ToolContext | None) -> Any:
1146
+ try:
1147
+ p = await proposals.propose(args.from_, args.to, args.source)
1148
+ except Exception as err: # noqa: BLE001 - feedback contract
1149
+ return f"Commit rejected — fix and retry: {err}"
1150
+ if p.committed_by_autonomy:
1151
+ return {"id": p.id, "status": "approved", "committed": True}
1152
+ return {"id": p.id, "status": "pending", "note": "Review mode: awaits a human decide() before writing."}
1153
+
1154
+ return define_tool(
1155
+ name="propose_mapping",
1156
+ description="Map a raw term to a canonical ontology IRI. Autonomy mode commits the alias immediately (provenance required); review mode queues it for human approval.",
1157
+ schema=_ProposeMappingArgs,
1158
+ execute=execute,
1159
+ )
1160
+
1161
+
1162
+ # ---------------------------------------------------------------------------
1163
+ # JSON-LD expand/compact I/O
1164
+ # ---------------------------------------------------------------------------
1165
+
1166
+ _IRI_PREFIX_RE = re.compile(r"^([a-zA-Z][\w-]*):(.+)$", re.ASCII)
1167
+ _HTTP_RE = re.compile(r"^https?://")
1168
+
1169
+
1170
+ def _resolve_iri(term: str, prefixes: Mapping[str, str]) -> str | None:
1171
+ m = _IRI_PREFIX_RE.match(term)
1172
+ base = prefixes.get(m.group(1)) if m else None
1173
+ if m and base:
1174
+ return f"{base}{m.group(2)}"
1175
+ if _HTTP_RE.match(term):
1176
+ return term
1177
+ return None # literal or unknown prefix
1178
+
1179
+
1180
+ def _compact_iri(iri: str, prefixes: Mapping[str, str]) -> str:
1181
+ for prefix, base in prefixes.items():
1182
+ if iri.startswith(base):
1183
+ return f"{prefix}:{iri[len(base):]}"
1184
+ return iri
1185
+
1186
+
1187
+ def _term_to_json_ld(term: str, prefixes: Mapping[str, str]) -> dict[str, Any]:
1188
+ iri = _resolve_iri(term, prefixes)
1189
+ if iri:
1190
+ return {"@id": iri}
1191
+ lang = re.match(r'^"(.*)"@([\w-]+)$', term, re.ASCII)
1192
+ if lang:
1193
+ return {"@value": lang.group(1), "@language": lang.group(2)}
1194
+ typed = re.match(r'^"(.*)"\^\^<?([\w:/#.-]+)>?$', term, re.ASCII)
1195
+ if typed:
1196
+ return {"@value": typed.group(1), "@type": _resolve_iri(typed.group(2), prefixes) or typed.group(2)}
1197
+ plain = re.match(r'^"(.*)"$', term)
1198
+ if plain:
1199
+ return {"@value": plain.group(1)}
1200
+ return {"@value": term}
1201
+
1202
+
1203
+ def _json_ld_to_term(value: Any, prefixes: Mapping[str, str]) -> str | None:
1204
+ if not isinstance(value, dict):
1205
+ native = str(value)
1206
+ if _INT_RE.match(native) or _DECIMAL_RE.match(native) or native in ("true", "false"):
1207
+ return native
1208
+ return f'"{native}"'
1209
+ if value.get("@id"):
1210
+ return _compact_iri(str(value["@id"]), prefixes)
1211
+ if "@value" in value:
1212
+ if value.get("@language"):
1213
+ return f'"{value["@value"]}"@{value["@language"]}'
1214
+ if value.get("@type"):
1215
+ return f'"{value["@value"]}"^^{_compact_iri(str(value["@type"]), prefixes)}'
1216
+ return f'"{value["@value"]}"'
1217
+ return None
1218
+
1219
+
1220
+ def quads_to_json_ld(quads: list[Quad], prefixes: Mapping[str, str] | None = None) -> dict[str, Any]:
1221
+ """Quads -> expanded JSON-LD. Named graphs round-trip: quads carrying a
1222
+ ``graph`` become JSON-LD named-graph entries, graph-less quads land in the
1223
+ default graph. Provenance (source/rule) does not round-trip — JSON-LD
1224
+ carries the statements, the store keeps the evidence."""
1225
+ prefixes = dict(prefixes) if prefixes is not None else dict(JSONLD_PREFIXES)
1226
+ by_graph: dict[str, list[Quad]] = {}
1227
+ for q in quads:
1228
+ by_graph.setdefault(q.graph or "", []).append(q)
1229
+
1230
+ def build_nodes(group: list[Quad]) -> list[dict[str, Any]]:
1231
+ nodes: dict[str, dict[str, Any]] = {}
1232
+
1233
+ def node_of(id_: str) -> dict[str, Any]:
1234
+ if id_ not in nodes:
1235
+ nodes[id_] = {"@id": _resolve_iri(id_, prefixes) or id_}
1236
+ return nodes[id_]
1237
+
1238
+ for q in group:
1239
+ node = node_of(q.subject)
1240
+ if q.predicate in ("rdf:type", "a"):
1241
+ type_iri = _resolve_iri(q.object, prefixes) or q.object
1242
+ existing = node.get("@type")
1243
+ if isinstance(existing, list):
1244
+ if type_iri not in existing:
1245
+ node["@type"] = [*existing, type_iri]
1246
+ elif existing is not None:
1247
+ node["@type"] = [existing, type_iri]
1248
+ else:
1249
+ node["@type"] = type_iri
1250
+ continue
1251
+ key = _resolve_iri(q.predicate, prefixes) or q.predicate
1252
+ value = _term_to_json_ld(q.object, prefixes)
1253
+ existing = node.get(key)
1254
+ node[key] = (
1255
+ [*existing, value]
1256
+ if isinstance(existing, list)
1257
+ else ([existing, value] if existing is not None else value)
1258
+ )
1259
+ return list(nodes.values())
1260
+
1261
+ entries: list[Any] = []
1262
+ for g, group in by_graph.items():
1263
+ nodes = build_nodes(group)
1264
+ if g == "":
1265
+ entries.extend(nodes)
1266
+ else:
1267
+ entries.append({"@id": _resolve_iri(g, prefixes) or g, "@graph": nodes})
1268
+ return {"@context": prefixes, "@graph": entries}
1269
+
1270
+
1271
+ def json_ld_to_quads(
1272
+ doc: Mapping[str, Any], prefixes: Mapping[str, str] | None = None
1273
+ ) -> list[Quad]:
1274
+ """Expanded JSON-LD -> quads (named graphs preserved; prefixed terms
1275
+ restored where the prefix map covers them)."""
1276
+ prefixes = prefixes if prefixes is not None else JSONLD_PREFIXES
1277
+ out: list[Quad] = []
1278
+ blank = 0
1279
+
1280
+ def convert_node(node: Mapping[str, Any], graph_name: str | None) -> None:
1281
+ nonlocal blank
1282
+ if node.get("@id"):
1283
+ subject = _compact_iri(str(node["@id"]), prefixes)
1284
+ else:
1285
+ subject = f"_:b{blank}"
1286
+ blank += 1
1287
+ types = node.get("@type")
1288
+ for t in types if isinstance(types, list) else ([] if types is None else [types]):
1289
+ out.append(Quad(subject, "rdf:type", _compact_iri(str(t), prefixes), graph=graph_name))
1290
+ for key, raw in node.items():
1291
+ if key in ("@id", "@type"):
1292
+ continue
1293
+ resolved = _resolve_iri(key, prefixes) if ":" in key else key
1294
+ predicate = _compact_iri(resolved if resolved is not None else key, prefixes)
1295
+ for v in raw if isinstance(raw, list) else [raw]:
1296
+ obj = _json_ld_to_term(v, prefixes)
1297
+ if obj is not None:
1298
+ out.append(Quad(subject, predicate, obj, graph=graph_name))
1299
+
1300
+ for entry in doc.get("@graph", []):
1301
+ if isinstance(entry, dict) and isinstance(entry.get("@graph"), list):
1302
+ name = _compact_iri(str(entry["@id"]), prefixes) if entry.get("@id") else f"_:g{len(out)}"
1303
+ for node in entry["@graph"]:
1304
+ convert_node(node, name)
1305
+ continue
1306
+ if isinstance(entry, dict):
1307
+ convert_node(entry, None)
1308
+ return out
1309
+
1310
+
1311
+ # ---------------------------------------------------------------------------
1312
+ # Conformance battery
1313
+ # ---------------------------------------------------------------------------
1314
+
1315
+
1316
+ @dataclass
1317
+ class ConformanceCheck:
1318
+ id: str
1319
+ #: SPARQL-subset SELECT. Passes when the row count is within
1320
+ #: [min_rows, max_rows]. Default: at least one row (ask-style);
1321
+ #: min_rows=0, max_rows=0 asserts absence.
1322
+ ask: str | None = None
1323
+ min_rows: int | None = None
1324
+ max_rows: int | None = None
1325
+ #: An extra SHACL shape the closure must satisfy.
1326
+ shape: NodeShape | None = None
1327
+ #: Why this check matters — surfaces in failure reports.
1328
+ reason: str | None = None
1329
+
1330
+
1331
+ @dataclass
1332
+ class ConformanceResult:
1333
+ id: str
1334
+ passed: bool
1335
+ detail: str
1336
+
1337
+
1338
+ @dataclass
1339
+ class ConformanceReport:
1340
+ passed: bool
1341
+ results: list[ConformanceResult] = field(default_factory=list)
1342
+
1343
+
1344
+ async def run_conformance(store: InMemoryGraphStore, battery: list[ConformanceCheck]) -> ConformanceReport:
1345
+ """Run a battery of checks against a store's closure."""
1346
+ results: list[ConformanceResult] = []
1347
+ closure = await store.match()
1348
+ for check in battery:
1349
+ try:
1350
+ if check.ask:
1351
+ rows = await execute_select(store, check.ask)
1352
+ minimum = check.min_rows if check.min_rows is not None else 1
1353
+ ok = len(rows) >= minimum and (check.max_rows is None or len(rows) <= check.max_rows)
1354
+ detail = f"{len(rows)} rows (expected >= {minimum}"
1355
+ if check.max_rows is not None:
1356
+ detail += f", <= {check.max_rows}"
1357
+ detail += ")"
1358
+ results.append(ConformanceResult(check.id, ok, detail))
1359
+ elif check.shape:
1360
+ # Battery shapes aren't store-registered: validate with a
1361
+ # scratch store carrying ONLY this shape (check_shapes is pure).
1362
+ scratch = InMemoryGraphStore(shapes=[check.shape])
1363
+ violations = scratch.check_shapes(closure).violations
1364
+ results.append(
1365
+ ConformanceResult(
1366
+ check.id,
1367
+ not violations,
1368
+ "; ".join(v.message for v in violations) if violations else "shape holds",
1369
+ )
1370
+ )
1371
+ else:
1372
+ detail = "check has neither ask nor shape"
1373
+ if check.reason:
1374
+ detail += f" ({check.reason})"
1375
+ results.append(ConformanceResult(check.id, False, detail))
1376
+ except Exception as err: # noqa: BLE001 - battery reports, never throws
1377
+ results.append(ConformanceResult(check.id, False, str(err)))
1378
+ return ConformanceReport(passed=all(r.passed for r in results), results=results)
1379
+
1380
+
1381
+ # ---------------------------------------------------------------------------
1382
+ # TBox evolution
1383
+ # ---------------------------------------------------------------------------
1384
+
1385
+
1386
+ def _term_key(q: Any) -> str:
1387
+ return f"{q.subject}|{q.predicate}|{q.object}"
1388
+
1389
+
1390
+ #: Ontology-defining predicates: shared with the review auditor.
1391
+ TBOX_PREDICATES = {
1392
+ "rdfs:subClassOf",
1393
+ "http://www.w3.org/2000/01/rdf-schema#subClassOf",
1394
+ "rdfs:subPropertyOf",
1395
+ "http://www.w3.org/2000/01/rdf-schema#subPropertyOf",
1396
+ "rdfs:domain",
1397
+ "http://www.w3.org/2000/01/rdf-schema#domain",
1398
+ "rdfs:range",
1399
+ "http://www.w3.org/2000/01/rdf-schema#range",
1400
+ "owl:inverseOf",
1401
+ "http://www.w3.org/2002/07/owl#inverseOf",
1402
+ }
1403
+
1404
+ #: rdf:type objects that mark a TERM declaration, not an instance fact.
1405
+ OWL_VOCAB = {
1406
+ "owl:TransitiveProperty",
1407
+ "http://www.w3.org/2002/07/owl#TransitiveProperty",
1408
+ "owl:SymmetricProperty",
1409
+ "http://www.w3.org/2002/07/owl#SymmetricProperty",
1410
+ "owl:FunctionalProperty",
1411
+ "http://www.w3.org/2002/07/owl#FunctionalProperty",
1412
+ "owl:Class",
1413
+ "http://www.w3.org/2002/07/owl#Class",
1414
+ "rdfs:Class",
1415
+ "http://www.w3.org/2000/01/rdf-schema#Class",
1416
+ "rdfs:Datatype",
1417
+ "http://www.w3.org/2000/01/rdf-schema#Datatype",
1418
+ }
1419
+
1420
+
1421
+ @dataclass
1422
+ class AxiomRef:
1423
+ """Exact axiom to retract (matched on subject|predicate|object)."""
1424
+
1425
+ subject: str
1426
+ predicate: str
1427
+ object: str
1428
+
1429
+
1430
+ @dataclass
1431
+ class Rename:
1432
+ from_: str
1433
+ to: str
1434
+
1435
+
1436
+ @dataclass
1437
+ class Deprecation:
1438
+ term: str
1439
+ reason: str
1440
+ replacement: str | None = None
1441
+
1442
+
1443
+ @dataclass
1444
+ class TBoxMigrationSpec:
1445
+ from_: str
1446
+ to: str
1447
+ #: New TBox axioms to add (provenance required by the store).
1448
+ added: list[Quad] = field(default_factory=list)
1449
+ #: Exact axioms to retract (matched on subject|predicate|object).
1450
+ removed: list[AxiomRef] = field(default_factory=list)
1451
+ #: Term renames applied to BOTH the TBox and ABox in all three positions.
1452
+ renames: list[Rename] = field(default_factory=list)
1453
+ #: Terms deprecated in this version; with replacement, uses are rewritten.
1454
+ deprecations: list[Deprecation] = field(default_factory=list)
1455
+ #: Shapes/rules carried by the NEW store (defaults to the old store's).
1456
+ shapes: list[NodeShape] | None = None
1457
+ rules: list[GraphRule] | None = None
1458
+
1459
+
1460
+ @dataclass
1461
+ class MigrationOptions:
1462
+ battery: list[ConformanceCheck] | None = None
1463
+ factory: Any | None = None # (opts: StoreOptions) -> InMemoryGraphStore
1464
+ #: Version-named graph for the new TBox. True/None -> default
1465
+ #: ``urn:framework:ontology:<to>``; False opts out (graph-free).
1466
+ tbox_graph: str | None | bool = None
1467
+
1468
+
1469
+ @dataclass
1470
+ class MigrationReport:
1471
+ from_: str
1472
+ to: str
1473
+ status: str = "migrated" # migrated | aborted | failed
1474
+ added_quads: int = 0
1475
+ removed_quads: int = 0
1476
+ rewritten_quads: int = 0
1477
+ #: Named graph the new TBox axioms were written to (match(..., g=tboxGraph)).
1478
+ tbox_graph: str | None = None
1479
+ deprecated_warnings: list[str] = field(default_factory=list)
1480
+ errors: list[str] = field(default_factory=list)
1481
+ pre_battery: ConformanceReport | None = None
1482
+ post_battery: ConformanceReport | None = None
1483
+
1484
+
1485
+ @dataclass
1486
+ class MigrationResult:
1487
+ report: MigrationReport
1488
+ #: Present on success — the new-version store. The old store is untouched.
1489
+ store: InMemoryGraphStore | None = None
1490
+
1491
+
1492
+ def _now_iso() -> str:
1493
+ import datetime
1494
+
1495
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
1496
+
1497
+
1498
+ def _is_axiom(q: Quad, predicates_in_use: set[str]) -> bool:
1499
+ """True when the quad is an ontology statement, not a fact."""
1500
+ if q.predicate in TBOX_PREDICATES:
1501
+ return True
1502
+ # rdf:type is TBox when it types a TERM (property/class used somewhere) or
1503
+ # carries an OWL vocabulary marker — ABox for plain instances.
1504
+ if q.predicate in RDF_TYPE:
1505
+ return q.subject in predicates_in_use or q.object in OWL_VOCAB
1506
+ return False
1507
+
1508
+
1509
+ async def migrate_t_box(
1510
+ old_store: InMemoryGraphStore,
1511
+ spec: TBoxMigrationSpec,
1512
+ opts: MigrationOptions | None = None,
1513
+ ) -> MigrationResult:
1514
+ """Migrate a TBox. Order: pre-battery (abort on failure) -> rebuild ->
1515
+ post-battery (report failure; caller decides whether to adopt).
1516
+
1517
+ The store has no delete by design; migration therefore REBUILDS: explicit
1518
+ facts are exported from the old store, transformed (axiom add/remove, term
1519
+ renames, deprecations), re-validated and re-entailed into a fresh store —
1520
+ the old store survives untouched (rollback is "don't adopt").
1521
+ """
1522
+ opts = opts or MigrationOptions()
1523
+ report = MigrationReport(from_=spec.from_, to=spec.to)
1524
+ if opts.battery:
1525
+ report.pre_battery = await run_conformance(old_store, opts.battery)
1526
+ if not report.pre_battery.passed:
1527
+ report.status = "aborted"
1528
+ report.errors.append("pre-migration conformance battery failed — ontology not migrated")
1529
+ return MigrationResult(report=report)
1530
+
1531
+ remove_keys = {_term_key(q) for q in (spec.removed or [])}
1532
+ rename_map = {r.from_: r.to for r in (spec.renames or [])}
1533
+ for d in spec.deprecations or []:
1534
+ if d.replacement:
1535
+ rename_map[d.term] = d.replacement
1536
+ report.deprecated_warnings.append(f"{d.term} deprecated ({d.reason}) — rewritten to {d.replacement}")
1537
+ else:
1538
+ report.deprecated_warnings.append(f"{d.term} deprecated ({d.reason}) — uses kept, flagged")
1539
+
1540
+ def rewrite(term: str) -> str:
1541
+ return rename_map.get(term, term)
1542
+
1543
+ factory = opts.factory or (lambda o: InMemoryGraphStore(o))
1544
+ new_store = factory(
1545
+ StoreOptions(
1546
+ shapes=spec.shapes if spec.shapes is not None else old_store.ontology_shapes(),
1547
+ rules=spec.rules if spec.rules is not None else old_store.ontology_rules(),
1548
+ )
1549
+ )
1550
+
1551
+ explicit = [q for q in await old_store.match() if q.source != "entailment"]
1552
+ # Named-graph use: the new ontology is its OWN dataset inside the store —
1553
+ # every version stays addressable (match(None, None, None, tbox_graph)),
1554
+ # JSON-LD round-trips it as a named graph, and False opts out.
1555
+ if opts.tbox_graph is False:
1556
+ tbox_graph: str | None = None
1557
+ else:
1558
+ tbox_graph = opts.tbox_graph or f"urn:framework:ontology:{spec.to}"
1559
+ report.tbox_graph = tbox_graph
1560
+ predicates_in_use = {
1561
+ rewrite(q.predicate) for q in explicit if _term_key(q) not in remove_keys
1562
+ }
1563
+
1564
+ migration_tag = f"migration:{spec.from_}->{spec.to}"
1565
+ rebuilt: list[Quad] = []
1566
+ for q in explicit:
1567
+ if _term_key(q) in remove_keys:
1568
+ report.removed_quads += 1
1569
+ continue
1570
+ next_q = Quad(
1571
+ rewrite(q.subject),
1572
+ rewrite(q.predicate),
1573
+ rewrite(q.object),
1574
+ graph=q.graph,
1575
+ source=q.source,
1576
+ derived_from=q.derived_from,
1577
+ )
1578
+ if _is_axiom(next_q, predicates_in_use) and tbox_graph:
1579
+ next_q.graph = tbox_graph
1580
+ if _term_key(next_q) != _term_key(q):
1581
+ report.rewritten_quads += 1
1582
+ next_q.derived_from = (
1583
+ f"{next_q.derived_from};{migration_tag}" if next_q.derived_from else migration_tag
1584
+ )
1585
+ rebuilt.append(next_q)
1586
+ for aq in spec.added or []:
1587
+ rebuilt.append(
1588
+ Quad(
1589
+ aq.subject,
1590
+ aq.predicate,
1591
+ aq.object,
1592
+ graph=tbox_graph if tbox_graph else aq.graph,
1593
+ source=aq.source,
1594
+ derived_from=migration_tag,
1595
+ )
1596
+ )
1597
+ report.added_quads = len(spec.added or [])
1598
+
1599
+ try:
1600
+ await new_store.add(rebuilt)
1601
+ except Exception as err: # noqa: BLE001 - migration reports, never throws
1602
+ report.status = "failed"
1603
+ report.errors.append(f"{spec.to} store rejected migrated facts: {err}")
1604
+ return MigrationResult(report=report)
1605
+
1606
+ if opts.battery:
1607
+ report.post_battery = await run_conformance(new_store, opts.battery)
1608
+ if not report.post_battery.passed:
1609
+ report.status = "failed"
1610
+ failed = [f"{r.id}: {r.detail}" for r in report.post_battery.results if not r.passed]
1611
+ report.errors.append(f"post-migration conformance battery failed — {'; '.join(failed)}")
1612
+ return MigrationResult(report=report)
1613
+
1614
+ return MigrationResult(report=report, store=new_store)