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,161 @@
|
|
|
1
|
+
"""BeliefService: creating beliefs, always with provenance.
|
|
2
|
+
|
|
3
|
+
The invariants from spec section 16 are enforced here, at the only place where
|
|
4
|
+
beliefs enter the system:
|
|
5
|
+
|
|
6
|
+
* every belief has at least one causal parent
|
|
7
|
+
* every parent must already exist
|
|
8
|
+
* a belief may never become its own ancestor
|
|
9
|
+
* beliefs start private; only the promotion gate makes them shared
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from sqlalchemy.orm import Session
|
|
19
|
+
|
|
20
|
+
from rootmemory.errors import (
|
|
21
|
+
NodeNotFoundError,
|
|
22
|
+
ProvenanceCycleError,
|
|
23
|
+
ProvenanceRequiredError,
|
|
24
|
+
)
|
|
25
|
+
from rootmemory.models.belief import Belief
|
|
26
|
+
from rootmemory.models.enums import EdgeType, EpistemicStatus, NodeType, Visibility
|
|
27
|
+
from rootmemory.models.node_ref import NodeRef
|
|
28
|
+
from rootmemory.repositories.agent_repo import AgentRepository
|
|
29
|
+
from rootmemory.repositories.belief_repo import BeliefRepository
|
|
30
|
+
from rootmemory.repositories.claim_repo import ClaimRepository
|
|
31
|
+
from rootmemory.repositories.edge_repo import EdgeRepository
|
|
32
|
+
from rootmemory.services.normalization_service import ClaimNormalizer, build_normalizer
|
|
33
|
+
from rootmemory.services.provenance_service import ProvenanceService
|
|
34
|
+
|
|
35
|
+
#: Node types a belief is allowed to be derived from in V1 (spec section 16).
|
|
36
|
+
ALLOWED_PARENT_TYPES: frozenset[NodeType] = frozenset({NodeType.OBSERVATION, NodeType.BELIEF})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ParentSpec:
|
|
41
|
+
"""A requested causal parent for a new belief."""
|
|
42
|
+
|
|
43
|
+
id: uuid.UUID
|
|
44
|
+
type: NodeType
|
|
45
|
+
|
|
46
|
+
def as_ref(self) -> NodeRef:
|
|
47
|
+
return NodeRef(self.id, self.type)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BeliefService:
|
|
51
|
+
def __init__(self, session: Session, normalizer: ClaimNormalizer | None = None) -> None:
|
|
52
|
+
self.session = session
|
|
53
|
+
self.beliefs = BeliefRepository(session)
|
|
54
|
+
self.agents = AgentRepository(session)
|
|
55
|
+
self.edges = EdgeRepository(session)
|
|
56
|
+
self.claims = ClaimRepository(session)
|
|
57
|
+
self.provenance = ProvenanceService(session)
|
|
58
|
+
self._normalizer = normalizer
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def normalizer(self) -> ClaimNormalizer:
|
|
62
|
+
"""Built lazily so configuration is read at use, not at import."""
|
|
63
|
+
if self._normalizer is None:
|
|
64
|
+
self._normalizer = build_normalizer()
|
|
65
|
+
return self._normalizer
|
|
66
|
+
|
|
67
|
+
def resolve_claim_key(self, claim_text: str) -> str:
|
|
68
|
+
"""Work out which proposition a belief is about.
|
|
69
|
+
|
|
70
|
+
Only reached when the caller did not supply a key. The normalizer sees
|
|
71
|
+
the keys already in use so it can file the belief alongside them
|
|
72
|
+
instead of starting a new claim for every rephrasing.
|
|
73
|
+
"""
|
|
74
|
+
existing = [claim.claim_key for claim in self.claims.list()]
|
|
75
|
+
return self.normalizer.normalize(claim_text, existing)
|
|
76
|
+
|
|
77
|
+
def create_belief(
|
|
78
|
+
self,
|
|
79
|
+
agent_id: uuid.UUID,
|
|
80
|
+
claim_text: str,
|
|
81
|
+
normalized_claim_key: str | None,
|
|
82
|
+
confidence: float,
|
|
83
|
+
parents: Sequence[ParentSpec],
|
|
84
|
+
epistemic_status: EpistemicStatus = EpistemicStatus.INFERRED,
|
|
85
|
+
llm_generated: bool = False,
|
|
86
|
+
meta: dict | None = None,
|
|
87
|
+
) -> Belief:
|
|
88
|
+
"""Create a belief and wire it to the evidence it came from.
|
|
89
|
+
|
|
90
|
+
Raises ProvenanceRequiredError when no parent is supplied, so a belief
|
|
91
|
+
with no traceable origin can never exist in the first place.
|
|
92
|
+
"""
|
|
93
|
+
claim_key = normalized_claim_key or self.resolve_claim_key(claim_text)
|
|
94
|
+
if not parents:
|
|
95
|
+
raise ProvenanceRequiredError(
|
|
96
|
+
"a belief must cite at least one observation or belief it was derived from"
|
|
97
|
+
)
|
|
98
|
+
if self.agents.get(agent_id) is None:
|
|
99
|
+
raise NodeNotFoundError(f"agent {agent_id} does not exist")
|
|
100
|
+
|
|
101
|
+
for parent in parents:
|
|
102
|
+
if parent.type not in ALLOWED_PARENT_TYPES:
|
|
103
|
+
raise ProvenanceRequiredError(
|
|
104
|
+
f"a belief may only be derived from {sorted(ALLOWED_PARENT_TYPES)}, "
|
|
105
|
+
f"got {parent.type}"
|
|
106
|
+
)
|
|
107
|
+
if not self.provenance.node_exists(parent.as_ref()):
|
|
108
|
+
raise NodeNotFoundError(f"parent {parent.type}:{parent.id} does not exist")
|
|
109
|
+
|
|
110
|
+
belief = self.beliefs.create(
|
|
111
|
+
agent_id=agent_id,
|
|
112
|
+
claim_text=claim_text,
|
|
113
|
+
normalized_claim_key=claim_key,
|
|
114
|
+
confidence=confidence,
|
|
115
|
+
epistemic_status=epistemic_status,
|
|
116
|
+
visibility=Visibility.PRIVATE,
|
|
117
|
+
llm_generated=llm_generated,
|
|
118
|
+
meta=meta or {},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for parent in parents:
|
|
122
|
+
self.add_parent(belief.id, parent)
|
|
123
|
+
|
|
124
|
+
return belief
|
|
125
|
+
|
|
126
|
+
def add_parent(self, belief_id: uuid.UUID, parent: ParentSpec) -> None:
|
|
127
|
+
"""Attach one causal parent to an existing belief, refusing cycles."""
|
|
128
|
+
child = NodeRef.belief(belief_id)
|
|
129
|
+
parent_ref = parent.as_ref()
|
|
130
|
+
|
|
131
|
+
if self.provenance.would_create_cycle(child, parent_ref):
|
|
132
|
+
raise ProvenanceCycleError(
|
|
133
|
+
f"edge {child} -> {parent_ref} would make the node its own ancestor"
|
|
134
|
+
)
|
|
135
|
+
if self.edges.exists(child, parent_ref, EdgeType.DERIVED_FROM):
|
|
136
|
+
return
|
|
137
|
+
self.edges.create(child, parent_ref, EdgeType.DERIVED_FROM)
|
|
138
|
+
|
|
139
|
+
def submit_for_promotion(self, belief_id: uuid.UUID) -> Belief:
|
|
140
|
+
"""Move a private belief to candidate_shared (spec section 29).
|
|
141
|
+
|
|
142
|
+
This is as far as an agent can push its own belief; only the promotion
|
|
143
|
+
gate can take it further.
|
|
144
|
+
"""
|
|
145
|
+
belief = self._require(belief_id)
|
|
146
|
+
if belief.visibility == Visibility.PRIVATE:
|
|
147
|
+
belief.visibility = str(Visibility.CANDIDATE_SHARED)
|
|
148
|
+
self.session.flush()
|
|
149
|
+
return belief
|
|
150
|
+
|
|
151
|
+
def set_epistemic_status(self, belief_id: uuid.UUID, status: EpistemicStatus) -> Belief:
|
|
152
|
+
belief = self._require(belief_id)
|
|
153
|
+
belief.epistemic_status = str(status)
|
|
154
|
+
self.session.flush()
|
|
155
|
+
return belief
|
|
156
|
+
|
|
157
|
+
def _require(self, belief_id: uuid.UUID) -> Belief:
|
|
158
|
+
belief = self.beliefs.get(belief_id)
|
|
159
|
+
if belief is None:
|
|
160
|
+
raise NodeNotFoundError(f"belief {belief_id} does not exist")
|
|
161
|
+
return belief
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""ContradictionService: attaching beliefs to claims, and reading claims back.
|
|
2
|
+
|
|
3
|
+
Two rules from the spec live here:
|
|
4
|
+
|
|
5
|
+
* A contradiction never deletes the belief it argues with. Both sides are
|
|
6
|
+
stored, both sides are reported (spec section 23).
|
|
7
|
+
* Agents never receive a bare "X is true". They receive the claim together
|
|
8
|
+
with how much independent evidence stands on each side (spec section 49).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
from rootmemory.errors import NodeNotFoundError, RootMemoryError
|
|
19
|
+
from rootmemory.models.claim import Claim
|
|
20
|
+
from rootmemory.models.enums import ClaimStatus, EdgeType, LinkRole
|
|
21
|
+
from rootmemory.models.node_ref import NodeRef
|
|
22
|
+
from rootmemory.repositories.belief_repo import BeliefRepository
|
|
23
|
+
from rootmemory.repositories.claim_repo import ClaimRepository
|
|
24
|
+
from rootmemory.repositories.edge_repo import EdgeRepository
|
|
25
|
+
from rootmemory.services.independence_service import IndependenceReport, IndependenceService
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ClaimContext:
|
|
30
|
+
"""Everything an agent needs to reason about a claim honestly."""
|
|
31
|
+
|
|
32
|
+
claim: Claim
|
|
33
|
+
support: IndependenceReport
|
|
34
|
+
contradiction: IndependenceReport
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_disputed(self) -> bool:
|
|
38
|
+
return self.contradiction.belief_count > 0
|
|
39
|
+
|
|
40
|
+
def as_dict(self) -> dict[str, object]:
|
|
41
|
+
return {
|
|
42
|
+
"claim_id": str(self.claim.id),
|
|
43
|
+
"claim_key": self.claim.claim_key,
|
|
44
|
+
"claim": self.claim.canonical_text,
|
|
45
|
+
"status": self.claim.status,
|
|
46
|
+
"support": {
|
|
47
|
+
"belief_count": self.support.belief_count,
|
|
48
|
+
"agreeing_agents": self.support.agreeing_agent_count,
|
|
49
|
+
"independent_sources": self.support.independent_source_count,
|
|
50
|
+
"confidence": self.support.aggregate_confidence,
|
|
51
|
+
"naive_average_confidence": self.support.naive_confidence,
|
|
52
|
+
"root_observation_ids": [str(o) for o in self.support.root_observation_ids],
|
|
53
|
+
},
|
|
54
|
+
"contradictions": {
|
|
55
|
+
"belief_count": self.contradiction.belief_count,
|
|
56
|
+
"agreeing_agents": self.contradiction.agreeing_agent_count,
|
|
57
|
+
"independent_sources": self.contradiction.independent_source_count,
|
|
58
|
+
"confidence": self.contradiction.aggregate_confidence,
|
|
59
|
+
"root_observation_ids": [str(o) for o in self.contradiction.root_observation_ids],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContradictionService:
|
|
65
|
+
def __init__(self, session: Session) -> None:
|
|
66
|
+
self.session = session
|
|
67
|
+
self.claims = ClaimRepository(session)
|
|
68
|
+
self.beliefs = BeliefRepository(session)
|
|
69
|
+
self.edges = EdgeRepository(session)
|
|
70
|
+
self.independence = IndependenceService(session)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# attaching beliefs
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def register_support(self, claim_id: uuid.UUID, belief_id: uuid.UUID) -> None:
|
|
77
|
+
self._link(claim_id, belief_id, LinkRole.SUPPORTS, EdgeType.SUPPORTS)
|
|
78
|
+
|
|
79
|
+
def register_contradiction(self, claim_id: uuid.UUID, belief_id: uuid.UUID) -> None:
|
|
80
|
+
self._link(claim_id, belief_id, LinkRole.CONTRADICTS, EdgeType.CONTRADICTS)
|
|
81
|
+
|
|
82
|
+
def _link(
|
|
83
|
+
self,
|
|
84
|
+
claim_id: uuid.UUID,
|
|
85
|
+
belief_id: uuid.UUID,
|
|
86
|
+
role: LinkRole,
|
|
87
|
+
edge_type: EdgeType,
|
|
88
|
+
) -> None:
|
|
89
|
+
claim = self.claims.get(claim_id)
|
|
90
|
+
if claim is None:
|
|
91
|
+
raise NodeNotFoundError(f"claim {claim_id} does not exist")
|
|
92
|
+
if self.beliefs.get(belief_id) is None:
|
|
93
|
+
raise NodeNotFoundError(f"belief {belief_id} does not exist")
|
|
94
|
+
|
|
95
|
+
opposite = LinkRole.CONTRADICTS if role == LinkRole.SUPPORTS else LinkRole.SUPPORTS
|
|
96
|
+
already = {b.id for b in self.claims.beliefs_for_claim(claim_id, opposite)}
|
|
97
|
+
if belief_id in already:
|
|
98
|
+
raise RootMemoryError(
|
|
99
|
+
f"belief {belief_id} is already registered as {opposite} on this claim"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self.claims.link_belief(claim_id, belief_id, role)
|
|
103
|
+
# The link table drives the services; the edge is the audit trail.
|
|
104
|
+
source, target = NodeRef.belief(belief_id), NodeRef.claim(claim_id)
|
|
105
|
+
if not self.edges.exists(source, target, edge_type):
|
|
106
|
+
self.edges.create(source, target, edge_type)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# reading claims
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def build_context(self, claim_id: uuid.UUID) -> ClaimContext:
|
|
113
|
+
"""Independence analysis of both sides of a claim."""
|
|
114
|
+
claim = self.claims.get(claim_id)
|
|
115
|
+
if claim is None:
|
|
116
|
+
raise NodeNotFoundError(f"claim {claim_id} does not exist")
|
|
117
|
+
|
|
118
|
+
supporting = self.claims.beliefs_for_claim(claim_id, LinkRole.SUPPORTS)
|
|
119
|
+
contradicting = self.claims.beliefs_for_claim(claim_id, LinkRole.CONTRADICTS)
|
|
120
|
+
return ClaimContext(
|
|
121
|
+
claim=claim,
|
|
122
|
+
support=self.independence.analyze(supporting),
|
|
123
|
+
contradiction=self.independence.analyze(contradicting),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def sync_claim_metrics(self, context: ClaimContext) -> Claim:
|
|
127
|
+
"""Write the independence numbers back onto the claim row.
|
|
128
|
+
|
|
129
|
+
Claim *status* is deliberately not set here - only the PromotionService
|
|
130
|
+
may move a claim into or out of confirmed (spec section 46).
|
|
131
|
+
"""
|
|
132
|
+
claim = context.claim
|
|
133
|
+
claim.independent_support_count = context.support.independent_source_count
|
|
134
|
+
claim.independent_contradiction_count = context.contradiction.independent_source_count
|
|
135
|
+
claim.confidence = context.support.aggregate_confidence
|
|
136
|
+
self.session.flush()
|
|
137
|
+
return claim
|
|
138
|
+
|
|
139
|
+
def unresolved_contradictions(self, context: ClaimContext, threshold: float) -> list[uuid.UUID]:
|
|
140
|
+
"""Contradicting beliefs strong enough to block promotion.
|
|
141
|
+
|
|
142
|
+
Retracted beliefs are already filtered out by the independence report.
|
|
143
|
+
"""
|
|
144
|
+
beliefs = self.beliefs.get_many(context.contradiction.belief_ids)
|
|
145
|
+
return [b.id for b in beliefs if b.confidence >= threshold]
|
|
146
|
+
|
|
147
|
+
def mark_contradicted(self, claim: Claim) -> Claim:
|
|
148
|
+
return self.claims.set_status(claim, ClaimStatus.CONTRADICTED)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""DecisionService: downstream actions and the claims they rest on.
|
|
2
|
+
|
|
3
|
+
A decision is the point where memory turns into consequence, so its
|
|
4
|
+
dependencies are recorded twice on purpose: in ``decision_dependencies`` for
|
|
5
|
+
fast lookup, and as ``depends_on`` provenance edges so a decision is reachable
|
|
6
|
+
by the same graph traversal as everything else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
from rootmemory.errors import NodeNotFoundError
|
|
17
|
+
from rootmemory.models.decision import Decision
|
|
18
|
+
from rootmemory.models.enums import DecisionStatus, EdgeType
|
|
19
|
+
from rootmemory.models.node_ref import NodeRef
|
|
20
|
+
from rootmemory.repositories.agent_repo import AgentRepository
|
|
21
|
+
from rootmemory.repositories.claim_repo import ClaimRepository
|
|
22
|
+
from rootmemory.repositories.decision_repo import DecisionRepository
|
|
23
|
+
from rootmemory.repositories.edge_repo import EdgeRepository
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DecisionService:
|
|
27
|
+
def __init__(self, session: Session) -> None:
|
|
28
|
+
self.session = session
|
|
29
|
+
self.decisions = DecisionRepository(session)
|
|
30
|
+
self.claims = ClaimRepository(session)
|
|
31
|
+
self.agents = AgentRepository(session)
|
|
32
|
+
self.edges = EdgeRepository(session)
|
|
33
|
+
|
|
34
|
+
def create_decision(
|
|
35
|
+
self,
|
|
36
|
+
agent_id: uuid.UUID,
|
|
37
|
+
decision_type: str,
|
|
38
|
+
content: str,
|
|
39
|
+
claim_ids: Sequence[uuid.UUID] = (),
|
|
40
|
+
meta: dict | None = None,
|
|
41
|
+
) -> Decision:
|
|
42
|
+
if self.agents.get(agent_id) is None:
|
|
43
|
+
raise NodeNotFoundError(f"agent {agent_id} does not exist")
|
|
44
|
+
|
|
45
|
+
decision = self.decisions.create(
|
|
46
|
+
agent_id=agent_id, decision_type=decision_type, content=content, meta=meta or {}
|
|
47
|
+
)
|
|
48
|
+
for claim_id in claim_ids:
|
|
49
|
+
self.add_dependency(decision.id, claim_id)
|
|
50
|
+
return decision
|
|
51
|
+
|
|
52
|
+
def add_dependency(self, decision_id: uuid.UUID, claim_id: uuid.UUID) -> None:
|
|
53
|
+
if self.claims.get(claim_id) is None:
|
|
54
|
+
raise NodeNotFoundError(f"claim {claim_id} does not exist")
|
|
55
|
+
self.decisions.add_dependency(decision_id, claim_id)
|
|
56
|
+
source = NodeRef.decision(decision_id)
|
|
57
|
+
target = NodeRef.claim(claim_id)
|
|
58
|
+
if not self.edges.exists(source, target, EdgeType.DEPENDS_ON):
|
|
59
|
+
self.edges.create(source, target, EdgeType.DEPENDS_ON)
|
|
60
|
+
|
|
61
|
+
def set_status(self, decision_id: uuid.UUID, status: DecisionStatus) -> Decision:
|
|
62
|
+
decision = self.decisions.get(decision_id)
|
|
63
|
+
if decision is None:
|
|
64
|
+
raise NodeNotFoundError(f"decision {decision_id} does not exist")
|
|
65
|
+
return self.decisions.set_status(decision, status)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""GraphService: one snapshot of the whole memory, shaped for drawing.
|
|
2
|
+
|
|
3
|
+
The portal needs the entire graph in a single call, with each node already
|
|
4
|
+
carrying the numbers that matter: what a belief rests on, and how much of a
|
|
5
|
+
claim's support is genuinely independent.
|
|
6
|
+
|
|
7
|
+
Layout depth is computed here rather than in the browser, because the server
|
|
8
|
+
already owns the traversal rules. Observations sit at depth 0 and everything
|
|
9
|
+
else is one level above whatever it depends on, so a chain of echoes renders as
|
|
10
|
+
a tall thin stack over a single source - which is the shape the whole project
|
|
11
|
+
is about.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from sqlalchemy.orm import Session
|
|
21
|
+
|
|
22
|
+
from rootmemory.models.enums import CAUSAL_EDGE_TYPES, LinkRole, NodeType
|
|
23
|
+
from rootmemory.models.node_ref import NodeRef
|
|
24
|
+
from rootmemory.repositories.agent_repo import AgentRepository
|
|
25
|
+
from rootmemory.repositories.belief_repo import BeliefRepository
|
|
26
|
+
from rootmemory.repositories.claim_repo import ClaimRepository
|
|
27
|
+
from rootmemory.repositories.decision_repo import DecisionRepository
|
|
28
|
+
from rootmemory.repositories.edge_repo import EdgeRepository
|
|
29
|
+
from rootmemory.repositories.observation_repo import ObservationRepository
|
|
30
|
+
from rootmemory.services.contradiction_service import ContradictionService
|
|
31
|
+
from rootmemory.services.provenance_service import ProvenanceService
|
|
32
|
+
|
|
33
|
+
#: Guard against a pathological graph making layout loop forever.
|
|
34
|
+
MAX_DEPTH = 64
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class GraphNode:
|
|
39
|
+
id: uuid.UUID
|
|
40
|
+
type: NodeType
|
|
41
|
+
label: str
|
|
42
|
+
status: str
|
|
43
|
+
depth: int = 0
|
|
44
|
+
detail: dict = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"id": str(self.id),
|
|
49
|
+
"type": str(self.type),
|
|
50
|
+
"label": self.label,
|
|
51
|
+
"status": self.status,
|
|
52
|
+
"depth": self.depth,
|
|
53
|
+
"detail": self.detail,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class GraphEdge:
|
|
59
|
+
source: uuid.UUID
|
|
60
|
+
target: uuid.UUID
|
|
61
|
+
edge_type: str
|
|
62
|
+
|
|
63
|
+
def as_dict(self) -> dict[str, str]:
|
|
64
|
+
return {
|
|
65
|
+
"source": str(self.source),
|
|
66
|
+
"target": str(self.target),
|
|
67
|
+
"edge_type": self.edge_type,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class GraphSnapshot:
|
|
73
|
+
nodes: list[GraphNode] = field(default_factory=list)
|
|
74
|
+
edges: list[GraphEdge] = field(default_factory=list)
|
|
75
|
+
stats: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, object]:
|
|
78
|
+
return {
|
|
79
|
+
"nodes": [n.as_dict() for n in self.nodes],
|
|
80
|
+
"edges": [e.as_dict() for e in self.edges],
|
|
81
|
+
"stats": self.stats,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GraphService:
|
|
86
|
+
"""Assembles a drawable view of the whole graph."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, session: Session) -> None:
|
|
89
|
+
self.session = session
|
|
90
|
+
self.observations = ObservationRepository(session)
|
|
91
|
+
self.beliefs = BeliefRepository(session)
|
|
92
|
+
self.claims = ClaimRepository(session)
|
|
93
|
+
self.decisions = DecisionRepository(session)
|
|
94
|
+
self.agents = AgentRepository(session)
|
|
95
|
+
self.edges = EdgeRepository(session)
|
|
96
|
+
self.provenance = ProvenanceService(session)
|
|
97
|
+
self.contradictions = ContradictionService(session)
|
|
98
|
+
|
|
99
|
+
def snapshot(self) -> GraphSnapshot:
|
|
100
|
+
agent_names = {a.id: a.name for a in self.agents.list()}
|
|
101
|
+
observations = list(self.observations.list())
|
|
102
|
+
beliefs = list(self.beliefs.list())
|
|
103
|
+
claims = list(self.claims.list())
|
|
104
|
+
decisions = list(self.decisions.list())
|
|
105
|
+
|
|
106
|
+
nodes: list[GraphNode] = []
|
|
107
|
+
nodes.extend(self._observation_nodes(observations))
|
|
108
|
+
nodes.extend(self._belief_nodes(beliefs, agent_names))
|
|
109
|
+
nodes.extend(self._claim_nodes(claims))
|
|
110
|
+
nodes.extend(self._decision_nodes(decisions, agent_names))
|
|
111
|
+
|
|
112
|
+
edges = [
|
|
113
|
+
GraphEdge(source=e.from_node_id, target=e.to_node_id, edge_type=e.edge_type)
|
|
114
|
+
for e in self.edges.all_edges()
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
self._assign_depths(nodes, edges)
|
|
118
|
+
|
|
119
|
+
return GraphSnapshot(
|
|
120
|
+
nodes=nodes,
|
|
121
|
+
edges=edges,
|
|
122
|
+
stats=self._stats(observations, beliefs, claims, decisions, nodes),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# nodes
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _observation_nodes(self, observations: Sequence) -> list[GraphNode]:
|
|
130
|
+
return [
|
|
131
|
+
GraphNode(
|
|
132
|
+
id=o.id,
|
|
133
|
+
type=NodeType.OBSERVATION,
|
|
134
|
+
label=o.content,
|
|
135
|
+
status=str(o.validity_status),
|
|
136
|
+
detail={
|
|
137
|
+
"source_type": o.source_type,
|
|
138
|
+
"source_actor": o.source_actor,
|
|
139
|
+
"source_uri": o.source_uri,
|
|
140
|
+
"reliability_score": o.reliability_score,
|
|
141
|
+
"source_family_id": o.source_family_id,
|
|
142
|
+
"independence_key": o.independence_key,
|
|
143
|
+
"validity_reason": o.validity_reason,
|
|
144
|
+
"content": o.content,
|
|
145
|
+
"timestamp": o.timestamp.isoformat() if o.timestamp else None,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
for o in observations
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def _belief_nodes(self, beliefs: Sequence, agent_names: dict) -> list[GraphNode]:
|
|
152
|
+
nodes: list[GraphNode] = []
|
|
153
|
+
for b in beliefs:
|
|
154
|
+
roots = self.provenance.find_evidence_root_observations(NodeRef.belief(b.id))
|
|
155
|
+
nodes.append(
|
|
156
|
+
GraphNode(
|
|
157
|
+
id=b.id,
|
|
158
|
+
type=NodeType.BELIEF,
|
|
159
|
+
label=b.claim_text,
|
|
160
|
+
status=str(b.epistemic_status),
|
|
161
|
+
detail={
|
|
162
|
+
"agent": agent_names.get(b.agent_id, "unknown"),
|
|
163
|
+
"agent_id": str(b.agent_id),
|
|
164
|
+
"confidence": b.confidence,
|
|
165
|
+
"visibility": str(b.visibility),
|
|
166
|
+
"claim_key": b.normalized_claim_key,
|
|
167
|
+
"llm_generated": b.llm_generated,
|
|
168
|
+
"evidence_roots": [str(o.id) for o in roots],
|
|
169
|
+
"independent_source_count": len({o.independence_key for o in roots}),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return nodes
|
|
174
|
+
|
|
175
|
+
def _claim_nodes(self, claims: Sequence) -> list[GraphNode]:
|
|
176
|
+
nodes: list[GraphNode] = []
|
|
177
|
+
for claim in claims:
|
|
178
|
+
context = self.contradictions.build_context(claim.id)
|
|
179
|
+
nodes.append(
|
|
180
|
+
GraphNode(
|
|
181
|
+
id=claim.id,
|
|
182
|
+
type=NodeType.CLAIM,
|
|
183
|
+
label=claim.canonical_text,
|
|
184
|
+
status=str(claim.status),
|
|
185
|
+
detail={
|
|
186
|
+
"claim_key": claim.claim_key,
|
|
187
|
+
"confidence": claim.confidence,
|
|
188
|
+
"promoted_at": (
|
|
189
|
+
claim.promoted_at.isoformat() if claim.promoted_at else None
|
|
190
|
+
),
|
|
191
|
+
# The two numbers the whole system exists to keep apart.
|
|
192
|
+
"support": {
|
|
193
|
+
"belief_count": context.support.belief_count,
|
|
194
|
+
"agreeing_agents": context.support.agreeing_agent_count,
|
|
195
|
+
"independent_sources": context.support.independent_source_count,
|
|
196
|
+
"confidence": context.support.aggregate_confidence,
|
|
197
|
+
"naive_average_confidence": context.support.naive_confidence,
|
|
198
|
+
},
|
|
199
|
+
"contradictions": {
|
|
200
|
+
"belief_count": context.contradiction.belief_count,
|
|
201
|
+
"agreeing_agents": context.contradiction.agreeing_agent_count,
|
|
202
|
+
"independent_sources": (
|
|
203
|
+
context.contradiction.independent_source_count
|
|
204
|
+
),
|
|
205
|
+
"confidence": context.contradiction.aggregate_confidence,
|
|
206
|
+
},
|
|
207
|
+
"shares_single_source": context.support.shares_single_source,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return nodes
|
|
212
|
+
|
|
213
|
+
def _decision_nodes(self, decisions: Sequence, agent_names: dict) -> list[GraphNode]:
|
|
214
|
+
return [
|
|
215
|
+
GraphNode(
|
|
216
|
+
id=d.id,
|
|
217
|
+
type=NodeType.DECISION,
|
|
218
|
+
label=d.content,
|
|
219
|
+
status=str(d.status),
|
|
220
|
+
detail={
|
|
221
|
+
"agent": agent_names.get(d.agent_id, "unknown"),
|
|
222
|
+
"decision_type": d.decision_type,
|
|
223
|
+
"depends_on": [str(c) for c in self.decisions.claims_for_decision(d.id)],
|
|
224
|
+
"meta": d.meta,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
for d in decisions
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# layout
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _assign_depths(self, nodes: list[GraphNode], edges: list[GraphEdge]) -> None:
|
|
235
|
+
"""Depth = one above the deepest thing a node rests on.
|
|
236
|
+
|
|
237
|
+
Causal edges (derived_from, depends_on) point from a dependent node to
|
|
238
|
+
its dependency, so for those a node's parents are the targets of its
|
|
239
|
+
outgoing edges.
|
|
240
|
+
|
|
241
|
+
supports/contradicts read the other way round - they point from a
|
|
242
|
+
belief at the claim it argues about - so they are followed in reverse
|
|
243
|
+
here. Otherwise a claim would have no outgoing edges at all and would
|
|
244
|
+
be drawn underneath the beliefs it summarises.
|
|
245
|
+
"""
|
|
246
|
+
parents: dict[uuid.UUID, list[uuid.UUID]] = {}
|
|
247
|
+
for edge in edges:
|
|
248
|
+
if edge.edge_type in CAUSAL_EDGE_TYPES:
|
|
249
|
+
parents.setdefault(edge.source, []).append(edge.target)
|
|
250
|
+
else:
|
|
251
|
+
# belief --supports--> claim: the claim sits above the belief.
|
|
252
|
+
parents.setdefault(edge.target, []).append(edge.source)
|
|
253
|
+
|
|
254
|
+
by_id = {node.id: node for node in nodes}
|
|
255
|
+
resolved: dict[uuid.UUID, int] = {}
|
|
256
|
+
|
|
257
|
+
def depth_of(node_id: uuid.UUID, seen: frozenset[uuid.UUID]) -> int:
|
|
258
|
+
if node_id in resolved:
|
|
259
|
+
return resolved[node_id]
|
|
260
|
+
# A cycle should be impossible, but layout must never hang.
|
|
261
|
+
if node_id in seen or len(seen) > MAX_DEPTH:
|
|
262
|
+
return 0
|
|
263
|
+
node = by_id.get(node_id)
|
|
264
|
+
if node is None or node.type == NodeType.OBSERVATION:
|
|
265
|
+
return 0
|
|
266
|
+
own_parents = parents.get(node_id, [])
|
|
267
|
+
if not own_parents:
|
|
268
|
+
return 0
|
|
269
|
+
deeper = seen | {node_id}
|
|
270
|
+
value = 1 + max(depth_of(parent, deeper) for parent in own_parents)
|
|
271
|
+
resolved[node_id] = value
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
for node in nodes:
|
|
275
|
+
node.depth = depth_of(node.id, frozenset())
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# stats
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def _stats(
|
|
282
|
+
self,
|
|
283
|
+
observations: Sequence,
|
|
284
|
+
beliefs: Sequence,
|
|
285
|
+
claims: Sequence,
|
|
286
|
+
decisions: Sequence,
|
|
287
|
+
nodes: Sequence[GraphNode],
|
|
288
|
+
) -> dict:
|
|
289
|
+
claim_nodes = [n for n in nodes if n.type == NodeType.CLAIM]
|
|
290
|
+
return {
|
|
291
|
+
"observations": len(observations),
|
|
292
|
+
"valid_observations": sum(1 for o in observations if o.validity_status == "valid"),
|
|
293
|
+
"beliefs": len(beliefs),
|
|
294
|
+
"claims": len(claims),
|
|
295
|
+
"decisions": len(decisions),
|
|
296
|
+
"independent_sources": len({o.independence_key for o in observations}),
|
|
297
|
+
"confirmed_claims": sum(1 for c in claims if c.status == "confirmed"),
|
|
298
|
+
"claims_on_a_single_source": sum(
|
|
299
|
+
1 for n in claim_nodes if n.detail.get("shares_single_source")
|
|
300
|
+
),
|
|
301
|
+
"decisions_needing_review": sum(1 for d in decisions if d.status == "needs_review"),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def claim_supporters(self, claim_id: uuid.UUID) -> dict[str, list[str]]:
|
|
305
|
+
"""Which beliefs sit on each side of a claim, for highlighting."""
|
|
306
|
+
return {
|
|
307
|
+
"supports": [
|
|
308
|
+
str(b.id) for b in self.claims.beliefs_for_claim(claim_id, LinkRole.SUPPORTS)
|
|
309
|
+
],
|
|
310
|
+
"contradicts": [
|
|
311
|
+
str(b.id) for b in self.claims.beliefs_for_claim(claim_id, LinkRole.CONTRADICTS)
|
|
312
|
+
],
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def causal_edge_types(self) -> list[str]:
|
|
316
|
+
return sorted(str(e) for e in CAUSAL_EDGE_TYPES)
|