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