realitydiff 0.4.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.
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from realitydiff.models import ClaimState, Evidence, Prediction
6
+
7
+ # Ignore retrieved_at / tiny note rewrites when deciding whether a watch is worth a commit.
8
+ MATERIAL_CONFIDENCE = 1.0
9
+ MATERIAL_WEIGHT = 0.08
10
+
11
+
12
+ def compact_belief(state: ClaimState) -> dict[str, Any]:
13
+ """High-compression snapshot for the model: keep ids, urls, scores; drop timestamps."""
14
+ return {
15
+ "statement": state.statement,
16
+ "refined_statement": state.refined_statement,
17
+ "confidence": state.confidence,
18
+ "summary": _clip(state.summary, 420),
19
+ "evidence_for": [_compact_evidence(item) for item in state.evidence_for],
20
+ "evidence_against": [_compact_evidence(item) for item in state.evidence_against],
21
+ "unknowns": [
22
+ {"id": item.id, "question": _clip(item.question, 180), "why": _clip(item.why_it_matters, 140)}
23
+ for item in state.unknowns
24
+ ],
25
+ "predictions": [_compact_prediction(item) for item in state.predictions],
26
+ "outcome": state.outcome.model_dump(mode="json") if state.outcome else None,
27
+ }
28
+
29
+
30
+ def is_material_change(before: ClaimState, after: ClaimState) -> bool:
31
+ if abs(after.confidence - before.confidence) >= MATERIAL_CONFIDENCE:
32
+ return True
33
+ if before.statement.strip() != after.statement.strip():
34
+ return True
35
+ if _evidence_signature(before.evidence_for) != _evidence_signature(after.evidence_for):
36
+ return True
37
+ if _evidence_signature(before.evidence_against) != _evidence_signature(after.evidence_against):
38
+ return True
39
+ if {u.question.strip().lower() for u in before.unknowns} != {u.question.strip().lower() for u in after.unknowns}:
40
+ return True
41
+ if _prediction_signature(before.predictions) != _prediction_signature(after.predictions):
42
+ return True
43
+ if (before.outcome is None) != (after.outcome is None):
44
+ return True
45
+ if before.outcome and after.outcome and before.outcome.model_dump(mode="json") != after.outcome.model_dump(mode="json"):
46
+ return True
47
+ return False
48
+
49
+
50
+ def _compact_evidence(item: Evidence) -> dict[str, Any]:
51
+ return {
52
+ "id": item.id,
53
+ "statement": _clip(item.statement, 220),
54
+ "source_url": item.source_url,
55
+ "source_title": item.source_title,
56
+ "weight": round(item.weight, 2),
57
+ "notes": _clip(item.notes, 160),
58
+ }
59
+
60
+
61
+ def _compact_prediction(item: Prediction) -> dict[str, Any]:
62
+ return {
63
+ "id": item.id,
64
+ "statement": _clip(item.statement, 180),
65
+ "due": item.due,
66
+ "status": item.status,
67
+ "how_to_falsify": _clip(item.how_to_falsify, 140),
68
+ }
69
+
70
+
71
+ def _evidence_signature(items: list[Evidence]) -> set[tuple[str, str, str, float]]:
72
+ signed: set[tuple[str, str, str, float]] = set()
73
+ for item in items:
74
+ weight = round(item.weight / MATERIAL_WEIGHT) * MATERIAL_WEIGHT
75
+ signed.add(
76
+ (
77
+ item.id,
78
+ (item.source_url or "").rstrip("/").lower(),
79
+ item.statement.strip().lower()[:160],
80
+ round(weight, 2),
81
+ )
82
+ )
83
+ return signed
84
+
85
+
86
+ def _prediction_signature(items: list[Prediction]) -> set[tuple[str, str]]:
87
+ return {(item.statement.strip().lower()[:160], item.status) for item in items}
88
+
89
+
90
+ def _clip(text: str, limit: int) -> str:
91
+ text = (text or "").strip()
92
+ if len(text) <= limit:
93
+ return text
94
+ return text[: limit - 1].rstrip() + "…"
realitydiff/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """User config for installed copies of Reality Diff.
2
+
3
+ The installer can save settings to ``~/.config/realitydiff/env`` (or
4
+ ``$XDG_CONFIG_HOME/realitydiff/env``). Values already set in the
5
+ environment always win over the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ ALLOWED_KEYS = ("XAI_API_KEY", "REALITYDIFF_DB")
14
+
15
+
16
+ def config_file() -> Path:
17
+ override = os.environ.get("REALITYDIFF_CONFIG")
18
+ if override:
19
+ return Path(override).expanduser()
20
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
21
+ return Path(base) / "realitydiff" / "env"
22
+
23
+
24
+ def load_env(path: Path | None = None) -> None:
25
+ """Read KEY=VALUE lines into os.environ without overriding existing values."""
26
+ path = path or config_file()
27
+ try:
28
+ text = path.read_text(encoding="utf-8")
29
+ except OSError:
30
+ return
31
+ found: dict[str, str] = {}
32
+ for raw in text.splitlines():
33
+ line = raw.strip()
34
+ if not line or line.startswith("#") or "=" not in line:
35
+ continue
36
+ key, _, value = line.partition("=")
37
+ key = key.strip()
38
+ if key.startswith("export "):
39
+ key = key[len("export ") :].strip()
40
+ value = value.strip()
41
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
42
+ value = value[1:-1]
43
+ if key in ALLOWED_KEYS and value:
44
+ found[key] = value # last line wins, like sourcing the file
45
+ for key, value in found.items():
46
+ if not os.environ.get(key):
47
+ os.environ[key] = value
48
+
49
+
50
+ def default_db() -> str:
51
+ """Where claims are stored.
52
+
53
+ REALITYDIFF_DB wins. Inside a Reality Diff checkout (or wherever an old
54
+ ``data/realitydiff.sqlite`` already exists) keep that path; otherwise use
55
+ the user data dir so an installed CLI does not drop a data/ folder
56
+ wherever it is run.
57
+ """
58
+ env = os.environ.get("REALITYDIFF_DB")
59
+ if env:
60
+ return env
61
+ legacy = Path("data") / "realitydiff.sqlite"
62
+ in_checkout = Path("pyproject.toml").is_file() and Path("src", "realitydiff").is_dir()
63
+ if legacy.exists() or in_checkout:
64
+ return "data/realitydiff.sqlite"
65
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
66
+ return str(Path(base) / "realitydiff" / "realitydiff.sqlite")
realitydiff/diffing.py ADDED
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from realitydiff.models import ClaimDiff, ClaimState, Evidence, FieldChange, Prediction, Unknown
6
+
7
+
8
+ def _index_evidence(items: list[Evidence]) -> dict[str, Evidence]:
9
+ return {item.id: item for item in items}
10
+
11
+
12
+ def _index_unknowns(items: list[Unknown]) -> dict[str, Unknown]:
13
+ return {item.id: item for item in items}
14
+
15
+
16
+ def _index_predictions(items: list[Prediction]) -> dict[str, Prediction]:
17
+ return {item.id: item for item in items}
18
+
19
+
20
+ def _dump(value: Any) -> Any:
21
+ if hasattr(value, "model_dump"):
22
+ return value.model_dump(mode="json")
23
+ return value
24
+
25
+
26
+ def diff_states(
27
+ before: ClaimState | None,
28
+ after: ClaimState | None,
29
+ *,
30
+ from_id: str | None = None,
31
+ to_id: str | None = None,
32
+ reason: str = "",
33
+ ) -> ClaimDiff:
34
+ changes: list[FieldChange] = []
35
+ prev_conf = before.confidence if before else None
36
+ new_conf = after.confidence if after else None
37
+
38
+ if before is None and after is None:
39
+ return ClaimDiff(from_id=from_id, to_id=to_id)
40
+
41
+ if before is None and after is not None:
42
+ changes.append(
43
+ FieldChange(path="statement", before=None, after=after.statement, kind="added")
44
+ )
45
+ return ClaimDiff(
46
+ from_id=from_id,
47
+ to_id=to_id,
48
+ previous_confidence=None,
49
+ new_confidence=after.confidence,
50
+ confidence_delta=after.confidence,
51
+ reason=reason or "Initial belief state.",
52
+ changes=changes
53
+ + [
54
+ FieldChange(path=f"evidence_for/{item.id}", before=None, after=_dump(item), kind="added")
55
+ for item in after.evidence_for
56
+ ]
57
+ + [
58
+ FieldChange(
59
+ path=f"evidence_against/{item.id}", before=None, after=_dump(item), kind="added"
60
+ )
61
+ for item in after.evidence_against
62
+ ]
63
+ + [
64
+ FieldChange(path=f"unknowns/{item.id}", before=None, after=_dump(item), kind="added")
65
+ for item in after.unknowns
66
+ ]
67
+ + [
68
+ FieldChange(
69
+ path=f"predictions/{item.id}", before=None, after=_dump(item), kind="added"
70
+ )
71
+ for item in after.predictions
72
+ ],
73
+ )
74
+
75
+ if before is not None and after is None:
76
+ return ClaimDiff(
77
+ from_id=from_id,
78
+ to_id=to_id,
79
+ previous_confidence=before.confidence,
80
+ new_confidence=None,
81
+ confidence_delta=-before.confidence,
82
+ reason=reason or "Belief deleted.",
83
+ changes=[FieldChange(path="state", before=_dump(before), after=None, kind="removed")],
84
+ )
85
+
86
+ assert before is not None and after is not None
87
+
88
+ for field in ("statement", "refined_statement", "summary"):
89
+ left = getattr(before, field)
90
+ right = getattr(after, field)
91
+ if left != right:
92
+ changes.append(
93
+ FieldChange(
94
+ path=field,
95
+ before=left,
96
+ after=right,
97
+ kind="changed" if left and right else ("added" if right else "removed"),
98
+ )
99
+ )
100
+
101
+ if before.confidence != after.confidence:
102
+ changes.append(
103
+ FieldChange(
104
+ path="confidence",
105
+ before=before.confidence,
106
+ after=after.confidence,
107
+ kind="changed",
108
+ )
109
+ )
110
+
111
+ if (before.outcome is None) != (after.outcome is None) or (
112
+ before.outcome and after.outcome and before.outcome.model_dump(mode="json") != after.outcome.model_dump(mode="json")
113
+ ):
114
+ changes.append(
115
+ FieldChange(
116
+ path="outcome",
117
+ before=_dump(before.outcome) if before.outcome else None,
118
+ after=_dump(after.outcome) if after.outcome else None,
119
+ kind="added" if after.outcome and not before.outcome else ("removed" if before.outcome and not after.outcome else "changed"),
120
+ )
121
+ )
122
+
123
+ for bucket in ("evidence_for", "evidence_against"):
124
+ left_map = _index_evidence(getattr(before, bucket))
125
+ right_map = _index_evidence(getattr(after, bucket))
126
+ for key in sorted(set(left_map) | set(right_map)):
127
+ if key not in right_map:
128
+ changes.append(
129
+ FieldChange(
130
+ path=f"{bucket}/{key}",
131
+ before=_dump(left_map[key]),
132
+ after=None,
133
+ kind="removed",
134
+ )
135
+ )
136
+ elif key not in left_map:
137
+ changes.append(
138
+ FieldChange(
139
+ path=f"{bucket}/{key}",
140
+ before=None,
141
+ after=_dump(right_map[key]),
142
+ kind="added",
143
+ )
144
+ )
145
+ elif left_map[key].model_dump(mode="json") != right_map[key].model_dump(mode="json"):
146
+ changes.append(
147
+ FieldChange(
148
+ path=f"{bucket}/{key}",
149
+ before=_dump(left_map[key]),
150
+ after=_dump(right_map[key]),
151
+ kind="changed",
152
+ )
153
+ )
154
+
155
+ left_u = _index_unknowns(before.unknowns)
156
+ right_u = _index_unknowns(after.unknowns)
157
+ for key in sorted(set(left_u) | set(right_u)):
158
+ if key not in right_u:
159
+ changes.append(
160
+ FieldChange(path=f"unknowns/{key}", before=_dump(left_u[key]), after=None, kind="removed")
161
+ )
162
+ elif key not in left_u:
163
+ changes.append(
164
+ FieldChange(path=f"unknowns/{key}", before=None, after=_dump(right_u[key]), kind="added")
165
+ )
166
+ elif left_u[key].model_dump(mode="json") != right_u[key].model_dump(mode="json"):
167
+ changes.append(
168
+ FieldChange(
169
+ path=f"unknowns/{key}",
170
+ before=_dump(left_u[key]),
171
+ after=_dump(right_u[key]),
172
+ kind="changed",
173
+ )
174
+ )
175
+
176
+ left_p = _index_predictions(before.predictions)
177
+ right_p = _index_predictions(after.predictions)
178
+ for key in sorted(set(left_p) | set(right_p)):
179
+ if key not in right_p:
180
+ changes.append(
181
+ FieldChange(
182
+ path=f"predictions/{key}", before=_dump(left_p[key]), after=None, kind="removed"
183
+ )
184
+ )
185
+ elif key not in left_p:
186
+ changes.append(
187
+ FieldChange(
188
+ path=f"predictions/{key}", before=None, after=_dump(right_p[key]), kind="added"
189
+ )
190
+ )
191
+ elif left_p[key].model_dump(mode="json") != right_p[key].model_dump(mode="json"):
192
+ changes.append(
193
+ FieldChange(
194
+ path=f"predictions/{key}",
195
+ before=_dump(left_p[key]),
196
+ after=_dump(right_p[key]),
197
+ kind="changed",
198
+ )
199
+ )
200
+
201
+ delta = round(after.confidence - before.confidence, 1)
202
+ return ClaimDiff(
203
+ from_id=from_id,
204
+ to_id=to_id,
205
+ previous_confidence=before.confidence,
206
+ new_confidence=after.confidence,
207
+ confidence_delta=delta,
208
+ reason=reason,
209
+ changes=changes,
210
+ )
realitydiff/hashing.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def canonical_json(value: Any) -> str:
9
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
10
+
11
+
12
+ def sha1_hex(payload: str | bytes) -> str:
13
+ data = payload.encode("utf-8") if isinstance(payload, str) else payload
14
+ return hashlib.sha1(data).hexdigest()
15
+
16
+
17
+ def object_id(kind: str, value: Any) -> str:
18
+ blob = f"{kind} {canonical_json(value)}"
19
+ return sha1_hex(blob)
20
+
21
+
22
+ def stable_item_id(prefix: str, *parts: str) -> str:
23
+ material = "\n".join(part.strip() for part in parts if part)
24
+ return f"{prefix}_{sha1_hex(material)[:12]}"
realitydiff/models.py ADDED
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from realitydiff.hashing import object_id, stable_item_id
9
+
10
+
11
+ def utcnow() -> datetime:
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ class Evidence(BaseModel):
16
+ id: str
17
+ statement: str
18
+ source_url: str | None = None
19
+ source_title: str | None = None
20
+ retrieved_at: datetime = Field(default_factory=utcnow)
21
+ weight: float = 0.5
22
+ notes: str = ""
23
+
24
+ @field_validator("weight")
25
+ @classmethod
26
+ def clamp_weight(cls, value: float) -> float:
27
+ return max(0.0, min(1.0, value))
28
+
29
+ @classmethod
30
+ def make(
31
+ cls,
32
+ statement: str,
33
+ *,
34
+ source_url: str | None = None,
35
+ source_title: str | None = None,
36
+ weight: float = 0.5,
37
+ notes: str = "",
38
+ retrieved_at: datetime | None = None,
39
+ ) -> "Evidence":
40
+ return cls(
41
+ id=stable_item_id("ev", source_url or "", statement),
42
+ statement=statement,
43
+ source_url=source_url,
44
+ source_title=source_title,
45
+ weight=weight,
46
+ notes=notes,
47
+ retrieved_at=retrieved_at or utcnow(),
48
+ )
49
+
50
+
51
+ class Unknown(BaseModel):
52
+ id: str
53
+ question: str
54
+ why_it_matters: str = ""
55
+
56
+ @classmethod
57
+ def make(cls, question: str, why_it_matters: str = "") -> "Unknown":
58
+ return cls(
59
+ id=stable_item_id("uk", question),
60
+ question=question,
61
+ why_it_matters=why_it_matters,
62
+ )
63
+
64
+
65
+ class ClaimOutcome(BaseModel):
66
+ verdict: Literal["confirmed", "falsified", "settled"]
67
+ summary: str
68
+ source_url: str | None = None
69
+ source_title: str | None = None
70
+ closed_at: datetime = Field(default_factory=utcnow)
71
+
72
+
73
+ class Prediction(BaseModel):
74
+ id: str
75
+ statement: str
76
+ due: str | None = None
77
+ status: Literal["open", "confirmed", "falsified"] = "open"
78
+ how_to_falsify: str = ""
79
+
80
+ @classmethod
81
+ def make(
82
+ cls,
83
+ statement: str,
84
+ *,
85
+ due: str | None = None,
86
+ status: Literal["open", "confirmed", "falsified"] = "open",
87
+ how_to_falsify: str = "",
88
+ ) -> "Prediction":
89
+ return cls(
90
+ id=stable_item_id("pr", statement),
91
+ statement=statement,
92
+ due=due,
93
+ status=status,
94
+ how_to_falsify=how_to_falsify,
95
+ )
96
+
97
+
98
+ class ClaimState(BaseModel):
99
+ statement: str
100
+ refined_statement: str = ""
101
+ confidence: float = 50.0
102
+ evidence_for: list[Evidence] = Field(default_factory=list)
103
+ evidence_against: list[Evidence] = Field(default_factory=list)
104
+ unknowns: list[Unknown] = Field(default_factory=list)
105
+ predictions: list[Prediction] = Field(default_factory=list)
106
+ summary: str = ""
107
+ outcome: ClaimOutcome | None = None
108
+
109
+ @field_validator("confidence")
110
+ @classmethod
111
+ def clamp_confidence(cls, value: float) -> float:
112
+ return round(max(0.0, min(100.0, value)), 1)
113
+
114
+ def tree_hash(self) -> str:
115
+ data = self.model_dump(mode="json")
116
+ for bucket in ("evidence_for", "evidence_against"):
117
+ for item in data.get(bucket) or []:
118
+ item.pop("retrieved_at", None)
119
+ return object_id("tree", data)
120
+
121
+ def all_evidence(self) -> list[tuple[str, Evidence]]:
122
+ return [("for", item) for item in self.evidence_for] + [
123
+ ("against", item) for item in self.evidence_against
124
+ ]
125
+
126
+
127
+ class Commit(BaseModel):
128
+ id: str
129
+ claim_id: str
130
+ parent_id: str | None = None
131
+ tree_hash: str
132
+ created_at: datetime
133
+ author: str
134
+ message: str
135
+ reason: str = ""
136
+ previous_confidence: float | None = None
137
+ new_confidence: float
138
+ state: ClaimState
139
+
140
+ @classmethod
141
+ def create(
142
+ cls,
143
+ *,
144
+ claim_id: str,
145
+ parent_id: str | None,
146
+ state: ClaimState,
147
+ author: str,
148
+ message: str,
149
+ reason: str = "",
150
+ previous_confidence: float | None = None,
151
+ created_at: datetime | None = None,
152
+ ) -> "Commit":
153
+ created = created_at or utcnow()
154
+ tree_hash = state.tree_hash()
155
+ payload = {
156
+ "author": author,
157
+ "claim_id": claim_id,
158
+ "created_at": created.isoformat(),
159
+ "message": message,
160
+ "parent": parent_id,
161
+ "reason": reason,
162
+ "tree": tree_hash,
163
+ }
164
+ return cls(
165
+ id=object_id("commit", payload),
166
+ claim_id=claim_id,
167
+ parent_id=parent_id,
168
+ tree_hash=tree_hash,
169
+ created_at=created,
170
+ author=author,
171
+ message=message,
172
+ reason=reason,
173
+ previous_confidence=previous_confidence,
174
+ new_confidence=state.confidence,
175
+ state=state,
176
+ )
177
+
178
+
179
+ class ClaimRecord(BaseModel):
180
+ id: str
181
+ created_at: datetime
182
+ title: str
183
+ watching: bool = True
184
+ watch_interval_seconds: int = 300
185
+ head: str | None = None
186
+ last_watched_at: datetime | None = None
187
+ last_response_id: str | None = None
188
+ compaction_json: str | None = None
189
+ closed: bool = False
190
+ closed_at: datetime | None = None
191
+ outcome_verdict: Literal["confirmed", "falsified", "settled"] | None = None
192
+
193
+
194
+ class FieldChange(BaseModel):
195
+ path: str
196
+ before: Any = None
197
+ after: Any = None
198
+ kind: Literal["added", "removed", "changed"]
199
+
200
+
201
+ class ClaimDiff(BaseModel):
202
+ from_id: str | None
203
+ to_id: str | None
204
+ previous_confidence: float | None
205
+ new_confidence: float | None
206
+ confidence_delta: float = 0.0
207
+ reason: str = ""
208
+ changes: list[FieldChange] = Field(default_factory=list)
209
+
210
+
211
+ class BlameEntry(BaseModel):
212
+ path: str
213
+ item_id: str | None = None
214
+ introduced_in: str
215
+ introduced_at: datetime
216
+ author: str
217
+ message: str
218
+ last_changed_in: str
219
+ last_changed_at: datetime
220
+ last_author: str
221
+ last_message: str
222
+ value: Any = None
223
+
224
+
225
+ class BlameReport(BaseModel):
226
+ claim_id: str
227
+ head: str | None
228
+ entries: list[BlameEntry] = Field(default_factory=list)
229
+
230
+
231
+ class WatchResult(BaseModel):
232
+ claim_id: str
233
+ changed: bool
234
+ commit: Commit | None = None
235
+ diff: ClaimDiff | None = None
236
+ detail: str = ""
237
+ in_progress: bool = False
238
+ cached_tokens: int = 0
239
+ closed: bool = False