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,39 @@
1
+ """Beliefs: agent interpretations of evidence (spec section 11)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import Float, ForeignKey, String, Text
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column, utcnow
12
+ from rootmemory.models.enums import EpistemicStatus, Visibility
13
+
14
+
15
+ class Belief(Base):
16
+ """An interpretation produced by one agent. A belief is not a fact.
17
+
18
+ Every belief must have at least one causal parent (an observation or
19
+ another belief). A belief with no provenance can never enter shared memory
20
+ (spec section 16).
21
+ """
22
+
23
+ __tablename__ = "beliefs"
24
+
25
+ id: Mapped[uuid.UUID] = pk_column()
26
+ agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), index=True)
27
+ claim_text: Mapped[str] = mapped_column(Text)
28
+ normalized_claim_key: Mapped[str] = mapped_column(String(300), index=True)
29
+ confidence: Mapped[float] = mapped_column(Float, default=0.5)
30
+ epistemic_status: Mapped[str] = mapped_column(
31
+ String(20), default=EpistemicStatus.INFERRED, index=True
32
+ )
33
+ visibility: Mapped[str] = mapped_column(String(20), default=Visibility.PRIVATE, index=True)
34
+ # True when the belief text was produced by an LLM rather than by
35
+ # deterministic code (spec section 47).
36
+ llm_generated: Mapped[bool] = mapped_column(default=False)
37
+ created_at: Mapped[datetime] = timestamp_column()
38
+ updated_at: Mapped[datetime] = timestamp_column(onupdate=utcnow)
39
+ meta: Mapped[dict] = json_column()
@@ -0,0 +1,52 @@
1
+ """Claims: normalized propositions that beliefs support or contradict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+ from rootmemory.models.enums import ClaimStatus, LinkRole
13
+
14
+
15
+ class Claim(Base):
16
+ """A proposition that several beliefs may map onto.
17
+
18
+ Only the PromotionService may set status to confirmed (spec section 46).
19
+ """
20
+
21
+ __tablename__ = "claims"
22
+
23
+ id: Mapped[uuid.UUID] = pk_column()
24
+ claim_key: Mapped[str] = mapped_column(String(300), unique=True, index=True)
25
+ canonical_text: Mapped[str] = mapped_column(Text)
26
+ status: Mapped[str] = mapped_column(String(20), default=ClaimStatus.UNCONFIRMED, index=True)
27
+ confidence: Mapped[float] = mapped_column(Float, default=0.0)
28
+ independent_support_count: Mapped[int] = mapped_column(Integer, default=0)
29
+ independent_contradiction_count: Mapped[int] = mapped_column(Integer, default=0)
30
+ # Timezone-aware like every other timestamp: PromotionService writes an
31
+ # aware datetime, and a naive column would silently strip the offset on
32
+ # PostgreSQL (SQLite does not notice, which is how this hid).
33
+ promoted_at: Mapped[datetime | None] = mapped_column(
34
+ DateTime(timezone=True), default=None
35
+ )
36
+ created_at: Mapped[datetime] = timestamp_column()
37
+ meta: Mapped[dict] = json_column()
38
+
39
+
40
+ class ClaimBeliefLink(Base):
41
+ """Association between a claim and a belief that supports or contradicts it."""
42
+
43
+ __tablename__ = "claim_belief_links"
44
+ __table_args__ = (
45
+ UniqueConstraint("claim_id", "belief_id", "role", name="claim_id_belief_id_role"),
46
+ )
47
+
48
+ id: Mapped[uuid.UUID] = pk_column()
49
+ claim_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("claims.id"), index=True)
50
+ belief_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("beliefs.id"), index=True)
51
+ role: Mapped[str] = mapped_column(String(20), default=LinkRole.SUPPORTS, index=True)
52
+ created_at: Mapped[datetime] = timestamp_column()
@@ -0,0 +1,38 @@
1
+ """Decisions: downstream outputs that depend on claims (spec section 14)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+ from rootmemory.models.enums import DecisionStatus
13
+
14
+
15
+ class Decision(Base):
16
+ """An action or conclusion an agent produced from one or more claims."""
17
+
18
+ __tablename__ = "decisions"
19
+
20
+ id: Mapped[uuid.UUID] = pk_column()
21
+ agent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("agents.id"), index=True)
22
+ decision_type: Mapped[str] = mapped_column(String(100), index=True)
23
+ content: Mapped[str] = mapped_column(Text)
24
+ status: Mapped[str] = mapped_column(String(20), default=DecisionStatus.ACTIVE, index=True)
25
+ created_at: Mapped[datetime] = timestamp_column()
26
+ meta: Mapped[dict] = json_column()
27
+
28
+
29
+ class DecisionDependency(Base):
30
+ """Explicit link from a decision to a claim it relies on."""
31
+
32
+ __tablename__ = "decision_dependencies"
33
+ __table_args__ = (UniqueConstraint("decision_id", "claim_id", name="decision_id_claim_id"),)
34
+
35
+ id: Mapped[uuid.UUID] = pk_column()
36
+ decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("decisions.id"), index=True)
37
+ claim_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("claims.id"), index=True)
38
+ created_at: Mapped[datetime] = timestamp_column()
@@ -0,0 +1,43 @@
1
+ """Provenance edges: the causal dependency graph (spec section 12)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import Float, Index, String, UniqueConstraint, Uuid
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+
13
+
14
+ class ProvenanceEdge(Base):
15
+ """A directed edge between two knowledge nodes.
16
+
17
+ Edges are polymorphic (they connect observations, beliefs, claims and
18
+ decisions) so the endpoints are stored as (id, type) pairs rather than as
19
+ foreign keys into a single table.
20
+
21
+ Edges are append-only in normal operation: nothing in the service layer
22
+ updates or deletes them.
23
+ """
24
+
25
+ __tablename__ = "provenance_edges"
26
+ __table_args__ = (
27
+ UniqueConstraint(
28
+ "from_node_id", "to_node_id", "edge_type", name="from_node_id_to_node_id_edge_type"
29
+ ),
30
+ Index("ix_provenance_edges_from_node_id", "from_node_id"),
31
+ Index("ix_provenance_edges_to_node_id", "to_node_id"),
32
+ Index("ix_provenance_edges_edge_type", "edge_type"),
33
+ )
34
+
35
+ id: Mapped[uuid.UUID] = pk_column()
36
+ from_node_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True))
37
+ from_node_type: Mapped[str] = mapped_column(String(20))
38
+ to_node_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True))
39
+ to_node_type: Mapped[str] = mapped_column(String(20))
40
+ edge_type: Mapped[str] = mapped_column(String(20))
41
+ weight: Mapped[float] = mapped_column(Float, default=1.0)
42
+ created_at: Mapped[datetime] = timestamp_column()
43
+ meta: Mapped[dict] = json_column()
@@ -0,0 +1,104 @@
1
+ """Enumerations shared by the ORM models, schemas and services.
2
+
3
+ All of these are stored as plain strings so the schema is portable across
4
+ SQLite and PostgreSQL and so new values never require a migration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+
11
+
12
+ class NodeType(StrEnum):
13
+ """The four knowledge objects that can appear in the provenance graph."""
14
+
15
+ OBSERVATION = "observation"
16
+ BELIEF = "belief"
17
+ CLAIM = "claim"
18
+ DECISION = "decision"
19
+
20
+
21
+ class EdgeType(StrEnum):
22
+ """Provenance edge types.
23
+
24
+ Direction convention, applied consistently across the whole system:
25
+
26
+ an edge always points from the *dependent* node to the node it
27
+ *depends on* (child -> parent).
28
+
29
+ belief --derived_from--> observation | belief
30
+ decision --depends_on----> claim
31
+ belief --supports-----> claim (annotation, see below)
32
+ belief --contradicts--> claim (annotation, see below)
33
+
34
+ ``derived_from`` and ``depends_on`` are the *causal* edges: they are the
35
+ ones walked by evidence-root discovery and by cycle detection.
36
+
37
+ ``supports`` / ``contradicts`` are recorded for audit and read naturally
38
+ left-to-right ("belief supports claim"). Because a claim is downstream of
39
+ the beliefs that support it, they are NOT walked as causal parent edges;
40
+ claim membership is resolved through ``claim_belief_links``.
41
+ """
42
+
43
+ DERIVED_FROM = "derived_from"
44
+ SUPPORTS = "supports"
45
+ CONTRADICTS = "contradicts"
46
+ DEPENDS_ON = "depends_on"
47
+
48
+
49
+ #: Edge types that express causal ancestry (dependent -> dependency).
50
+ CAUSAL_EDGE_TYPES: frozenset[str] = frozenset({EdgeType.DERIVED_FROM, EdgeType.DEPENDS_ON})
51
+
52
+
53
+ class ValidityStatus(StrEnum):
54
+ """Validity of an observation. Observations are immutable; only this moves."""
55
+
56
+ VALID = "valid"
57
+ INVALID = "invalid"
58
+ DISPUTED = "disputed"
59
+ UNKNOWN = "unknown"
60
+
61
+
62
+ class EpistemicStatus(StrEnum):
63
+ """How strongly an agent currently holds a belief."""
64
+
65
+ INFERRED = "inferred"
66
+ BELIEVED = "believed"
67
+ UNCERTAIN = "uncertain"
68
+ CONFIRMED = "confirmed"
69
+ CONTRADICTED = "contradicted"
70
+ RETRACTED = "retracted"
71
+
72
+
73
+ class Visibility(StrEnum):
74
+ """Private reasoning vs shared memory (spec section 29)."""
75
+
76
+ PRIVATE = "private"
77
+ CANDIDATE_SHARED = "candidate_shared"
78
+ SHARED = "shared"
79
+
80
+
81
+ class ClaimStatus(StrEnum):
82
+ UNCONFIRMED = "unconfirmed"
83
+ PROVISIONAL = "provisional"
84
+ CONFIRMED = "confirmed"
85
+ CONTRADICTED = "contradicted"
86
+ RETRACTED = "retracted"
87
+
88
+
89
+ class DecisionStatus(StrEnum):
90
+ ACTIVE = "active"
91
+ NEEDS_REVIEW = "needs_review"
92
+ INVALIDATED = "invalidated"
93
+
94
+
95
+ class LinkRole(StrEnum):
96
+ """How a belief relates to a claim."""
97
+
98
+ SUPPORTS = "supports"
99
+ CONTRADICTS = "contradicts"
100
+
101
+
102
+ class PromotionResultType(StrEnum):
103
+ PROMOTED = "promoted"
104
+ REJECTED = "rejected"
@@ -0,0 +1,42 @@
1
+ """NodeRef: a typed pointer to any node in the provenance graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ from rootmemory.models.enums import NodeType
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class NodeRef:
13
+ """A (id, type) pair identifying one node.
14
+
15
+ The provenance graph spans four tables, so edges and traversal results
16
+ address nodes by this pair rather than by a bare id.
17
+ """
18
+
19
+ id: uuid.UUID
20
+ type: NodeType
21
+
22
+ def __str__(self) -> str:
23
+ return f"{self.type}:{self.id}"
24
+
25
+ def as_dict(self) -> dict[str, str]:
26
+ return {"id": str(self.id), "type": str(self.type)}
27
+
28
+ @classmethod
29
+ def observation(cls, node_id: uuid.UUID) -> NodeRef:
30
+ return cls(node_id, NodeType.OBSERVATION)
31
+
32
+ @classmethod
33
+ def belief(cls, node_id: uuid.UUID) -> NodeRef:
34
+ return cls(node_id, NodeType.BELIEF)
35
+
36
+ @classmethod
37
+ def claim(cls, node_id: uuid.UUID) -> NodeRef:
38
+ return cls(node_id, NodeType.CLAIM)
39
+
40
+ @classmethod
41
+ def decision(cls, node_id: uuid.UUID) -> NodeRef:
42
+ return cls(node_id, NodeType.DECISION)
@@ -0,0 +1,51 @@
1
+ """Observations: immutable external evidence (spec section 10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import Float, ForeignKey, String, Text
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+ from rootmemory.models.enums import ValidityStatus
13
+
14
+
15
+ class Observation(Base):
16
+ """A raw piece of evidence from outside the system.
17
+
18
+ Observations are append-only. When one turns out to be wrong its
19
+ ``validity_status`` changes; the row itself is never edited or deleted, so
20
+ history stays reconstructable.
21
+ """
22
+
23
+ __tablename__ = "observations"
24
+
25
+ id: Mapped[uuid.UUID] = pk_column()
26
+ source_type: Mapped[str] = mapped_column(String(100), index=True)
27
+ source_uri: Mapped[str | None] = mapped_column(String(1000), default=None)
28
+ source_actor: Mapped[str | None] = mapped_column(String(300), default=None)
29
+ content: Mapped[str] = mapped_column(Text)
30
+ content_hash: Mapped[str] = mapped_column(String(64), index=True)
31
+ timestamp: Mapped[datetime] = timestamp_column()
32
+ created_by_agent_id: Mapped[uuid.UUID | None] = mapped_column(
33
+ ForeignKey("agents.id"), default=None, index=True
34
+ )
35
+ validity_status: Mapped[str] = mapped_column(
36
+ String(20), default=ValidityStatus.VALID, index=True
37
+ )
38
+ validity_reason: Mapped[str | None] = mapped_column(String(2000), default=None)
39
+ reliability_score: Mapped[float] = mapped_column(Float, default=1.0)
40
+ # Two observations that copy the same original source share a family id and
41
+ # therefore count as ONE independent source (spec section 20).
42
+ source_family_id: Mapped[str | None] = mapped_column(String(200), default=None, index=True)
43
+ created_at: Mapped[datetime] = timestamp_column()
44
+ meta: Mapped[dict] = json_column()
45
+
46
+ @property
47
+ def independence_key(self) -> str:
48
+ """The key this observation contributes to an independence count."""
49
+ if self.source_family_id:
50
+ return f"family:{self.source_family_id}"
51
+ return f"obs:{self.id}"
rootmemory/py.typed ADDED
File without changes
@@ -0,0 +1,19 @@
1
+ """Repository layer: all SQL lives here, no business rules."""
2
+
3
+ from rootmemory.repositories.agent_repo import AgentRepository
4
+ from rootmemory.repositories.audit_repo import AuditRepository
5
+ from rootmemory.repositories.belief_repo import BeliefRepository
6
+ from rootmemory.repositories.claim_repo import ClaimRepository
7
+ from rootmemory.repositories.decision_repo import DecisionRepository
8
+ from rootmemory.repositories.edge_repo import EdgeRepository
9
+ from rootmemory.repositories.observation_repo import ObservationRepository
10
+
11
+ __all__ = [
12
+ "AgentRepository",
13
+ "AuditRepository",
14
+ "BeliefRepository",
15
+ "ClaimRepository",
16
+ "DecisionRepository",
17
+ "EdgeRepository",
18
+ "ObservationRepository",
19
+ ]
@@ -0,0 +1,44 @@
1
+ """Data access for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Sequence
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from rootmemory.models.agent import Agent
12
+
13
+
14
+ class AgentRepository:
15
+ def __init__(self, session: Session) -> None:
16
+ self.session = session
17
+
18
+ def create(
19
+ self,
20
+ name: str,
21
+ role: str = "",
22
+ description: str = "",
23
+ trust_score: float = 0.5,
24
+ meta: dict | None = None,
25
+ ) -> Agent:
26
+ agent = Agent(
27
+ name=name,
28
+ role=role,
29
+ description=description,
30
+ trust_score=trust_score,
31
+ meta=meta or {},
32
+ )
33
+ self.session.add(agent)
34
+ self.session.flush()
35
+ return agent
36
+
37
+ def get(self, agent_id: uuid.UUID) -> Agent | None:
38
+ return self.session.get(Agent, agent_id)
39
+
40
+ def get_by_name(self, name: str) -> Agent | None:
41
+ return self.session.execute(select(Agent).where(Agent.name == name)).scalar_one_or_none()
42
+
43
+ def list(self) -> Sequence[Agent]:
44
+ return self.session.execute(select(Agent).order_by(Agent.created_at)).scalars().all()
@@ -0,0 +1,43 @@
1
+ """Data access for promotion audits and repair reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Sequence
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from rootmemory.models.audit import PromotionAudit, RepairReport
12
+
13
+
14
+ class AuditRepository:
15
+ def __init__(self, session: Session) -> None:
16
+ self.session = session
17
+
18
+ def record_promotion(self, **fields: object) -> PromotionAudit:
19
+ audit = PromotionAudit(**fields)
20
+ self.session.add(audit)
21
+ self.session.flush()
22
+ return audit
23
+
24
+ def promotions_for_claim(self, claim_id: uuid.UUID) -> Sequence[PromotionAudit]:
25
+ stmt = (
26
+ select(PromotionAudit)
27
+ .where(PromotionAudit.claim_id == claim_id)
28
+ .order_by(PromotionAudit.evaluated_at)
29
+ )
30
+ return self.session.execute(stmt).scalars().all()
31
+
32
+ def record_repair(self, **fields: object) -> RepairReport:
33
+ report = RepairReport(**fields)
34
+ self.session.add(report)
35
+ self.session.flush()
36
+ return report
37
+
38
+ def get_repair(self, report_id: uuid.UUID) -> RepairReport | None:
39
+ return self.session.get(RepairReport, report_id)
40
+
41
+ def list_repairs(self) -> Sequence[RepairReport]:
42
+ stmt = select(RepairReport).order_by(RepairReport.created_at)
43
+ return self.session.execute(stmt).scalars().all()
@@ -0,0 +1,63 @@
1
+ """Data access for beliefs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Iterable, Sequence
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from rootmemory.models.belief import Belief
12
+ from rootmemory.models.enums import EpistemicStatus, Visibility
13
+
14
+
15
+ class BeliefRepository:
16
+ def __init__(self, session: Session) -> None:
17
+ self.session = session
18
+
19
+ def create(
20
+ self,
21
+ agent_id: uuid.UUID,
22
+ claim_text: str,
23
+ normalized_claim_key: str,
24
+ confidence: float,
25
+ epistemic_status: EpistemicStatus = EpistemicStatus.INFERRED,
26
+ visibility: Visibility = Visibility.PRIVATE,
27
+ llm_generated: bool = False,
28
+ meta: dict | None = None,
29
+ ) -> Belief:
30
+ belief = Belief(
31
+ agent_id=agent_id,
32
+ claim_text=claim_text,
33
+ normalized_claim_key=normalized_claim_key,
34
+ confidence=confidence,
35
+ epistemic_status=str(epistemic_status),
36
+ visibility=str(visibility),
37
+ llm_generated=llm_generated,
38
+ meta=meta or {},
39
+ )
40
+ self.session.add(belief)
41
+ self.session.flush()
42
+ return belief
43
+
44
+ def get(self, belief_id: uuid.UUID) -> Belief | None:
45
+ return self.session.get(Belief, belief_id)
46
+
47
+ def get_many(self, belief_ids: Iterable[uuid.UUID]) -> Sequence[Belief]:
48
+ ids = list(belief_ids)
49
+ if not ids:
50
+ return []
51
+ stmt = select(Belief).where(Belief.id.in_(ids))
52
+ return self.session.execute(stmt).scalars().all()
53
+
54
+ def list_by_claim_key(self, claim_key: str) -> Sequence[Belief]:
55
+ stmt = select(Belief).where(Belief.normalized_claim_key == claim_key)
56
+ return self.session.execute(stmt).scalars().all()
57
+
58
+ def list_by_agent(self, agent_id: uuid.UUID) -> Sequence[Belief]:
59
+ stmt = select(Belief).where(Belief.agent_id == agent_id).order_by(Belief.created_at)
60
+ return self.session.execute(stmt).scalars().all()
61
+
62
+ def list(self) -> Sequence[Belief]:
63
+ return self.session.execute(select(Belief).order_by(Belief.created_at)).scalars().all()
@@ -0,0 +1,102 @@
1
+ """Data access for claims and claim/belief links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Sequence
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from rootmemory.models.belief import Belief
12
+ from rootmemory.models.claim import Claim, ClaimBeliefLink
13
+ from rootmemory.models.enums import ClaimStatus, LinkRole
14
+
15
+
16
+ class ClaimRepository:
17
+ def __init__(self, session: Session) -> None:
18
+ self.session = session
19
+
20
+ def create(
21
+ self,
22
+ claim_key: str,
23
+ canonical_text: str,
24
+ meta: dict | None = None,
25
+ ) -> Claim:
26
+ claim = Claim(claim_key=claim_key, canonical_text=canonical_text, meta=meta or {})
27
+ self.session.add(claim)
28
+ self.session.flush()
29
+ return claim
30
+
31
+ def get(self, claim_id: uuid.UUID) -> Claim | None:
32
+ return self.session.get(Claim, claim_id)
33
+
34
+ def get_for_update(self, claim_id: uuid.UUID) -> Claim | None:
35
+ """Read a claim and hold a row lock until the transaction ends.
36
+
37
+ The promotion gate reads a claim's support, decides, then writes the
38
+ result. Without this lock two workers can interleave those steps and
39
+ confirm a claim on evidence the other one is retracting.
40
+
41
+ SQLAlchemy emits FOR UPDATE on PostgreSQL and omits it on SQLite, which
42
+ has no row locks and serializes writers anyway - so this is correct on
43
+ both without a dialect branch.
44
+ """
45
+ stmt = select(Claim).where(Claim.id == claim_id).with_for_update()
46
+ return self.session.execute(stmt).scalar_one_or_none()
47
+
48
+ def get_by_key(self, claim_key: str) -> Claim | None:
49
+ stmt = select(Claim).where(Claim.claim_key == claim_key)
50
+ return self.session.execute(stmt).scalar_one_or_none()
51
+
52
+ def get_or_create(self, claim_key: str, canonical_text: str) -> Claim:
53
+ existing = self.get_by_key(claim_key)
54
+ if existing is not None:
55
+ return existing
56
+ return self.create(claim_key=claim_key, canonical_text=canonical_text)
57
+
58
+ def list(self) -> Sequence[Claim]:
59
+ return self.session.execute(select(Claim).order_by(Claim.created_at)).scalars().all()
60
+
61
+ def link_belief(
62
+ self, claim_id: uuid.UUID, belief_id: uuid.UUID, role: LinkRole
63
+ ) -> ClaimBeliefLink:
64
+ existing = self.session.execute(
65
+ select(ClaimBeliefLink).where(
66
+ ClaimBeliefLink.claim_id == claim_id,
67
+ ClaimBeliefLink.belief_id == belief_id,
68
+ ClaimBeliefLink.role == str(role),
69
+ )
70
+ ).scalar_one_or_none()
71
+ if existing is not None:
72
+ return existing
73
+ link = ClaimBeliefLink(claim_id=claim_id, belief_id=belief_id, role=str(role))
74
+ self.session.add(link)
75
+ self.session.flush()
76
+ return link
77
+
78
+ def beliefs_for_claim(self, claim_id: uuid.UUID, role: LinkRole) -> Sequence[Belief]:
79
+ stmt = (
80
+ select(Belief)
81
+ .join(ClaimBeliefLink, ClaimBeliefLink.belief_id == Belief.id)
82
+ .where(ClaimBeliefLink.claim_id == claim_id, ClaimBeliefLink.role == str(role))
83
+ .order_by(Belief.created_at)
84
+ )
85
+ return self.session.execute(stmt).scalars().all()
86
+
87
+ def claims_for_beliefs(self, belief_ids: Sequence[uuid.UUID]) -> Sequence[Claim]:
88
+ """Every claim any of these beliefs supports or contradicts."""
89
+ if not belief_ids:
90
+ return []
91
+ stmt = (
92
+ select(Claim)
93
+ .join(ClaimBeliefLink, ClaimBeliefLink.claim_id == Claim.id)
94
+ .where(ClaimBeliefLink.belief_id.in_(list(belief_ids)))
95
+ .distinct()
96
+ )
97
+ return self.session.execute(stmt).scalars().all()
98
+
99
+ def set_status(self, claim: Claim, status: ClaimStatus) -> Claim:
100
+ claim.status = str(status)
101
+ self.session.flush()
102
+ return claim