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.
Files changed (53) hide show
  1. tensorcode/__init__.py +84 -0
  2. tensorcode/actions.py +137 -0
  3. tensorcode/answer_type.py +222 -0
  4. tensorcode/awareness.py +344 -0
  5. tensorcode/backends/__init__.py +0 -0
  6. tensorcode/backends/builtin.py +167 -0
  7. tensorcode/backends/hf_local.py +89 -0
  8. tensorcode/backends/linear.py +133 -0
  9. tensorcode/backends/neural.py +361 -0
  10. tensorcode/causal.py +262 -0
  11. tensorcode/change.py +566 -0
  12. tensorcode/chunking.py +195 -0
  13. tensorcode/cognition.py +311 -0
  14. tensorcode/context.py +97 -0
  15. tensorcode/control.py +291 -0
  16. tensorcode/cues.py +192 -0
  17. tensorcode/expectation.py +270 -0
  18. tensorcode/frames.py +232 -0
  19. tensorcode/language/__init__.py +36 -0
  20. tensorcode/language/chart.py +558 -0
  21. tensorcode/language/discourse.py +132 -0
  22. tensorcode/language/domains/__init__.py +0 -0
  23. tensorcode/language/domains/desktop.py +552 -0
  24. tensorcode/language/english.py +459 -0
  25. tensorcode/language/features.py +112 -0
  26. tensorcode/language/generate.py +574 -0
  27. tensorcode/language/grammar.py +893 -0
  28. tensorcode/language/semantics.py +349 -0
  29. tensorcode/learning/__init__.py +30 -0
  30. tensorcode/learning/certificate.py +148 -0
  31. tensorcode/learning/induce.py +304 -0
  32. tensorcode/learning/library.py +217 -0
  33. tensorcode/learning/literals.py +126 -0
  34. tensorcode/learning/verify.py +253 -0
  35. tensorcode/memory.py +303 -0
  36. tensorcode/metacognition.py +351 -0
  37. tensorcode/ops.py +207 -0
  38. tensorcode/outcomes.py +99 -0
  39. tensorcode/permanence.py +376 -0
  40. tensorcode/priming.py +191 -0
  41. tensorcode/py.typed +0 -0
  42. tensorcode/quantity.py +311 -0
  43. tensorcode/records.py +728 -0
  44. tensorcode/relation.py +771 -0
  45. tensorcode/runtime.py +471 -0
  46. tensorcode/semantics_bridge.py +308 -0
  47. tensorcode/social.py +380 -0
  48. tensorcode/temporal.py +189 -0
  49. tensorcode/wants.py +185 -0
  50. tensorcode-0.1.0a1.dist-info/METADATA +196 -0
  51. tensorcode-0.1.0a1.dist-info/RECORD +53 -0
  52. tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
  53. 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 []