tensorcode 0.1.0a1__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.
- tensorcode/__init__.py +84 -0
- tensorcode/actions.py +137 -0
- tensorcode/answer_type.py +222 -0
- tensorcode/awareness.py +344 -0
- tensorcode/backends/__init__.py +0 -0
- tensorcode/backends/builtin.py +167 -0
- tensorcode/backends/hf_local.py +89 -0
- tensorcode/backends/linear.py +133 -0
- tensorcode/backends/neural.py +361 -0
- tensorcode/causal.py +262 -0
- tensorcode/change.py +566 -0
- tensorcode/chunking.py +195 -0
- tensorcode/cognition.py +311 -0
- tensorcode/context.py +97 -0
- tensorcode/control.py +291 -0
- tensorcode/cues.py +192 -0
- tensorcode/expectation.py +270 -0
- tensorcode/frames.py +232 -0
- tensorcode/language/__init__.py +36 -0
- tensorcode/language/chart.py +558 -0
- tensorcode/language/discourse.py +132 -0
- tensorcode/language/domains/__init__.py +0 -0
- tensorcode/language/domains/desktop.py +552 -0
- tensorcode/language/english.py +459 -0
- tensorcode/language/features.py +112 -0
- tensorcode/language/generate.py +574 -0
- tensorcode/language/grammar.py +893 -0
- tensorcode/language/semantics.py +349 -0
- tensorcode/learning/__init__.py +30 -0
- tensorcode/learning/certificate.py +148 -0
- tensorcode/learning/induce.py +304 -0
- tensorcode/learning/library.py +217 -0
- tensorcode/learning/literals.py +126 -0
- tensorcode/learning/verify.py +253 -0
- tensorcode/memory.py +303 -0
- tensorcode/metacognition.py +351 -0
- tensorcode/ops.py +207 -0
- tensorcode/outcomes.py +99 -0
- tensorcode/permanence.py +376 -0
- tensorcode/priming.py +191 -0
- tensorcode/py.typed +0 -0
- tensorcode/quantity.py +311 -0
- tensorcode/records.py +728 -0
- tensorcode/relation.py +771 -0
- tensorcode/runtime.py +471 -0
- tensorcode/semantics_bridge.py +308 -0
- tensorcode/social.py +380 -0
- tensorcode/temporal.py +189 -0
- tensorcode/wants.py +185 -0
- tensorcode-0.1.0a1.dist-info/METADATA +196 -0
- tensorcode-0.1.0a1.dist-info/RECORD +53 -0
- tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
- tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
tensorcode/records.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Typed native values by default; explicit graph records where identity matters.
|
|
2
|
+
|
|
3
|
+
The world/state substrate has exactly two record kinds:
|
|
4
|
+
|
|
5
|
+
* an **entity**: a stable ``Ref`` bound to an ordinary typed Python value
|
|
6
|
+
(dataclass, Pydantic model, enum, primitive). Identity is *assigned*.
|
|
7
|
+
* a **claim**: a proposition ``(subject, predicate, object)`` qualified by the
|
|
8
|
+
interval in which it is asserted to hold and an optional scope. Identity is
|
|
9
|
+
*content-derived*: the same proposition from two sources is one claim with
|
|
10
|
+
two pieces of evidence.
|
|
11
|
+
|
|
12
|
+
Observations, documents, sources, hypotheses, and contexts are entities whose
|
|
13
|
+
values have domain types. Evidence, intervals, and scores are plain values.
|
|
14
|
+
Executable structure (plans) lives in ``actions.py``, not here: a relationship
|
|
15
|
+
between facts is not a scheduling dependency.
|
|
16
|
+
|
|
17
|
+
Serialization only reconstructs types that were explicitly registered. Unknown
|
|
18
|
+
type names come back as ``Opaque`` values; nothing is ever imported by name.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import dataclasses
|
|
24
|
+
import enum
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import date, datetime
|
|
30
|
+
from functools import cached_property
|
|
31
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
32
|
+
|
|
33
|
+
from .outcomes import Score
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------- refs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, order=True)
|
|
39
|
+
class Ref:
|
|
40
|
+
"""A stable reference: ``"<kind>:<name>"``. Equality is identity."""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
kind, sep, name = self.id.partition(":")
|
|
46
|
+
if not (kind and sep and name):
|
|
47
|
+
raise ValueError(f"Ref must look like 'kind:name', got {self.id!r}")
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def kind(self) -> str:
|
|
51
|
+
return self.id.partition(":")[0]
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return self.id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Interval:
|
|
59
|
+
"""Closed interval; ``None`` means unbounded on that side."""
|
|
60
|
+
|
|
61
|
+
start: datetime | None = None
|
|
62
|
+
end: datetime | None = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.start and self.end and self.end < self.start:
|
|
66
|
+
raise ValueError("interval end precedes start")
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def at(cls, t: datetime) -> Interval:
|
|
70
|
+
return cls(t, t)
|
|
71
|
+
|
|
72
|
+
def contains(self, t: datetime) -> bool:
|
|
73
|
+
return (self.start is None or self.start <= t) and (self.end is None or t <= self.end)
|
|
74
|
+
|
|
75
|
+
def overlap(self, other: Interval) -> Interval | None:
|
|
76
|
+
starts = [s for s in (self.start, other.start) if s is not None]
|
|
77
|
+
ends = [e for e in (self.end, other.end) if e is not None]
|
|
78
|
+
start, end = (max(starts) if starts else None), (min(ends) if ends else None)
|
|
79
|
+
if start and end and end < start:
|
|
80
|
+
return None
|
|
81
|
+
return Interval(start, end)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Evidence:
|
|
86
|
+
"""Why a claim is on record: which source said it, when, and how it was extracted."""
|
|
87
|
+
|
|
88
|
+
source: Ref
|
|
89
|
+
observed_at: datetime # when the source made the observation (not when the fact holds)
|
|
90
|
+
locator: str | None = None # where inside the source, e.g. "text[18:33]"
|
|
91
|
+
method: str | None = None # e.g. "snmp-poll", "parse:note-rules@1"
|
|
92
|
+
confidence: Score | None = None # the extractor's or source's own score, with its kind
|
|
93
|
+
derived_from: tuple[str, ...] = () # premise claim ids, when a rule derived this; retracting a premise withdraws it
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Claim:
|
|
98
|
+
subject: Ref
|
|
99
|
+
predicate: str
|
|
100
|
+
object: Any # a Ref or an encodable typed value
|
|
101
|
+
valid: Interval = Interval()
|
|
102
|
+
scope: Ref | None = None # None: the shared world; otherwise a hypothesis/context entity
|
|
103
|
+
|
|
104
|
+
@cached_property
|
|
105
|
+
def id(self) -> str:
|
|
106
|
+
"""Content identity: equal propositions share an id regardless of source."""
|
|
107
|
+
canonical = json.dumps(_canonical(self), sort_keys=True, separators=(",", ":"))
|
|
108
|
+
return "claim:" + hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _canonical(v: Any) -> Any:
|
|
112
|
+
"""Deterministic, registry-free form used only for hashing (never for reconstruction)."""
|
|
113
|
+
if v is None or isinstance(v, (bool, int, float, str)):
|
|
114
|
+
return v
|
|
115
|
+
if isinstance(v, Ref):
|
|
116
|
+
return {"$ref": v.id}
|
|
117
|
+
if isinstance(v, datetime):
|
|
118
|
+
return {"$datetime": v.isoformat()}
|
|
119
|
+
if isinstance(v, date):
|
|
120
|
+
return {"$date": v.isoformat()}
|
|
121
|
+
if isinstance(v, enum.Enum):
|
|
122
|
+
return {"$enum": f"{type(v).__module__}.{type(v).__qualname__}", "value": _canonical(v.value)}
|
|
123
|
+
if isinstance(v, (list, tuple)):
|
|
124
|
+
return [_canonical(x) for x in v]
|
|
125
|
+
if isinstance(v, (set, frozenset)):
|
|
126
|
+
return sorted((_canonical(x) for x in v), key=lambda x: json.dumps(x, sort_keys=True))
|
|
127
|
+
if isinstance(v, Mapping):
|
|
128
|
+
return {"$map": sorted([[_canonical(k), _canonical(x)] for k, x in v.items()], key=json.dumps)}
|
|
129
|
+
if dataclasses.is_dataclass(v) or _is_model(v):
|
|
130
|
+
return {"$type": f"{type(v).__module__}.{type(v).__qualname__}", "fields": {k: _canonical(x) for k, x in _fields_of(v).items()}}
|
|
131
|
+
raise EncodeError(f"claim objects must be values, got {type(v).__qualname__}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Retraction:
|
|
136
|
+
reason: str
|
|
137
|
+
evidence: tuple[Evidence, ...] = ()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ClaimRecord:
|
|
142
|
+
claim: Claim
|
|
143
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
144
|
+
retracted: Retraction | None = None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def id(self) -> str:
|
|
148
|
+
return self.claim.id
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Conflict:
|
|
153
|
+
"""Two live claims about a functional predicate that cannot both hold."""
|
|
154
|
+
|
|
155
|
+
a: ClaimRecord
|
|
156
|
+
b: ClaimRecord
|
|
157
|
+
during: Interval
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Var:
|
|
162
|
+
name: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_ANY = object()
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------- codec
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class EncodeError(TypeError):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class Opaque:
|
|
176
|
+
"""A value whose type name is not registered here. Preserved, never imported."""
|
|
177
|
+
|
|
178
|
+
type_name: str
|
|
179
|
+
data: Any
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class ConversionReport:
|
|
184
|
+
losses: list[str] = field(default_factory=list)
|
|
185
|
+
opaque: list[str] = field(default_factory=list)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def lossless(self) -> bool:
|
|
189
|
+
return not self.losses and not self.opaque
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class Encoded:
|
|
194
|
+
data: Any
|
|
195
|
+
report: ConversionReport
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class _Registration:
|
|
200
|
+
cls: type
|
|
201
|
+
name: str
|
|
202
|
+
identity: Callable[[Any], Any] | None # entity types only
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TypeRegistry:
|
|
206
|
+
"""Explicit allow-list of types that may be encoded and reconstructed."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, *, parent: TypeRegistry | None = None) -> None:
|
|
209
|
+
self._by_name: dict[str, _Registration] = {}
|
|
210
|
+
self._by_cls: dict[type, _Registration] = {}
|
|
211
|
+
self._parent = parent
|
|
212
|
+
|
|
213
|
+
def register(
|
|
214
|
+
self, cls: type, *, name: str | None = None, identity: str | Callable[[Any], Any] | None = None
|
|
215
|
+
) -> type:
|
|
216
|
+
"""Register a value type, or an entity type when ``identity`` is given.
|
|
217
|
+
|
|
218
|
+
``identity`` is a field name or a function returning a stable key.
|
|
219
|
+
"""
|
|
220
|
+
if isinstance(identity, str):
|
|
221
|
+
attr = identity
|
|
222
|
+
identity = lambda obj: getattr(obj, attr) # noqa: E731
|
|
223
|
+
reg = _Registration(cls, name or cls.__name__, identity)
|
|
224
|
+
if (existing := self.lookup_name(reg.name)) and existing.cls is not cls:
|
|
225
|
+
raise ValueError(f"type name {reg.name!r} already registered for {existing.cls!r}")
|
|
226
|
+
self._by_name[reg.name] = reg
|
|
227
|
+
self._by_cls[cls] = reg
|
|
228
|
+
return cls
|
|
229
|
+
|
|
230
|
+
def lookup_name(self, name: str) -> _Registration | None:
|
|
231
|
+
return self._by_name.get(name) or (self._parent.lookup_name(name) if self._parent else None)
|
|
232
|
+
|
|
233
|
+
def lookup_cls(self, cls: type) -> _Registration | None:
|
|
234
|
+
return self._by_cls.get(cls) or (self._parent.lookup_cls(cls) if self._parent else None)
|
|
235
|
+
|
|
236
|
+
def is_entity(self, obj: Any) -> bool:
|
|
237
|
+
reg = self.lookup_cls(type(obj))
|
|
238
|
+
return bool(reg and reg.identity)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_BUILTINS = TypeRegistry()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _is_model(obj: Any) -> bool:
|
|
245
|
+
return hasattr(type(obj), "model_fields") and hasattr(obj, "model_dump")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _fields_of(obj: Any) -> dict[str, Any]:
|
|
249
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
250
|
+
return {f.name: getattr(obj, f.name) for f in dataclasses.fields(obj)}
|
|
251
|
+
if _is_model(obj):
|
|
252
|
+
return {name: getattr(obj, name) for name in type(obj).model_fields}
|
|
253
|
+
raise EncodeError(f"cannot enumerate fields of {type(obj).__qualname__}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def encode(
|
|
257
|
+
value: Any,
|
|
258
|
+
registry: TypeRegistry,
|
|
259
|
+
*,
|
|
260
|
+
entity_ref: Callable[[Any], Ref] | None = None,
|
|
261
|
+
root_path: str = "$",
|
|
262
|
+
seen: dict[int, str] | None = None,
|
|
263
|
+
) -> Encoded:
|
|
264
|
+
"""Encode a value into JSON-compatible data.
|
|
265
|
+
|
|
266
|
+
Cycles through *values* raise ``EncodeError`` (declare one of the types as an
|
|
267
|
+
entity to break the cycle). Shared mutable sub-values are duplicated and the
|
|
268
|
+
aliasing is reported as a loss.
|
|
269
|
+
"""
|
|
270
|
+
report = ConversionReport()
|
|
271
|
+
first_seen: dict[int, str] = {} if seen is None else seen # shared across records by to_records
|
|
272
|
+
stack: set[int] = set()
|
|
273
|
+
|
|
274
|
+
def enc(v: Any, path: str) -> Any:
|
|
275
|
+
if v is None or isinstance(v, (bool, int, float, str)):
|
|
276
|
+
return v
|
|
277
|
+
if isinstance(v, Ref):
|
|
278
|
+
return {"$ref": v.id}
|
|
279
|
+
if entity_ref is not None and path != root_path and registry.is_entity(v):
|
|
280
|
+
return {"$ref": entity_ref(v).id}
|
|
281
|
+
if isinstance(v, Opaque):
|
|
282
|
+
report.opaque.append(f"{path}: {v.type_name}")
|
|
283
|
+
return {"$type": v.type_name, "fields": v.data}
|
|
284
|
+
if isinstance(v, datetime):
|
|
285
|
+
return {"$datetime": v.isoformat()}
|
|
286
|
+
if isinstance(v, date):
|
|
287
|
+
return {"$date": v.isoformat()}
|
|
288
|
+
mutable = isinstance(v, (list, dict, set)) or dataclasses.is_dataclass(v) or _is_model(v)
|
|
289
|
+
if id(v) in stack:
|
|
290
|
+
raise EncodeError(f"cycle through value at {path}; register one of its types as an entity")
|
|
291
|
+
if mutable and id(v) in first_seen:
|
|
292
|
+
report.losses.append(f"aliasing: {path} is the same object as {first_seen[id(v)]}")
|
|
293
|
+
elif mutable:
|
|
294
|
+
first_seen[id(v)] = path
|
|
295
|
+
stack.add(id(v))
|
|
296
|
+
try:
|
|
297
|
+
if isinstance(v, enum.Enum):
|
|
298
|
+
reg = registry.lookup_cls(type(v)) or _BUILTINS.lookup_cls(type(v))
|
|
299
|
+
if not reg:
|
|
300
|
+
raise EncodeError(f"unregistered enum {type(v).__qualname__} at {path}")
|
|
301
|
+
return {"$enum": reg.name, "value": v.value}
|
|
302
|
+
if isinstance(v, (list, tuple)):
|
|
303
|
+
items = [enc(x, f"{path}[{i}]") for i, x in enumerate(v)]
|
|
304
|
+
return items if isinstance(v, list) else {"$tuple": items}
|
|
305
|
+
if isinstance(v, (set, frozenset)):
|
|
306
|
+
items = sorted((enc(x, f"{path}{{}}") for x in v), key=lambda x: json.dumps(x, sort_keys=True))
|
|
307
|
+
return {"$set": items}
|
|
308
|
+
if isinstance(v, Mapping):
|
|
309
|
+
if all(isinstance(k, str) and not k.startswith("$") for k in v):
|
|
310
|
+
return {k: enc(x, f"{path}.{k}") for k, x in v.items()}
|
|
311
|
+
return {"$map": [[enc(k, f"{path}<key>"), enc(x, f"{path}[{k!r}]")] for k, x in v.items()]}
|
|
312
|
+
if dataclasses.is_dataclass(v) or _is_model(v):
|
|
313
|
+
reg = registry.lookup_cls(type(v)) or _BUILTINS.lookup_cls(type(v))
|
|
314
|
+
if not reg:
|
|
315
|
+
raise EncodeError(f"unregistered type {type(v).__qualname__} at {path}")
|
|
316
|
+
return {"$type": reg.name, "fields": {k: enc(x, f"{path}.{k}") for k, x in _fields_of(v).items()}}
|
|
317
|
+
raise EncodeError(f"no encoding for {type(v).__qualname__} at {path}")
|
|
318
|
+
finally:
|
|
319
|
+
stack.discard(id(v))
|
|
320
|
+
|
|
321
|
+
return Encoded(enc(value, root_path), report)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def decode(data: Any, registry: TypeRegistry, *, resolve: Callable[[Ref], Any] | None = None) -> tuple[Any, ConversionReport]:
|
|
325
|
+
"""Reconstruct a value. Only registered types are instantiated; others become ``Opaque``."""
|
|
326
|
+
report = ConversionReport()
|
|
327
|
+
|
|
328
|
+
def dec(d: Any, path: str) -> Any:
|
|
329
|
+
if d is None or isinstance(d, (bool, int, float, str)):
|
|
330
|
+
return d
|
|
331
|
+
if isinstance(d, list):
|
|
332
|
+
return [dec(x, f"{path}[{i}]") for i, x in enumerate(d)]
|
|
333
|
+
if not isinstance(d, dict):
|
|
334
|
+
raise EncodeError(f"malformed data at {path}")
|
|
335
|
+
if "$ref" in d:
|
|
336
|
+
ref = Ref(d["$ref"])
|
|
337
|
+
return resolve(ref) if resolve else ref
|
|
338
|
+
if "$datetime" in d:
|
|
339
|
+
return datetime.fromisoformat(d["$datetime"])
|
|
340
|
+
if "$date" in d:
|
|
341
|
+
return date.fromisoformat(d["$date"])
|
|
342
|
+
if "$tuple" in d:
|
|
343
|
+
return tuple(dec(x, path) for x in d["$tuple"])
|
|
344
|
+
if "$set" in d:
|
|
345
|
+
return frozenset(dec(x, path) for x in d["$set"])
|
|
346
|
+
if "$map" in d:
|
|
347
|
+
return {dec(k, path): dec(v, path) for k, v in d["$map"]}
|
|
348
|
+
if "$enum" in d:
|
|
349
|
+
reg = registry.lookup_name(d["$enum"]) or _BUILTINS.lookup_name(d["$enum"])
|
|
350
|
+
if not reg:
|
|
351
|
+
report.opaque.append(f"{path}: {d['$enum']}")
|
|
352
|
+
return Opaque(d["$enum"], d["value"])
|
|
353
|
+
return reg.cls(d["value"])
|
|
354
|
+
if "$type" in d:
|
|
355
|
+
reg = registry.lookup_name(d["$type"]) or _BUILTINS.lookup_name(d["$type"])
|
|
356
|
+
if not reg:
|
|
357
|
+
report.opaque.append(f"{path}: {d['$type']}")
|
|
358
|
+
return Opaque(d["$type"], d["fields"])
|
|
359
|
+
fields = {k: dec(v, f"{path}.{k}") for k, v in d["fields"].items()}
|
|
360
|
+
if hasattr(reg.cls, "model_validate"):
|
|
361
|
+
return reg.cls.model_validate(fields)
|
|
362
|
+
init = {f.name for f in dataclasses.fields(reg.cls) if f.init}
|
|
363
|
+
obj = reg.cls(**{k: v for k, v in fields.items() if k in init})
|
|
364
|
+
for k, v in fields.items():
|
|
365
|
+
if k not in init:
|
|
366
|
+
object.__setattr__(obj, k, v)
|
|
367
|
+
return obj
|
|
368
|
+
return {k: dec(v, f"{path}.{k}") for k, v in d.items()}
|
|
369
|
+
|
|
370
|
+
return dec(data, "$"), report
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
for _cls in (Interval, Evidence, Claim, Score, Retraction):
|
|
374
|
+
_BUILTINS.register(_cls)
|
|
375
|
+
|
|
376
|
+
# --------------------------------------------------------------------- rewrites
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True)
|
|
380
|
+
class Put:
|
|
381
|
+
ref: Ref
|
|
382
|
+
value: Any
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass(frozen=True)
|
|
386
|
+
class SetField:
|
|
387
|
+
ref: Ref
|
|
388
|
+
path: tuple[str | int, ...]
|
|
389
|
+
value: Any
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class Tell:
|
|
394
|
+
claim: Claim
|
|
395
|
+
evidence: tuple[Evidence, ...]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass(frozen=True)
|
|
399
|
+
class Retract:
|
|
400
|
+
claim_id: str
|
|
401
|
+
reason: str
|
|
402
|
+
evidence: tuple[Evidence, ...] = ()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
Edit = Put | SetField | Tell | Retract
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True)
|
|
409
|
+
class Patch:
|
|
410
|
+
"""A *proposed* change. Inert until a store applies it."""
|
|
411
|
+
|
|
412
|
+
edits: tuple[Edit, ...]
|
|
413
|
+
base_revision: int
|
|
414
|
+
rationale: str = ""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class Commit:
|
|
419
|
+
revision: int
|
|
420
|
+
patch: Patch
|
|
421
|
+
added: tuple[str, ...] = () # claim ids that became live (new or revived)
|
|
422
|
+
retracted: tuple[str, ...] = () # claim ids that stopped being live, including withdrawn derivations
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class StaleRevision(RuntimeError):
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _replace_path(value: Any, path: Sequence[str | int], new: Any) -> Any:
|
|
430
|
+
"""Copy-on-write update of a nested field."""
|
|
431
|
+
if not path:
|
|
432
|
+
return new
|
|
433
|
+
head, rest = path[0], path[1:]
|
|
434
|
+
if isinstance(value, list):
|
|
435
|
+
copy = list(value)
|
|
436
|
+
copy[head] = _replace_path(value[head], rest, new) # type: ignore[index]
|
|
437
|
+
return copy
|
|
438
|
+
if isinstance(value, dict):
|
|
439
|
+
return {**value, head: _replace_path(value[head], rest, new)}
|
|
440
|
+
if dataclasses.is_dataclass(value):
|
|
441
|
+
return dataclasses.replace(value, **{head: _replace_path(getattr(value, head), rest, new)}) # type: ignore[arg-type]
|
|
442
|
+
if _is_model(value):
|
|
443
|
+
return value.model_copy(update={head: _replace_path(getattr(value, head), rest, new)})
|
|
444
|
+
raise TypeError(f"cannot update field {head!r} of {type(value).__qualname__}")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------------ store
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@dataclass(frozen=True)
|
|
451
|
+
class Subgraph:
|
|
452
|
+
entities: dict[Ref, Any]
|
|
453
|
+
claims: list[ClaimRecord]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class Store:
|
|
457
|
+
"""An in-memory world/state graph. A reference implementation, not a database."""
|
|
458
|
+
|
|
459
|
+
def __init__(self, registry: TypeRegistry | None = None) -> None:
|
|
460
|
+
self.registry = registry or TypeRegistry()
|
|
461
|
+
self.revision = 0
|
|
462
|
+
self.entities: dict[Ref, Any] = {}
|
|
463
|
+
self.functional: set[str] = set()
|
|
464
|
+
self._claims: dict[str, ClaimRecord] = {}
|
|
465
|
+
self._by_subject: dict[Ref, set[str]] = defaultdict(set)
|
|
466
|
+
self._by_object: dict[Ref, set[str]] = defaultdict(set)
|
|
467
|
+
self._by_predicate: dict[str, set[str]] = defaultdict(set)
|
|
468
|
+
self._by_scope: dict[Ref | None, set[str]] = defaultdict(set)
|
|
469
|
+
self._dependents: dict[str, set[str]] = defaultdict(set) # premise id -> ids of claims derived from it
|
|
470
|
+
|
|
471
|
+
def _index(self, rec: ClaimRecord) -> None:
|
|
472
|
+
c = rec.claim
|
|
473
|
+
self._by_subject[c.subject].add(rec.id)
|
|
474
|
+
self._by_predicate[c.predicate].add(rec.id)
|
|
475
|
+
self._by_scope[c.scope].add(rec.id)
|
|
476
|
+
if isinstance(c.object, Ref):
|
|
477
|
+
self._by_object[c.object].add(rec.id)
|
|
478
|
+
|
|
479
|
+
def _unindex(self, rec: ClaimRecord) -> None:
|
|
480
|
+
c = rec.claim
|
|
481
|
+
for index, key in ((self._by_subject, c.subject), (self._by_predicate, c.predicate), (self._by_scope, c.scope), (self._by_object, c.object)):
|
|
482
|
+
if isinstance(key, Ref) or index is not self._by_object:
|
|
483
|
+
index.get(key, set()).discard(rec.id)
|
|
484
|
+
|
|
485
|
+
# -- schema
|
|
486
|
+
def declare(self, predicate: str, *, functional: bool) -> None:
|
|
487
|
+
"""A functional predicate has at most one object per subject, scope, and instant."""
|
|
488
|
+
(self.functional.add if functional else self.functional.discard)(predicate)
|
|
489
|
+
|
|
490
|
+
# -- committed single edits
|
|
491
|
+
def put(self, ref: Ref, value: Any) -> Ref:
|
|
492
|
+
return self._commit(Patch((Put(ref, value),), self.revision)).patch.edits[0].ref # type: ignore[union-attr]
|
|
493
|
+
|
|
494
|
+
def tell(self, claim: Claim, *evidence: Evidence) -> ClaimRecord:
|
|
495
|
+
if not evidence:
|
|
496
|
+
raise ValueError("a claim needs at least one piece of evidence")
|
|
497
|
+
self._commit(Patch((Tell(claim, evidence),), self.revision))
|
|
498
|
+
return self._claims[claim.id]
|
|
499
|
+
|
|
500
|
+
def apply(self, patch: Patch) -> Commit:
|
|
501
|
+
return self._commit(patch)
|
|
502
|
+
|
|
503
|
+
def _commit(self, patch: Patch) -> Commit:
|
|
504
|
+
if patch.base_revision != self.revision:
|
|
505
|
+
raise StaleRevision(f"patch based on revision {patch.base_revision}, store is at {self.revision}")
|
|
506
|
+
staged: dict[Ref, Any] = {} # only the entities this patch touches
|
|
507
|
+
for edit in patch.edits: # validate everything before mutating anything
|
|
508
|
+
if isinstance(edit, SetField):
|
|
509
|
+
current = staged[edit.ref] if edit.ref in staged else self.entities.get(edit.ref, _ANY)
|
|
510
|
+
if current is _ANY:
|
|
511
|
+
raise KeyError(edit.ref)
|
|
512
|
+
staged[edit.ref] = _replace_path(current, edit.path, edit.value)
|
|
513
|
+
elif isinstance(edit, Put):
|
|
514
|
+
staged[edit.ref] = edit.value
|
|
515
|
+
elif isinstance(edit, Retract) and edit.claim_id not in self._claims:
|
|
516
|
+
raise KeyError(edit.claim_id)
|
|
517
|
+
elif isinstance(edit, Tell) and not edit.evidence:
|
|
518
|
+
raise ValueError("a claim needs at least one piece of evidence")
|
|
519
|
+
self.entities.update(staged)
|
|
520
|
+
added: list[str] = []
|
|
521
|
+
retracted: list[str] = []
|
|
522
|
+
for edit in patch.edits:
|
|
523
|
+
if isinstance(edit, Tell):
|
|
524
|
+
rec = self._claims.get(edit.claim.id)
|
|
525
|
+
if rec is None:
|
|
526
|
+
rec = self._claims[edit.claim.id] = ClaimRecord(edit.claim)
|
|
527
|
+
self._index(rec)
|
|
528
|
+
added.append(rec.id)
|
|
529
|
+
elif rec.retracted: # new evidence revives a withdrawn claim
|
|
530
|
+
rec.retracted = None
|
|
531
|
+
rec.evidence.clear()
|
|
532
|
+
added.append(rec.id)
|
|
533
|
+
for e in edit.evidence:
|
|
534
|
+
if e not in rec.evidence:
|
|
535
|
+
rec.evidence.append(e)
|
|
536
|
+
for premise in e.derived_from:
|
|
537
|
+
self._dependents[premise].add(rec.id)
|
|
538
|
+
elif isinstance(edit, Retract):
|
|
539
|
+
self._retract(edit.claim_id, Retraction(edit.reason, edit.evidence), retracted)
|
|
540
|
+
self.revision += 1
|
|
541
|
+
return Commit(self.revision, patch, tuple(dict.fromkeys(added)), tuple(dict.fromkeys(retracted)))
|
|
542
|
+
|
|
543
|
+
def _retract(self, claim_id: str, why: Retraction, out: list[str]) -> None:
|
|
544
|
+
"""Retract a claim, then withdraw derivations whose every line of support depended on something retracted."""
|
|
545
|
+
stack = [(claim_id, why)]
|
|
546
|
+
while stack:
|
|
547
|
+
cid, reason = stack.pop()
|
|
548
|
+
rec = self._claims.get(cid)
|
|
549
|
+
if rec is None or rec.retracted:
|
|
550
|
+
continue
|
|
551
|
+
rec.retracted = reason
|
|
552
|
+
out.append(cid)
|
|
553
|
+
for dep in sorted(self._dependents.get(cid, ())):
|
|
554
|
+
d = self._claims.get(dep)
|
|
555
|
+
if d is None or d.retracted:
|
|
556
|
+
continue
|
|
557
|
+
supported = any(
|
|
558
|
+
not e.derived_from or all((p := self._claims.get(x)) is not None and not p.retracted for x in e.derived_from) for e in d.evidence
|
|
559
|
+
)
|
|
560
|
+
if not supported:
|
|
561
|
+
stack.append((dep, Retraction(f"premise {cid} withdrawn")))
|
|
562
|
+
|
|
563
|
+
def forget(self, claim_ids: Iterable[str]) -> None:
|
|
564
|
+
"""Evict claims from working memory entirely (not a retraction: no history is kept)."""
|
|
565
|
+
for cid in claim_ids:
|
|
566
|
+
rec = self._claims.pop(cid, None)
|
|
567
|
+
if rec is not None:
|
|
568
|
+
self._unindex(rec)
|
|
569
|
+
self._dependents.pop(cid, None)
|
|
570
|
+
|
|
571
|
+
# -- queries
|
|
572
|
+
def get(self, ref: Ref) -> Any:
|
|
573
|
+
return self.entities[ref]
|
|
574
|
+
|
|
575
|
+
def claim(self, claim_id: str) -> ClaimRecord:
|
|
576
|
+
return self._claims[claim_id]
|
|
577
|
+
|
|
578
|
+
def claims(
|
|
579
|
+
self,
|
|
580
|
+
subject: Ref | None = None,
|
|
581
|
+
predicate: str | None = None,
|
|
582
|
+
object: Any = _ANY,
|
|
583
|
+
*,
|
|
584
|
+
at: datetime | None = None,
|
|
585
|
+
scope: Ref | None | object = _ANY,
|
|
586
|
+
include_retracted: bool = False,
|
|
587
|
+
) -> list[ClaimRecord]:
|
|
588
|
+
candidates: list[Iterable[str]] = []
|
|
589
|
+
if subject is not None:
|
|
590
|
+
candidates.append(self._by_subject.get(subject, ()))
|
|
591
|
+
if isinstance(object, Ref):
|
|
592
|
+
candidates.append(self._by_object.get(object, ()))
|
|
593
|
+
if predicate is not None:
|
|
594
|
+
candidates.append(self._by_predicate.get(predicate, ()))
|
|
595
|
+
if scope is not _ANY:
|
|
596
|
+
candidates.append(self._by_scope.get(scope, ())) # type: ignore[arg-type]
|
|
597
|
+
ids: Iterable[str] = min(candidates, key=len) if candidates else self._claims
|
|
598
|
+
out = []
|
|
599
|
+
for cid in ids:
|
|
600
|
+
rec = self._claims.get(cid)
|
|
601
|
+
c = rec.claim if rec else None
|
|
602
|
+
if rec is None or (rec.retracted and not include_retracted):
|
|
603
|
+
continue
|
|
604
|
+
if subject is not None and c.subject != subject:
|
|
605
|
+
continue
|
|
606
|
+
if predicate is not None and c.predicate != predicate:
|
|
607
|
+
continue
|
|
608
|
+
if object is not _ANY and c.object != object:
|
|
609
|
+
continue
|
|
610
|
+
if scope is not _ANY and c.scope != scope:
|
|
611
|
+
continue
|
|
612
|
+
if at is not None and not c.valid.contains(at):
|
|
613
|
+
continue
|
|
614
|
+
out.append(rec)
|
|
615
|
+
return sorted(out, key=lambda r: r.id)
|
|
616
|
+
|
|
617
|
+
def match(self, *patterns: tuple[Any, str, Any], at: datetime | None = None, with_support: bool = False) -> list:
|
|
618
|
+
"""Conjunctive pattern query. ``Var`` terms bind; everything else must be equal.
|
|
619
|
+
|
|
620
|
+
With ``with_support=True`` each result is ``(bindings, claim ids that matched)``.
|
|
621
|
+
"""
|
|
622
|
+
results: list[tuple[dict[str, Any], tuple[str, ...]]] = [({}, ())]
|
|
623
|
+
for s, p, o in patterns:
|
|
624
|
+
nxt = []
|
|
625
|
+
for binding, support in results:
|
|
626
|
+
s_b = binding.get(s.name, s) if isinstance(s, Var) else s
|
|
627
|
+
o_b = binding.get(o.name, o) if isinstance(o, Var) else o
|
|
628
|
+
for rec in self.claims(
|
|
629
|
+
subject=None if isinstance(s_b, Var) else s_b,
|
|
630
|
+
predicate=p,
|
|
631
|
+
object=_ANY if isinstance(o_b, Var) else o_b,
|
|
632
|
+
at=at,
|
|
633
|
+
):
|
|
634
|
+
b = dict(binding)
|
|
635
|
+
if isinstance(s_b, Var):
|
|
636
|
+
b[s_b.name] = rec.claim.subject
|
|
637
|
+
if isinstance(o_b, Var):
|
|
638
|
+
if o_b.name in b and b[o_b.name] != rec.claim.object:
|
|
639
|
+
continue
|
|
640
|
+
b[o_b.name] = rec.claim.object
|
|
641
|
+
nxt.append((b, support + (rec.id,)))
|
|
642
|
+
results = nxt
|
|
643
|
+
return results if with_support else [b for b, _ in results]
|
|
644
|
+
|
|
645
|
+
def conflicts(self, subject: Ref | None = None) -> list[Conflict]:
|
|
646
|
+
groups: dict[tuple, list[ClaimRecord]] = defaultdict(list)
|
|
647
|
+
for rec in self.claims(subject=subject):
|
|
648
|
+
if rec.claim.predicate in self.functional:
|
|
649
|
+
groups[(rec.claim.subject, rec.claim.predicate, rec.claim.scope)].append(rec)
|
|
650
|
+
found = []
|
|
651
|
+
for recs in groups.values():
|
|
652
|
+
for i, a in enumerate(recs):
|
|
653
|
+
for b in recs[i + 1 :]:
|
|
654
|
+
if a.claim.object != b.claim.object and (during := a.claim.valid.overlap(b.claim.valid)):
|
|
655
|
+
found.append(Conflict(a, b, during))
|
|
656
|
+
return found
|
|
657
|
+
|
|
658
|
+
def neighborhood(self, ref: Ref, depth: int = 1) -> Subgraph:
|
|
659
|
+
seen, frontier, claims = {ref}, [ref], {}
|
|
660
|
+
for _ in range(depth):
|
|
661
|
+
nxt = []
|
|
662
|
+
for r in frontier:
|
|
663
|
+
linked = self.claims(subject=r) + self.claims(object=r)
|
|
664
|
+
if r in self.entities:
|
|
665
|
+
linked_refs = _refs_in(self.entities[r])
|
|
666
|
+
else:
|
|
667
|
+
linked_refs = []
|
|
668
|
+
for rec in linked:
|
|
669
|
+
claims[rec.id] = rec
|
|
670
|
+
linked_refs += [rec.claim.subject] + ([rec.claim.object] if isinstance(rec.claim.object, Ref) else [])
|
|
671
|
+
for other in linked_refs:
|
|
672
|
+
if other not in seen:
|
|
673
|
+
seen.add(other)
|
|
674
|
+
nxt.append(other)
|
|
675
|
+
frontier = nxt
|
|
676
|
+
return Subgraph({r: self.entities[r] for r in sorted(seen) if r in self.entities}, sorted(claims.values(), key=lambda c: c.id))
|
|
677
|
+
|
|
678
|
+
# -- persistence
|
|
679
|
+
def to_json(self) -> dict[str, Any]:
|
|
680
|
+
return {
|
|
681
|
+
"revision": self.revision,
|
|
682
|
+
"functional": sorted(self.functional),
|
|
683
|
+
"entities": {r.id: encode(v, self.registry).data for r, v in sorted(self.entities.items())},
|
|
684
|
+
"claims": [
|
|
685
|
+
{
|
|
686
|
+
"claim": encode(rec.claim, self.registry).data,
|
|
687
|
+
"evidence": [encode(e, self.registry).data for e in rec.evidence],
|
|
688
|
+
"retracted": encode(rec.retracted, self.registry).data if rec.retracted else None,
|
|
689
|
+
}
|
|
690
|
+
for rec in sorted(self._claims.values(), key=lambda r: r.id)
|
|
691
|
+
],
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
@classmethod
|
|
695
|
+
def from_json(cls, data: Mapping[str, Any], registry: TypeRegistry) -> tuple[Store, ConversionReport]:
|
|
696
|
+
store, report = cls(registry), ConversionReport()
|
|
697
|
+
|
|
698
|
+
def dec(d: Any) -> Any:
|
|
699
|
+
value, rep = decode(d, registry)
|
|
700
|
+
report.opaque.extend(rep.opaque)
|
|
701
|
+
return value
|
|
702
|
+
|
|
703
|
+
store.functional = set(data["functional"])
|
|
704
|
+
store.entities = {Ref(r): dec(v) for r, v in data["entities"].items()}
|
|
705
|
+
for row in data["claims"]:
|
|
706
|
+
claim = dec(row["claim"])
|
|
707
|
+
rec = store._claims[claim.id] = ClaimRecord(claim, [dec(e) for e in row["evidence"]])
|
|
708
|
+
rec.retracted = dec(row["retracted"]) if row["retracted"] else None
|
|
709
|
+
store._index(rec)
|
|
710
|
+
for e in rec.evidence:
|
|
711
|
+
for premise in e.derived_from:
|
|
712
|
+
store._dependents[premise].add(rec.id)
|
|
713
|
+
if isinstance(claim.object, Ref):
|
|
714
|
+
store._by_object[claim.object].add(claim.id)
|
|
715
|
+
store.revision = data["revision"]
|
|
716
|
+
return store, report
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _refs_in(value: Any) -> list[Ref]:
|
|
720
|
+
if isinstance(value, Ref):
|
|
721
|
+
return [value]
|
|
722
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
723
|
+
return [r for v in value for r in _refs_in(v)]
|
|
724
|
+
if isinstance(value, Mapping):
|
|
725
|
+
return [r for v in value.values() for r in _refs_in(v)]
|
|
726
|
+
if dataclasses.is_dataclass(value) or _is_model(value):
|
|
727
|
+
return [r for v in _fields_of(value).values() for r in _refs_in(v)]
|
|
728
|
+
return []
|