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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|