rootmemory 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 (75) hide show
  1. rootmemory/__init__.py +84 -0
  2. rootmemory/api/__init__.py +25 -0
  3. rootmemory/api/agents.py +38 -0
  4. rootmemory/api/beliefs.py +66 -0
  5. rootmemory/api/claims.py +87 -0
  6. rootmemory/api/deps.py +21 -0
  7. rootmemory/api/errors.py +36 -0
  8. rootmemory/api/graph.py +32 -0
  9. rootmemory/api/invalidation.py +47 -0
  10. rootmemory/api/observations.py +46 -0
  11. rootmemory/api/promotion.py +37 -0
  12. rootmemory/api/provenance.py +88 -0
  13. rootmemory/api/security.py +53 -0
  14. rootmemory/cli.py +96 -0
  15. rootmemory/client.py +219 -0
  16. rootmemory/config.py +64 -0
  17. rootmemory/db/__init__.py +0 -0
  18. rootmemory/db/base.py +42 -0
  19. rootmemory/db/migrations/env.py +59 -0
  20. rootmemory/db/migrations/script.py.mako +26 -0
  21. rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
  22. rootmemory/db/session.py +138 -0
  23. rootmemory/errors.py +27 -0
  24. rootmemory/integrations/__init__.py +16 -0
  25. rootmemory/integrations/async_client.py +268 -0
  26. rootmemory/integrations/langchain_tools.py +237 -0
  27. rootmemory/integrations/langgraph_memory.py +179 -0
  28. rootmemory/integrations/langgraph_store.py +402 -0
  29. rootmemory/logging_config.py +30 -0
  30. rootmemory/main.py +133 -0
  31. rootmemory/models/__init__.py +22 -0
  32. rootmemory/models/agent.py +30 -0
  33. rootmemory/models/audit.py +51 -0
  34. rootmemory/models/belief.py +39 -0
  35. rootmemory/models/claim.py +52 -0
  36. rootmemory/models/decision.py +38 -0
  37. rootmemory/models/edge.py +43 -0
  38. rootmemory/models/enums.py +104 -0
  39. rootmemory/models/node_ref.py +42 -0
  40. rootmemory/models/observation.py +51 -0
  41. rootmemory/py.typed +0 -0
  42. rootmemory/repositories/__init__.py +19 -0
  43. rootmemory/repositories/agent_repo.py +44 -0
  44. rootmemory/repositories/audit_repo.py +43 -0
  45. rootmemory/repositories/belief_repo.py +63 -0
  46. rootmemory/repositories/claim_repo.py +102 -0
  47. rootmemory/repositories/decision_repo.py +76 -0
  48. rootmemory/repositories/edge_repo.py +77 -0
  49. rootmemory/repositories/observation_repo.py +76 -0
  50. rootmemory/schemas/__init__.py +52 -0
  51. rootmemory/schemas/agent.py +26 -0
  52. rootmemory/schemas/belief.py +49 -0
  53. rootmemory/schemas/claim.py +54 -0
  54. rootmemory/schemas/common.py +27 -0
  55. rootmemory/schemas/observation.py +69 -0
  56. rootmemory/schemas/promotion.py +50 -0
  57. rootmemory/schemas/provenance.py +51 -0
  58. rootmemory/services/__init__.py +35 -0
  59. rootmemory/services/belief_service.py +161 -0
  60. rootmemory/services/contradiction_service.py +148 -0
  61. rootmemory/services/decision_service.py +65 -0
  62. rootmemory/services/graph_service.py +316 -0
  63. rootmemory/services/independence_service.py +159 -0
  64. rootmemory/services/invalidation_service.py +164 -0
  65. rootmemory/services/normalization_service.py +234 -0
  66. rootmemory/services/promotion_service.py +318 -0
  67. rootmemory/services/provenance_service.py +282 -0
  68. rootmemory/services/scoring_service.py +53 -0
  69. rootmemory/static/index.html +866 -0
  70. rootmemory-0.1.0.dist-info/METADATA +266 -0
  71. rootmemory-0.1.0.dist-info/RECORD +75 -0
  72. rootmemory-0.1.0.dist-info/WHEEL +5 -0
  73. rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
  74. rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. rootmemory-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,318 @@
1
+ """PromotionService: the gate between private reasoning and shared memory.
2
+
3
+ A claim becomes shared knowledge only by passing this gate, and every
4
+ evaluation - pass or fail - is written to promotion_audits. The gate counts
5
+ *independent evidence sources*, never agreeing agents, which is what stops
6
+ three copies of one inference from looking like corroboration.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from dataclasses import asdict, dataclass, field
13
+
14
+ from sqlalchemy.orm import Session
15
+
16
+ from rootmemory.config import Settings, get_settings
17
+ from rootmemory.db.base import utcnow
18
+ from rootmemory.errors import NodeNotFoundError
19
+ from rootmemory.models.claim import Claim
20
+ from rootmemory.models.enums import (
21
+ ClaimStatus,
22
+ EpistemicStatus,
23
+ LinkRole,
24
+ PromotionResultType,
25
+ Visibility,
26
+ )
27
+ from rootmemory.repositories.audit_repo import AuditRepository
28
+ from rootmemory.repositories.belief_repo import BeliefRepository
29
+ from rootmemory.repositories.claim_repo import ClaimRepository
30
+ from rootmemory.services.contradiction_service import ClaimContext, ContradictionService
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class PromotionPolicy:
35
+ """The rules a claim must satisfy to enter shared memory (spec section 21)."""
36
+
37
+ min_independent_support: int = 2
38
+ min_confidence: float = 0.75
39
+ max_high_confidence_contradictions: int = 0
40
+ contradiction_confidence_threshold: float = 0.75
41
+ require_valid_provenance: bool = True
42
+ version: str = "v1"
43
+
44
+ @classmethod
45
+ def from_settings(cls, settings: Settings | None = None) -> PromotionPolicy:
46
+ settings = settings or get_settings()
47
+ return cls(
48
+ min_independent_support=settings.promotion_min_independent_support,
49
+ min_confidence=settings.promotion_min_confidence,
50
+ max_high_confidence_contradictions=(
51
+ settings.promotion_max_high_confidence_contradictions
52
+ ),
53
+ contradiction_confidence_threshold=(
54
+ settings.promotion_contradiction_confidence_threshold
55
+ ),
56
+ version=settings.promotion_policy_version,
57
+ )
58
+
59
+ def as_dict(self) -> dict[str, object]:
60
+ return asdict(self)
61
+
62
+
63
+ # Reason codes. Stored in the audit log, so they are stable strings rather than
64
+ # free text.
65
+ REASON_SUPPORT_MET = "independent_support_requirement_met"
66
+ REASON_SUPPORT_INSUFFICIENT = "independent_support_requirement_not_met"
67
+ REASON_CONFIDENCE_MET = "confidence_requirement_met"
68
+ REASON_CONFIDENCE_LOW = "confidence_below_threshold"
69
+ REASON_NO_CONTRADICTIONS = "no_unresolved_contradictions"
70
+ REASON_CONTRADICTED = "unresolved_contradiction_present"
71
+ REASON_NO_PROVENANCE = "no_valid_provenance"
72
+ REASON_SHARED_ROOT = "supporting_beliefs_share_a_single_evidence_root"
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class PromotionEvaluation:
77
+ """The result of running the gate. Both counts are always present."""
78
+
79
+ claim_id: uuid.UUID
80
+ claim_key: str
81
+ supporting_belief_count: int
82
+ agreeing_agent_count: int
83
+ independent_support_count: int
84
+ contradiction_count: int
85
+ independent_contradiction_count: int
86
+ aggregate_confidence: float
87
+ naive_average_confidence: float
88
+ decision: PromotionResultType
89
+ reasons: list[str] = field(default_factory=list)
90
+ policy: PromotionPolicy = field(default_factory=PromotionPolicy)
91
+ unresolved_contradiction_ids: list[uuid.UUID] = field(default_factory=list)
92
+ root_observation_ids: list[uuid.UUID] = field(default_factory=list)
93
+
94
+ @property
95
+ def promoted(self) -> bool:
96
+ return self.decision == PromotionResultType.PROMOTED
97
+
98
+ @property
99
+ def explanation(self) -> str:
100
+ """One sentence a human (or an agent) can act on."""
101
+ if self.promoted:
102
+ return (
103
+ f"{self.supporting_belief_count} supporting beliefs rest on "
104
+ f"{self.independent_support_count} independent evidence sources."
105
+ )
106
+ if REASON_SHARED_ROOT in self.reasons:
107
+ return (
108
+ f"{self.supporting_belief_count} supporting beliefs descend from a single "
109
+ f"evidence source."
110
+ )
111
+ if REASON_CONTRADICTED in self.reasons:
112
+ return (
113
+ f"{len(self.unresolved_contradiction_ids)} unresolved high-confidence "
114
+ f"contradiction(s) stand against this claim."
115
+ )
116
+ if REASON_SUPPORT_INSUFFICIENT in self.reasons:
117
+ return (
118
+ f"{self.independent_support_count} independent evidence source(s), "
119
+ f"{self.policy.min_independent_support} required."
120
+ )
121
+ if REASON_CONFIDENCE_LOW in self.reasons:
122
+ return (
123
+ f"aggregate confidence {self.aggregate_confidence} is below "
124
+ f"{self.policy.min_confidence}."
125
+ )
126
+ return "claim does not satisfy the promotion policy."
127
+
128
+ def as_dict(self) -> dict[str, object]:
129
+ return {
130
+ "claim_id": str(self.claim_id),
131
+ "claim_key": self.claim_key,
132
+ "supporting_belief_count": self.supporting_belief_count,
133
+ "agreeing_agents": self.agreeing_agent_count,
134
+ "independent_support_count": self.independent_support_count,
135
+ "contradiction_count": self.contradiction_count,
136
+ "independent_contradiction_count": self.independent_contradiction_count,
137
+ "aggregate_confidence": self.aggregate_confidence,
138
+ "naive_average_confidence": self.naive_average_confidence,
139
+ "decision": "promote" if self.promoted else "reject",
140
+ "reasons": list(self.reasons),
141
+ "explanation": self.explanation,
142
+ "policy_version": self.policy.version,
143
+ }
144
+
145
+
146
+ class PromotionService:
147
+ """The only component allowed to confirm a claim or share a belief."""
148
+
149
+ def __init__(self, session: Session, policy: PromotionPolicy | None = None) -> None:
150
+ self.session = session
151
+ self.policy = policy or PromotionPolicy.from_settings()
152
+ self.claims = ClaimRepository(session)
153
+ self.beliefs = BeliefRepository(session)
154
+ self.audits = AuditRepository(session)
155
+ self.contradictions = ContradictionService(session)
156
+
157
+ # ------------------------------------------------------------------
158
+ # evaluation
159
+ # ------------------------------------------------------------------
160
+
161
+ def evaluate(self, claim_id: uuid.UUID, persist_audit: bool = True) -> PromotionEvaluation:
162
+ """Run the promotion policy against a claim without changing its status."""
163
+ context = self.contradictions.build_context(claim_id)
164
+ evaluation = self._evaluate_context(context)
165
+ self.contradictions.sync_claim_metrics(context)
166
+ if persist_audit:
167
+ self._write_audit(evaluation)
168
+ return evaluation
169
+
170
+ def _evaluate_context(self, context: ClaimContext) -> PromotionEvaluation:
171
+ support = context.support
172
+ contradiction = context.contradiction
173
+
174
+ unresolved = self.contradictions.unresolved_contradictions(
175
+ context, self.policy.contradiction_confidence_threshold
176
+ )
177
+
178
+ reasons: list[str] = []
179
+
180
+ has_provenance = support.independent_source_count > 0
181
+ if self.policy.require_valid_provenance and not has_provenance:
182
+ reasons.append(REASON_NO_PROVENANCE)
183
+
184
+ support_ok = support.independent_source_count >= self.policy.min_independent_support
185
+ if support_ok:
186
+ reasons.append(REASON_SUPPORT_MET)
187
+ else:
188
+ reasons.append(REASON_SUPPORT_INSUFFICIENT)
189
+ # The interesting sub-case: plenty of agreement, one source.
190
+ if support.shares_single_source:
191
+ reasons.append(REASON_SHARED_ROOT)
192
+
193
+ confidence_ok = support.aggregate_confidence >= self.policy.min_confidence
194
+ reasons.append(REASON_CONFIDENCE_MET if confidence_ok else REASON_CONFIDENCE_LOW)
195
+
196
+ contradictions_ok = len(unresolved) <= self.policy.max_high_confidence_contradictions
197
+ reasons.append(REASON_NO_CONTRADICTIONS if contradictions_ok else REASON_CONTRADICTED)
198
+
199
+ passes = (
200
+ support_ok
201
+ and confidence_ok
202
+ and contradictions_ok
203
+ and (has_provenance or not self.policy.require_valid_provenance)
204
+ )
205
+
206
+ return PromotionEvaluation(
207
+ claim_id=context.claim.id,
208
+ claim_key=context.claim.claim_key,
209
+ supporting_belief_count=support.belief_count,
210
+ agreeing_agent_count=support.agreeing_agent_count,
211
+ independent_support_count=support.independent_source_count,
212
+ contradiction_count=contradiction.belief_count,
213
+ independent_contradiction_count=contradiction.independent_source_count,
214
+ aggregate_confidence=support.aggregate_confidence,
215
+ naive_average_confidence=support.naive_confidence,
216
+ decision=(
217
+ PromotionResultType.PROMOTED if passes else PromotionResultType.REJECTED
218
+ ),
219
+ reasons=reasons,
220
+ policy=self.policy,
221
+ unresolved_contradiction_ids=unresolved,
222
+ root_observation_ids=support.root_observation_ids,
223
+ )
224
+
225
+ # ------------------------------------------------------------------
226
+ # promotion
227
+ # ------------------------------------------------------------------
228
+
229
+ def promote(self, claim_id: uuid.UUID) -> PromotionEvaluation:
230
+ """Attempt to move a claim into shared memory.
231
+
232
+ The gate is always re-run here: a caller's earlier evaluation is never
233
+ trusted (spec section 27.8).
234
+
235
+ The claim row is locked first, so concurrent workers serialize on it
236
+ rather than racing between reading the evidence and writing the verdict.
237
+ """
238
+ if self.claims.get_for_update(claim_id) is None:
239
+ raise NodeNotFoundError(f"claim {claim_id} does not exist")
240
+ context = self.contradictions.build_context(claim_id)
241
+ evaluation = self._evaluate_context(context)
242
+ self.contradictions.sync_claim_metrics(context)
243
+ self._apply(context, evaluation)
244
+ self._write_audit(evaluation)
245
+ return evaluation
246
+
247
+ def recompute(self, claim_id: uuid.UUID) -> PromotionEvaluation:
248
+ """Re-run the gate on a claim whose evidence changed.
249
+
250
+ Used by cascade repair: a confirmed claim that no longer passes is
251
+ downgraded rather than left standing on retracted evidence.
252
+ """
253
+ return self.promote(claim_id)
254
+
255
+ def _apply(self, context: ClaimContext, evaluation: PromotionEvaluation) -> None:
256
+ claim = context.claim
257
+ supporting = self.claims.beliefs_for_claim(claim.id, LinkRole.SUPPORTS)
258
+
259
+ if evaluation.promoted:
260
+ claim.status = str(ClaimStatus.CONFIRMED)
261
+ if claim.promoted_at is None:
262
+ claim.promoted_at = utcnow()
263
+ for belief in supporting:
264
+ if belief.epistemic_status != EpistemicStatus.RETRACTED:
265
+ belief.visibility = str(Visibility.SHARED)
266
+ belief.epistemic_status = str(EpistemicStatus.CONFIRMED)
267
+ else:
268
+ claim.status = str(self._rejected_status(context, evaluation))
269
+ claim.promoted_at = None
270
+ for belief in supporting:
271
+ # A failed candidate falls back to private; the reason lives in
272
+ # the audit log.
273
+ if belief.visibility != Visibility.PRIVATE:
274
+ belief.visibility = str(Visibility.PRIVATE)
275
+ if belief.epistemic_status == EpistemicStatus.CONFIRMED:
276
+ belief.epistemic_status = str(EpistemicStatus.BELIEVED)
277
+
278
+ self.session.flush()
279
+
280
+ def _rejected_status(
281
+ self, context: ClaimContext, evaluation: PromotionEvaluation
282
+ ) -> ClaimStatus:
283
+ if evaluation.unresolved_contradiction_ids:
284
+ return ClaimStatus.CONTRADICTED
285
+ # Linked beliefs including retracted ones: "nobody ever asserted this"
286
+ # and "everyone who asserted it has withdrawn" are different states.
287
+ linked = self.claims.beliefs_for_claim(context.claim.id, LinkRole.SUPPORTS)
288
+ if not linked:
289
+ return ClaimStatus.UNCONFIRMED
290
+ if evaluation.independent_support_count == 0:
291
+ # Support exists on paper but rests on no valid evidence any more.
292
+ return ClaimStatus.RETRACTED
293
+ return ClaimStatus.PROVISIONAL
294
+
295
+ # ------------------------------------------------------------------
296
+ # audit
297
+ # ------------------------------------------------------------------
298
+
299
+ def _write_audit(self, evaluation: PromotionEvaluation) -> None:
300
+ self.audits.record_promotion(
301
+ claim_id=evaluation.claim_id,
302
+ supporting_belief_count=evaluation.supporting_belief_count,
303
+ agreeing_agent_count=evaluation.agreeing_agent_count,
304
+ independent_support_count=evaluation.independent_support_count,
305
+ contradiction_count=evaluation.contradiction_count,
306
+ independent_contradiction_count=evaluation.independent_contradiction_count,
307
+ aggregate_confidence=evaluation.aggregate_confidence,
308
+ result=str(evaluation.decision),
309
+ reasons=list(evaluation.reasons),
310
+ policy_version=self.policy.version,
311
+ policy_snapshot=self.policy.as_dict(),
312
+ )
313
+
314
+ def require_claim(self, claim_id: uuid.UUID) -> Claim:
315
+ claim = self.claims.get(claim_id)
316
+ if claim is None:
317
+ raise NodeNotFoundError(f"claim {claim_id} does not exist")
318
+ return claim
@@ -0,0 +1,282 @@
1
+ """ProvenanceService: traversal over the causal dependency graph.
2
+
3
+ This module is the foundation of the whole system. Everything else -
4
+ independence counting, the promotion gate, cascade repair - is expressed in
5
+ terms of the four operations here:
6
+
7
+ parents() one hop towards the evidence
8
+ find_evidence_roots() the observations a node ultimately rests on
9
+ path_exists() cycle prevention
10
+ cascade_descendants() everything affected by a retraction
11
+
12
+ All of it is deterministic. No LLM is involved (spec section 47).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import uuid
18
+ from collections.abc import Iterable, Sequence
19
+ from dataclasses import dataclass, field
20
+
21
+ from sqlalchemy.orm import Session
22
+
23
+ from rootmemory.models.belief import Belief
24
+ from rootmemory.models.enums import CAUSAL_EDGE_TYPES, LinkRole, NodeType, ValidityStatus
25
+ from rootmemory.models.node_ref import NodeRef
26
+ from rootmemory.models.observation import Observation
27
+ from rootmemory.repositories.belief_repo import BeliefRepository
28
+ from rootmemory.repositories.claim_repo import ClaimRepository
29
+ from rootmemory.repositories.decision_repo import DecisionRepository
30
+ from rootmemory.repositories.edge_repo import EdgeRepository
31
+ from rootmemory.repositories.observation_repo import ObservationRepository
32
+
33
+ #: Observation validity states that may act as evidence roots by default.
34
+ DEFAULT_ROOT_STATUSES: frozenset[str] = frozenset({str(ValidityStatus.VALID)})
35
+
36
+ #: Safety valve for path enumeration on dense graphs.
37
+ MAX_ENUMERATED_PATHS = 200
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class EvidenceRoots:
42
+ """The result of walking a node back to its evidence.
43
+
44
+ ``roots`` holds the observations that currently qualify as evidence.
45
+ ``excluded_roots`` holds ancestors that were reached but rejected (invalid
46
+ or disputed), which is what makes a retraction explainable rather than a
47
+ silent disappearance.
48
+ """
49
+
50
+ roots: set[uuid.UUID] = field(default_factory=set)
51
+ excluded_roots: dict[uuid.UUID, str] = field(default_factory=dict)
52
+ visited_nodes: int = 0
53
+
54
+ def __len__(self) -> int:
55
+ return len(self.roots)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class CascadeResult:
60
+ """Everything downstream of a node, split by node type."""
61
+
62
+ beliefs: list[uuid.UUID] = field(default_factory=list)
63
+ claims: list[uuid.UUID] = field(default_factory=list)
64
+ decisions: list[uuid.UUID] = field(default_factory=list)
65
+
66
+
67
+ class ProvenanceService:
68
+ """Graph traversal. Holds no state beyond the session it was given."""
69
+
70
+ def __init__(self, session: Session) -> None:
71
+ self.session = session
72
+ self.edges = EdgeRepository(session)
73
+ self.observations = ObservationRepository(session)
74
+ self.beliefs = BeliefRepository(session)
75
+ self.claims = ClaimRepository(session)
76
+ self.decisions = DecisionRepository(session)
77
+
78
+ # ------------------------------------------------------------------
79
+ # single hop
80
+ # ------------------------------------------------------------------
81
+
82
+ def parents(self, node: NodeRef) -> list[NodeRef]:
83
+ """Nodes that ``node`` was derived from or depends on."""
84
+ edges = self.edges.outgoing([node.id], CAUSAL_EDGE_TYPES)
85
+ return [NodeRef(e.to_node_id, NodeType(e.to_node_type)) for e in edges]
86
+
87
+ def children(self, node: NodeRef) -> list[NodeRef]:
88
+ """Nodes that were derived from ``node`` (causal edges only)."""
89
+ edges = self.edges.incoming([node.id], CAUSAL_EDGE_TYPES)
90
+ return [NodeRef(e.from_node_id, NodeType(e.from_node_type)) for e in edges]
91
+
92
+ # ------------------------------------------------------------------
93
+ # evidence roots (spec section 18)
94
+ # ------------------------------------------------------------------
95
+
96
+ def find_evidence_roots(
97
+ self,
98
+ node: NodeRef,
99
+ allowed_statuses: Iterable[str] = DEFAULT_ROOT_STATUSES,
100
+ ) -> EvidenceRoots:
101
+ """Walk ``node`` back to the observations it ultimately rests on.
102
+
103
+ Cycle-safe: every node is expanded at most once, so a corrupted graph
104
+ can slow this down but can never hang it.
105
+
106
+ Observations whose validity is not in ``allowed_statuses`` are recorded
107
+ in ``excluded_roots`` and do not count as evidence.
108
+ """
109
+ allowed = {str(s) for s in allowed_statuses}
110
+ result = EvidenceRoots()
111
+ visited: set[uuid.UUID] = set()
112
+ frontier: list[NodeRef] = [node]
113
+
114
+ while frontier:
115
+ # Nodes not seen before, deduplicated within this level.
116
+ level: dict[uuid.UUID, NodeRef] = {}
117
+ for ref in frontier:
118
+ if ref.id not in visited:
119
+ visited.add(ref.id)
120
+ level[ref.id] = ref
121
+ if not level:
122
+ break
123
+ result.visited_nodes += len(level)
124
+
125
+ observation_ids = [r.id for r in level.values() if r.type == NodeType.OBSERVATION]
126
+ for observation in self.observations.get_many(observation_ids):
127
+ if observation.validity_status in allowed:
128
+ result.roots.add(observation.id)
129
+ else:
130
+ result.excluded_roots[observation.id] = observation.validity_status
131
+
132
+ # Observations are terminal; only non-observations are expanded.
133
+ expandable = [r.id for r in level.values() if r.type != NodeType.OBSERVATION]
134
+ next_frontier: list[NodeRef] = []
135
+ for edge in self.edges.outgoing(expandable, CAUSAL_EDGE_TYPES):
136
+ next_frontier.append(NodeRef(edge.to_node_id, NodeType(edge.to_node_type)))
137
+ frontier = next_frontier
138
+
139
+ return result
140
+
141
+ def find_evidence_root_observations(
142
+ self,
143
+ node: NodeRef,
144
+ allowed_statuses: Iterable[str] = DEFAULT_ROOT_STATUSES,
145
+ ) -> list[Observation]:
146
+ """``find_evidence_roots`` but returning the observation rows."""
147
+ roots = self.find_evidence_roots(node, allowed_statuses)
148
+ return list(self.observations.get_many(roots.roots))
149
+
150
+ def evidence_roots_for_beliefs(
151
+ self,
152
+ belief_ids: Iterable[uuid.UUID],
153
+ allowed_statuses: Iterable[str] = DEFAULT_ROOT_STATUSES,
154
+ ) -> dict[uuid.UUID, set[uuid.UUID]]:
155
+ """Evidence roots for many beliefs at once, keyed by belief id."""
156
+ return {
157
+ belief_id: self.find_evidence_roots(NodeRef.belief(belief_id), allowed_statuses).roots
158
+ for belief_id in belief_ids
159
+ }
160
+
161
+ # ------------------------------------------------------------------
162
+ # paths and cycles (spec section 28)
163
+ # ------------------------------------------------------------------
164
+
165
+ def path_exists(self, source: NodeRef, target: NodeRef) -> bool:
166
+ """True when ``target`` is a causal ancestor of ``source``."""
167
+ if source.id == target.id:
168
+ return True
169
+ visited: set[uuid.UUID] = {source.id}
170
+ frontier = [source.id]
171
+ while frontier:
172
+ next_frontier: list[uuid.UUID] = []
173
+ for edge in self.edges.outgoing(frontier, CAUSAL_EDGE_TYPES):
174
+ if edge.to_node_id == target.id:
175
+ return True
176
+ if edge.to_node_id not in visited:
177
+ visited.add(edge.to_node_id)
178
+ next_frontier.append(edge.to_node_id)
179
+ frontier = next_frontier
180
+ return False
181
+
182
+ def would_create_cycle(self, source: NodeRef, target: NodeRef) -> bool:
183
+ """True when adding ``source -> target`` would close a loop.
184
+
185
+ A new edge points from a node to something it depends on, so the graph
186
+ stays acyclic exactly when ``source`` is not already an ancestor of
187
+ ``target``.
188
+ """
189
+ return self.path_exists(target, source)
190
+
191
+ def enumerate_paths(
192
+ self,
193
+ node: NodeRef,
194
+ max_paths: int = MAX_ENUMERATED_PATHS,
195
+ ) -> list[list[str]]:
196
+ """Every causal path from ``node`` down to a terminal ancestor.
197
+
198
+ Paths are returned as lists of "type:id" strings, nearest node first.
199
+ Cyclic branches are cut rather than followed.
200
+ """
201
+ paths: list[list[str]] = []
202
+ stack: list[tuple[NodeRef, list[NodeRef]]] = [(node, [node])]
203
+
204
+ while stack and len(paths) < max_paths:
205
+ current, trail = stack.pop()
206
+ parents = [] if current.type == NodeType.OBSERVATION else self.parents(current)
207
+ seen_ids = {n.id for n in trail}
208
+ unseen = [p for p in parents if p.id not in seen_ids]
209
+ if not unseen:
210
+ paths.append([str(n) for n in trail])
211
+ continue
212
+ for parent in unseen:
213
+ stack.append((parent, [*trail, parent]))
214
+
215
+ return paths
216
+
217
+ # ------------------------------------------------------------------
218
+ # descendants (spec section 25)
219
+ # ------------------------------------------------------------------
220
+
221
+ def cascade_descendants(self, node: NodeRef) -> CascadeResult:
222
+ """Everything downstream of ``node``.
223
+
224
+ The walk crosses node types explicitly:
225
+
226
+ observation/belief -> beliefs derived from it (causal edges)
227
+ belief -> claims it supports or contradicts
228
+ claim -> decisions that depend on it
229
+
230
+ Beliefs are collected transitively first, so a claim several derivation
231
+ hops away is still found.
232
+ """
233
+ result = CascadeResult()
234
+
235
+ # 1. transitive belief descendants
236
+ visited: set[uuid.UUID] = {node.id}
237
+ frontier: list[uuid.UUID] = [node.id]
238
+ belief_ids: list[uuid.UUID] = []
239
+ while frontier:
240
+ next_frontier: list[uuid.UUID] = []
241
+ for edge in self.edges.incoming(frontier, CAUSAL_EDGE_TYPES):
242
+ if edge.from_node_id in visited:
243
+ continue
244
+ visited.add(edge.from_node_id)
245
+ if edge.from_node_type == NodeType.BELIEF:
246
+ belief_ids.append(edge.from_node_id)
247
+ next_frontier.append(edge.from_node_id)
248
+ elif edge.from_node_type == NodeType.DECISION:
249
+ result.decisions.append(edge.from_node_id)
250
+ frontier = next_frontier
251
+ result.beliefs = belief_ids
252
+
253
+ # 2. claims those beliefs are attached to
254
+ result.claims = [claim.id for claim in self.claims.claims_for_beliefs(belief_ids)]
255
+
256
+ # 3. decisions depending on those claims
257
+ for decision in self.decisions.decisions_for_claims(result.claims):
258
+ if decision.id not in result.decisions:
259
+ result.decisions.append(decision.id)
260
+
261
+ return result
262
+
263
+ # ------------------------------------------------------------------
264
+ # helpers
265
+ # ------------------------------------------------------------------
266
+
267
+ def node_exists(self, node: NodeRef) -> bool:
268
+ """Whether the referenced row is present in its table."""
269
+ match node.type:
270
+ case NodeType.OBSERVATION:
271
+ return self.observations.get(node.id) is not None
272
+ case NodeType.BELIEF:
273
+ return self.beliefs.get(node.id) is not None
274
+ case NodeType.CLAIM:
275
+ return self.claims.get(node.id) is not None
276
+ case NodeType.DECISION:
277
+ return self.decisions.get(node.id) is not None
278
+ return False
279
+
280
+ def beliefs_in_role(self, claim_id: uuid.UUID, role: LinkRole) -> Sequence[Belief]:
281
+ """Beliefs linked to a claim in the given role."""
282
+ return self.claims.beliefs_for_claim(claim_id, role)
@@ -0,0 +1,53 @@
1
+ """Root-aware confidence aggregation (spec section 22).
2
+
3
+ Averaging the confidence of every supporting belief is wrong here: three
4
+ copies of one inference would inflate the average exactly the way three copies
5
+ inflate a naive support count. So confidence is aggregated *per independent
6
+ evidence root* instead.
7
+
8
+ These are pure functions over plain data, which keeps them trivially testable
9
+ and free of any database dependency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from collections.abc import Mapping, Sequence
16
+
17
+
18
+ def aggregate_confidence_by_root(
19
+ beliefs_by_source: Mapping[str, Sequence[uuid.UUID]],
20
+ confidence_by_belief: Mapping[uuid.UUID, float],
21
+ ) -> float:
22
+ """Aggregate confidence across independent sources.
23
+
24
+ For each independent source, the strongest belief resting on it is taken;
25
+ those per-source values are then averaged. A source can therefore never
26
+ contribute more than once, no matter how many times its conclusion was
27
+ copied.
28
+
29
+ Returns 0.0 when there is no supporting evidence at all.
30
+ """
31
+ if not beliefs_by_source:
32
+ return 0.0
33
+
34
+ per_source: list[float] = []
35
+ for belief_ids in beliefs_by_source.values():
36
+ confidences = [confidence_by_belief[b] for b in belief_ids if b in confidence_by_belief]
37
+ if confidences:
38
+ per_source.append(max(confidences))
39
+
40
+ if not per_source:
41
+ return 0.0
42
+ return round(sum(per_source) / len(per_source), 6)
43
+
44
+
45
+ def naive_average_confidence(confidences: Sequence[float]) -> float:
46
+ """Flat mean over every belief.
47
+
48
+ Kept for the baseline comparison in experiments/ so the two aggregation
49
+ strategies can be reported side by side.
50
+ """
51
+ if not confidences:
52
+ return 0.0
53
+ return round(sum(confidences) / len(confidences), 6)