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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Inducing readable artifacts: decision lists, preconditions, and role types.
|
|
2
|
+
|
|
3
|
+
Three inducers, each with the discipline its source repo learned the hard way:
|
|
4
|
+
|
|
5
|
+
* :func:`decision_list` — greedy separate-and-conquer with an **MDL stop**, so a
|
|
6
|
+
rule has to save more bits than it costs to state. (The algorithm is
|
|
7
|
+
``symbolic-ai-models``'s ``models/ruleinduce_001/dlist.py``, re-expressed over
|
|
8
|
+
claims instead of that repo's graph facts.)
|
|
9
|
+
* :func:`preconditions` — the most-specific cover of the states in which an action
|
|
10
|
+
fired, with optional **interventional pruning**: keep a condition only if
|
|
11
|
+
removing it changes what the world does. (``synthEX``'s
|
|
12
|
+
``perception/induce_rules.py`` reports precision 0.44 → 0.74 from exactly this.)
|
|
13
|
+
* :func:`role_type` — the least general type covering every observed filler, with a
|
|
14
|
+
**productivity gate**: a shape generalises only when many distinct fillers share
|
|
15
|
+
it and almost all of them are of that kind, otherwise the forms are memorised.
|
|
16
|
+
(``symbolic-ai-models``'s ``reader/learned.py`` ``Lexicon.induce``.)
|
|
17
|
+
|
|
18
|
+
Nothing here adopts what it induces. Adoption is :mod:`tensorcode.learning.verify`,
|
|
19
|
+
which is where the controls live.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from collections import Counter, defaultdict
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Callable, Hashable, Iterable, Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
from ..outcomes import Score, Unknown
|
|
30
|
+
from .literals import Case, Literal
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------- decision lists
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Rule:
|
|
37
|
+
conditions: tuple[Literal, ...]
|
|
38
|
+
label: Any
|
|
39
|
+
support: int = 0
|
|
40
|
+
correct: int = 0
|
|
41
|
+
|
|
42
|
+
def matches(self, facts: Any) -> bool:
|
|
43
|
+
return all(c.holds(facts) for c in self.conditions)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def confidence(self) -> float:
|
|
47
|
+
return self.correct / max(1, self.support)
|
|
48
|
+
|
|
49
|
+
def cost(self) -> int:
|
|
50
|
+
return 2 + sum(c.cost() for c in self.conditions)
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
body = " ∧ ".join(repr(c) for c in self.conditions) or "true"
|
|
54
|
+
return f"IF {body} THEN {self.label!r} [{self.correct}/{self.support}]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class DecisionList:
|
|
59
|
+
"""An ordered list of rules you can read, argue with, and price."""
|
|
60
|
+
|
|
61
|
+
rules: list[Rule] = field(default_factory=list)
|
|
62
|
+
default: Any = None
|
|
63
|
+
considered: int = 0
|
|
64
|
+
|
|
65
|
+
def predict(self, facts: Any) -> Any:
|
|
66
|
+
for rule in self.rules:
|
|
67
|
+
if rule.matches(facts):
|
|
68
|
+
return rule.label
|
|
69
|
+
return self.default
|
|
70
|
+
|
|
71
|
+
def explain(self, facts: Any) -> tuple[Any, Rule | None]:
|
|
72
|
+
for rule in self.rules:
|
|
73
|
+
if rule.matches(facts):
|
|
74
|
+
return rule.label, rule
|
|
75
|
+
return self.default, None
|
|
76
|
+
|
|
77
|
+
def decide(self, facts: Any) -> tuple[Any, "Rule | None", Any]:
|
|
78
|
+
"""Predict, and return the certificate of what the decision actually read.
|
|
79
|
+
|
|
80
|
+
The read set includes predicates that were *absent*, so an answer is
|
|
81
|
+
invalidated by a fact appearing as well as by one changing.
|
|
82
|
+
"""
|
|
83
|
+
from .certificate import Reader
|
|
84
|
+
|
|
85
|
+
reader = Reader(dict(facts) if isinstance(facts, frozenset) else facts, note="decision_list")
|
|
86
|
+
label, rule = self.explain(reader)
|
|
87
|
+
return label, rule, reader.readset()
|
|
88
|
+
|
|
89
|
+
def score(self, facts: Any) -> Score:
|
|
90
|
+
_, rule = self.explain(facts)
|
|
91
|
+
return Score(rule.confidence if rule else 0.0, "uncalibrated")
|
|
92
|
+
|
|
93
|
+
def cost(self) -> int:
|
|
94
|
+
return sum(r.cost() for r in self.rules) + 2
|
|
95
|
+
|
|
96
|
+
def accuracy(self, cases: Sequence[Case]) -> float:
|
|
97
|
+
if not cases:
|
|
98
|
+
return 0.0
|
|
99
|
+
return sum(self.predict(facts) == label for facts, label in cases) / len(cases)
|
|
100
|
+
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
return "\n".join([*(repr(r) for r in self.rules), f"ELSE {self.default!r}"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _entropy(counts: Counter) -> float:
|
|
106
|
+
total = sum(counts.values())
|
|
107
|
+
if not total:
|
|
108
|
+
return 0.0
|
|
109
|
+
return -sum((c / total) * math.log2(c / total) for c in counts.values() if c)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decision_list(cases: Sequence[Case], literals: Sequence[Literal], *, max_conditions: int = 3,
|
|
113
|
+
min_support: int = 2, min_confidence: float = 0.6, max_rules: int = 40,
|
|
114
|
+
beam: int = 4, mdl: bool = True) -> DecisionList:
|
|
115
|
+
"""Induce an ordered rule list. Discrete throughout: no gradients, five knobs."""
|
|
116
|
+
remaining = list(cases)
|
|
117
|
+
out = DecisionList(considered=len(literals))
|
|
118
|
+
labels = Counter(label for _, label in cases)
|
|
119
|
+
out.default = labels.most_common(1)[0][0] if labels else None
|
|
120
|
+
|
|
121
|
+
while remaining and len(out.rules) < max_rules:
|
|
122
|
+
rule = _grow(remaining, literals, max_conditions=max_conditions, min_support=min_support, beam=beam)
|
|
123
|
+
if rule is None or rule.confidence < min_confidence:
|
|
124
|
+
break
|
|
125
|
+
if mdl:
|
|
126
|
+
covered = [(f, y) for f, y in remaining if rule.matches(f)]
|
|
127
|
+
before = _entropy(Counter(y for _, y in remaining)) * len(covered)
|
|
128
|
+
after = _entropy(Counter(y for _, y in covered)) * len(covered)
|
|
129
|
+
if before - after < rule.cost(): # the rule must pay for its own statement
|
|
130
|
+
break
|
|
131
|
+
out.rules.append(rule)
|
|
132
|
+
remaining = [(f, y) for f, y in remaining if not rule.matches(f)]
|
|
133
|
+
if remaining:
|
|
134
|
+
out.default = Counter(y for _, y in remaining).most_common(1)[0][0]
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _grow(cases: Sequence[Case], literals: Sequence[Literal], *, max_conditions: int, min_support: int,
|
|
139
|
+
beam: int) -> Rule | None:
|
|
140
|
+
"""Beam search over conjunctions, scored by information gain weighted by purity."""
|
|
141
|
+
hits = {literal: [literal.holds(facts) for facts, _ in cases] for literal in literals}
|
|
142
|
+
labels = [label for _, label in cases]
|
|
143
|
+
base = _entropy(Counter(labels))
|
|
144
|
+
n = len(cases)
|
|
145
|
+
|
|
146
|
+
def score(mask: Sequence[bool]) -> tuple[float, Counter, int]:
|
|
147
|
+
picked = [i for i, m in enumerate(mask) if m]
|
|
148
|
+
if len(picked) < min_support:
|
|
149
|
+
return -1e9, Counter(), 0
|
|
150
|
+
counts = Counter(labels[i] for i in picked)
|
|
151
|
+
return (base - _entropy(counts)) * (len(picked) / n), counts, len(picked)
|
|
152
|
+
|
|
153
|
+
beams: list[tuple[float, tuple[Literal, ...], list[bool]]] = [(0.0, (), [True] * n)]
|
|
154
|
+
best: tuple[float, tuple[Literal, ...], Counter, int] | None = None
|
|
155
|
+
for _ in range(max_conditions):
|
|
156
|
+
nxt: list[tuple[float, tuple[Literal, ...], list[bool]]] = []
|
|
157
|
+
for _, conditions, mask in beams:
|
|
158
|
+
for literal in literals:
|
|
159
|
+
if literal in conditions:
|
|
160
|
+
continue
|
|
161
|
+
merged = [a and b for a, b in zip(mask, hits[literal])]
|
|
162
|
+
gain, counts, support = score(merged)
|
|
163
|
+
if gain <= 0:
|
|
164
|
+
continue
|
|
165
|
+
adjusted = gain * (max(counts.values()) / max(1, support))
|
|
166
|
+
nxt.append((adjusted, conditions + (literal,), merged))
|
|
167
|
+
if best is None or adjusted > best[0]:
|
|
168
|
+
best = (adjusted, conditions + (literal,), counts, support)
|
|
169
|
+
if not nxt:
|
|
170
|
+
break
|
|
171
|
+
nxt.sort(key=lambda row: (-row[0], tuple(repr(c) for c in row[1])))
|
|
172
|
+
beams = nxt[:beam]
|
|
173
|
+
if best is None:
|
|
174
|
+
return None
|
|
175
|
+
_, conditions, counts, support = best
|
|
176
|
+
label, correct = counts.most_common(1)[0]
|
|
177
|
+
return Rule(conditions, label, support, correct)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------- preconditions
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class Precondition:
|
|
185
|
+
"""What has to hold for an action to fire, and how it was established."""
|
|
186
|
+
|
|
187
|
+
action: str
|
|
188
|
+
conditions: tuple[Literal, ...]
|
|
189
|
+
support: int
|
|
190
|
+
method: str = "most-specific-cover"
|
|
191
|
+
|
|
192
|
+
def holds(self, facts: Any) -> bool:
|
|
193
|
+
return all(c.holds(facts) for c in self.conditions)
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
body = " ∧ ".join(repr(c) for c in self.conditions) or "true"
|
|
197
|
+
return f"{self.action} needs {body} [{self.support} firings, {self.method}]"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def preconditions(action: str, fired: Sequence[frozenset], *,
|
|
201
|
+
intervene: Callable[[str, frozenset], bool] | None = None,
|
|
202
|
+
did_not_fire: Sequence[frozenset] = ()) -> Precondition:
|
|
203
|
+
"""Conditions true in **every** state where the action fired.
|
|
204
|
+
|
|
205
|
+
The passive cover over-specialises: anything the world happened to keep constant
|
|
206
|
+
becomes a condition. Two optional correctives, in the order they should be tried:
|
|
207
|
+
|
|
208
|
+
* ``did_not_fire`` states drop conditions that also held when nothing happened;
|
|
209
|
+
* ``intervene(action, facts)`` is asked whether the action still fires with a
|
|
210
|
+
condition removed, which is the only way to tell a cause from a coincidence.
|
|
211
|
+
"""
|
|
212
|
+
if not fired:
|
|
213
|
+
return Precondition(action, (), 0, "no firings")
|
|
214
|
+
common = set(fired[0])
|
|
215
|
+
for state in fired[1:]:
|
|
216
|
+
common &= set(state)
|
|
217
|
+
conditions = [Literal(p, v) for p, v in sorted(common, key=repr)]
|
|
218
|
+
|
|
219
|
+
if did_not_fire:
|
|
220
|
+
conditions = [c for c in conditions if not all(c.holds(state) for state in did_not_fire)]
|
|
221
|
+
method = "most-specific-cover" + ("+negatives" if did_not_fire else "")
|
|
222
|
+
|
|
223
|
+
if intervene is not None:
|
|
224
|
+
kept: list[Literal] = []
|
|
225
|
+
for condition in conditions:
|
|
226
|
+
weakened = [c for c in conditions if c is not condition]
|
|
227
|
+
probe = frozenset((c.predicate, c.value) for c in weakened)
|
|
228
|
+
if not intervene(action, probe): # removing it stopped the action: it matters
|
|
229
|
+
kept.append(condition)
|
|
230
|
+
conditions, method = kept, method + "+intervention"
|
|
231
|
+
return Precondition(action, tuple(conditions), len(fired), method)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def effects(before: Sequence[frozenset], after: Sequence[frozenset]) -> tuple[Literal, ...]:
|
|
235
|
+
"""What every firing added: the union of new facts, kept only where it is unanimous."""
|
|
236
|
+
added: list[set] = [set(b) ^ (set(a) & set(b)) for a, b in zip(after, before)]
|
|
237
|
+
gained = [set(a) - set(b) for a, b in zip(after, before)]
|
|
238
|
+
if not gained:
|
|
239
|
+
return ()
|
|
240
|
+
common = set(gained[0])
|
|
241
|
+
for facts in gained[1:]:
|
|
242
|
+
common &= facts
|
|
243
|
+
_ = added
|
|
244
|
+
return tuple(Literal(p, v) for p, v in sorted(common, key=repr))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ role types
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class RoleType:
|
|
252
|
+
"""What fills a role: a generalising shape, or a memorised set of forms."""
|
|
253
|
+
|
|
254
|
+
role: str
|
|
255
|
+
concept: str | None
|
|
256
|
+
shapes: frozenset[str] = frozenset()
|
|
257
|
+
forms: frozenset[str] = frozenset()
|
|
258
|
+
productive: bool = False
|
|
259
|
+
support: int = 0
|
|
260
|
+
|
|
261
|
+
def admits(self, value: Any) -> bool:
|
|
262
|
+
text = value if isinstance(value, str) else str(value)
|
|
263
|
+
return text in self.forms or (self.productive and shape(text) in self.shapes)
|
|
264
|
+
|
|
265
|
+
def __repr__(self) -> str:
|
|
266
|
+
how = f"shape {sorted(self.shapes)}" if self.productive else f"{len(self.forms)} memorised forms"
|
|
267
|
+
return f"{self.role}: {self.concept or 'unknown'} ({how}, {self.support} observations)"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def shape(text: str) -> str:
|
|
271
|
+
"""A token's shape: letters, digits and punctuation classes, run-length collapsed."""
|
|
272
|
+
out = []
|
|
273
|
+
for ch in text:
|
|
274
|
+
kind = "X" if ch.isupper() else "x" if ch.islower() else "9" if ch.isdigit() else ch
|
|
275
|
+
if not out or out[-1] != kind:
|
|
276
|
+
out.append(kind)
|
|
277
|
+
return "".join(out)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def role_type(role: str, fillers: Sequence[Any], *, subsumes: Callable[[Any], str] | None = None,
|
|
281
|
+
min_forms: int = 5, purity: float = 0.9, others: Sequence[Any] = ()) -> RoleType:
|
|
282
|
+
"""The least general type covering every filler, generalised only if productive.
|
|
283
|
+
|
|
284
|
+
``subsumes`` maps a filler to its concept; the induced concept is the most
|
|
285
|
+
specific one covering all fillers. A *shape* is trusted only when it covers
|
|
286
|
+
``min_forms`` distinct fillers and almost nothing else in ``others``, so
|
|
287
|
+
``region_0`` generalises to ``region_84`` while ``group`` must be memorised.
|
|
288
|
+
"""
|
|
289
|
+
texts = [f if isinstance(f, str) else str(f) for f in fillers]
|
|
290
|
+
concepts = {subsumes(f) for f in fillers} if subsumes else set()
|
|
291
|
+
concept = concepts.pop() if len(concepts) == 1 else None
|
|
292
|
+
by_shape: dict[str, set[str]] = defaultdict(set)
|
|
293
|
+
for text in texts:
|
|
294
|
+
by_shape[shape(text)].add(text)
|
|
295
|
+
foreign: dict[str, set[str]] = defaultdict(set)
|
|
296
|
+
for other in others:
|
|
297
|
+
text = other if isinstance(other, str) else str(other)
|
|
298
|
+
foreign[shape(text)].add(text)
|
|
299
|
+
productive = {
|
|
300
|
+
s for s, forms in by_shape.items()
|
|
301
|
+
if len(forms) >= min_forms and len(forms) >= purity * (len(forms) + len(foreign.get(s, ())))
|
|
302
|
+
}
|
|
303
|
+
memorised = {t for t in texts if shape(t) not in productive}
|
|
304
|
+
return RoleType(role, concept, frozenset(productive), frozenset(memorised), bool(productive), len(texts))
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""A persistent, versioned library of learned artifacts.
|
|
2
|
+
|
|
3
|
+
Learning that does not outlive the process is not learning, so artifacts go to a
|
|
4
|
+
directory of plain JSON: a manifest, one content-addressed file per artifact, and
|
|
5
|
+
one recorded fixture per artifact. The design is
|
|
6
|
+
``typed-crystallization-networks``'s ``tcn/library.py``, kept because its rules are
|
|
7
|
+
the ones that stop a library quietly rotting:
|
|
8
|
+
|
|
9
|
+
* **content addressing** — two names that induce the same artifact share one file,
|
|
10
|
+
so a definition is stored and charged once;
|
|
11
|
+
* **versions** — publishing under an existing name allocates the next version and
|
|
12
|
+
marks every artifact that depends on the superseded digest ``stale``;
|
|
13
|
+
* **fixtures** — an artifact records cases it got right, and loading replays them.
|
|
14
|
+
A disagreement raises rather than loading;
|
|
15
|
+
* **provenance** — what it was induced from, by what method, when, and under which
|
|
16
|
+
verification. Nothing enters without it.
|
|
17
|
+
|
|
18
|
+
There is deliberately no "load anyway" policy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
29
|
+
|
|
30
|
+
from .induce import DecisionList, Rule
|
|
31
|
+
from .literals import Literal
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LibraryError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FixtureMismatch(LibraryError):
|
|
39
|
+
"""A stored artifact no longer reproduces its recorded cases."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MissingArtifact(LibraryError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def digest_of(payload: Any) -> str:
|
|
47
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Entry:
|
|
52
|
+
name: str
|
|
53
|
+
version: int
|
|
54
|
+
digest: str
|
|
55
|
+
kind: str
|
|
56
|
+
provenance: Mapping[str, Any]
|
|
57
|
+
depends_on: tuple[str, ...] = ()
|
|
58
|
+
stale: bool = False
|
|
59
|
+
at: float = field(default_factory=time.time)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def reference(self) -> str:
|
|
63
|
+
return f"{self.name}@{self.version}"
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {"name": self.name, "version": self.version, "digest": self.digest, "kind": self.kind,
|
|
67
|
+
"provenance": dict(self.provenance), "depends_on": list(self.depends_on), "stale": self.stale,
|
|
68
|
+
"at": self.at}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Entry":
|
|
72
|
+
return cls(data["name"], data["version"], data["digest"], data["kind"], data.get("provenance", {}),
|
|
73
|
+
tuple(data.get("depends_on", ())), bool(data.get("stale")), data.get("at", 0.0))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------- serialisation
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _literal_to_json(literal: Literal) -> dict[str, Any]:
|
|
80
|
+
return {"predicate": literal.predicate, "value": literal.value, "negated": literal.negated, "kind": literal.kind}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _literal_from_json(data: Mapping[str, Any]) -> Literal:
|
|
84
|
+
return Literal(data["predicate"], data.get("value"), bool(data.get("negated")), data.get("kind", "equals"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_json(artifact: Any) -> dict[str, Any]:
|
|
88
|
+
"""Artifacts are data: a reader needs this module's *format*, not its code."""
|
|
89
|
+
if isinstance(artifact, DecisionList):
|
|
90
|
+
return {"kind": "decision_list", "default": artifact.default, "considered": artifact.considered,
|
|
91
|
+
"rules": [{"conditions": [_literal_to_json(c) for c in r.conditions], "label": r.label,
|
|
92
|
+
"support": r.support, "correct": r.correct} for r in artifact.rules]}
|
|
93
|
+
raise LibraryError(f"cannot store {type(artifact).__name__}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def from_json(data: Mapping[str, Any]) -> Any:
|
|
97
|
+
if data.get("kind") == "decision_list":
|
|
98
|
+
rules = [Rule(tuple(_literal_from_json(c) for c in r["conditions"]), r["label"], r.get("support", 0), r.get("correct", 0))
|
|
99
|
+
for r in data.get("rules", ())]
|
|
100
|
+
return DecisionList(rules, data.get("default"), data.get("considered", 0))
|
|
101
|
+
raise LibraryError(f"unknown stored kind {data.get('kind')!r}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# -------------------------------------------------------------------- library
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Library:
|
|
108
|
+
"""A directory of learned artifacts, versioned and replayable."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, root: str | Path) -> None:
|
|
111
|
+
self.root = Path(root)
|
|
112
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
(self.root / "artifacts").mkdir(exist_ok=True)
|
|
114
|
+
(self.root / "fixtures").mkdir(exist_ok=True)
|
|
115
|
+
self.manifest_path = self.root / "manifest.json"
|
|
116
|
+
self.entries: list[Entry] = []
|
|
117
|
+
if self.manifest_path.exists():
|
|
118
|
+
self.entries = [Entry.from_dict(row) for row in json.loads(self.manifest_path.read_text())]
|
|
119
|
+
|
|
120
|
+
# -- reading
|
|
121
|
+
def names(self) -> list[str]:
|
|
122
|
+
return sorted({e.name for e in self.entries})
|
|
123
|
+
|
|
124
|
+
def versions(self, name: str) -> list[Entry]:
|
|
125
|
+
return [e for e in self.entries if e.name == name]
|
|
126
|
+
|
|
127
|
+
def head(self, name: str) -> Entry:
|
|
128
|
+
found = self.versions(name)
|
|
129
|
+
if not found:
|
|
130
|
+
raise MissingArtifact(name)
|
|
131
|
+
return found[-1]
|
|
132
|
+
|
|
133
|
+
def entry(self, reference: str) -> Entry:
|
|
134
|
+
if "@" not in reference:
|
|
135
|
+
return self.head(reference)
|
|
136
|
+
name, _, version = reference.partition("@")
|
|
137
|
+
for candidate in self.versions(name):
|
|
138
|
+
if candidate.version == int(version):
|
|
139
|
+
return candidate
|
|
140
|
+
raise MissingArtifact(reference)
|
|
141
|
+
|
|
142
|
+
def dependents(self, digest: str) -> list[Entry]:
|
|
143
|
+
return [e for e in self.entries if digest in e.depends_on]
|
|
144
|
+
|
|
145
|
+
# -- writing
|
|
146
|
+
def publish(self, name: str, artifact: Any, *, provenance: Mapping[str, Any],
|
|
147
|
+
fixture: Sequence[tuple[Any, Any]] = (), depends_on: Sequence[str] = ()) -> Entry:
|
|
148
|
+
"""Store an artifact as the next version of ``name``, with a replayable fixture."""
|
|
149
|
+
payload = to_json(artifact)
|
|
150
|
+
digest = digest_of(payload)
|
|
151
|
+
(self.root / "artifacts" / f"{digest}.json").write_text(json.dumps(payload, indent=1, default=str))
|
|
152
|
+
(self.root / "fixtures" / f"{digest}.json").write_text(
|
|
153
|
+
json.dumps([{"facts": sorted(((p, v) for p, v in facts), key=repr), "expect": expect}
|
|
154
|
+
for facts, expect in fixture], indent=1, default=str))
|
|
155
|
+
superseded = self.versions(name)
|
|
156
|
+
version = superseded[-1].version + 1 if superseded else 1
|
|
157
|
+
entry = Entry(name, version, digest, payload["kind"], dict(provenance), tuple(depends_on))
|
|
158
|
+
# relearning marks every dependent stale: it must be revalidated, not trusted
|
|
159
|
+
if superseded:
|
|
160
|
+
old = superseded[-1].digest
|
|
161
|
+
self.entries = [
|
|
162
|
+
Entry(e.name, e.version, e.digest, e.kind, e.provenance, e.depends_on, True, e.at)
|
|
163
|
+
if old in e.depends_on else e
|
|
164
|
+
for e in self.entries
|
|
165
|
+
]
|
|
166
|
+
self.entries.append(entry)
|
|
167
|
+
self._save()
|
|
168
|
+
return entry
|
|
169
|
+
|
|
170
|
+
def load(self, reference: str, *, replay: bool = True) -> Any:
|
|
171
|
+
"""Load an artifact, replaying its fixture first unless told not to."""
|
|
172
|
+
entry = self.entry(reference)
|
|
173
|
+
path = self.root / "artifacts" / f"{entry.digest}.json"
|
|
174
|
+
if not path.exists():
|
|
175
|
+
raise MissingArtifact(f"{reference}: {entry.digest} is not in the library")
|
|
176
|
+
artifact = from_json(json.loads(path.read_text()))
|
|
177
|
+
if replay:
|
|
178
|
+
self._replay(entry, artifact)
|
|
179
|
+
return artifact
|
|
180
|
+
|
|
181
|
+
def revalidate(self, reference: str) -> Entry:
|
|
182
|
+
"""Clear ``stale`` only if the recorded fixture still reproduces exactly."""
|
|
183
|
+
entry = self.entry(reference)
|
|
184
|
+
artifact = self.load(reference, replay=True)
|
|
185
|
+
_ = artifact
|
|
186
|
+
self.entries = [
|
|
187
|
+
Entry(e.name, e.version, e.digest, e.kind, e.provenance, e.depends_on, False, e.at)
|
|
188
|
+
if (e.name, e.version) == (entry.name, entry.version) else e
|
|
189
|
+
for e in self.entries
|
|
190
|
+
]
|
|
191
|
+
self._save()
|
|
192
|
+
return self.entry(reference)
|
|
193
|
+
|
|
194
|
+
def verify(self) -> list[str]:
|
|
195
|
+
"""Every artifact still present and still reproducing its fixture; problems listed."""
|
|
196
|
+
problems: list[str] = []
|
|
197
|
+
for entry in self.entries:
|
|
198
|
+
try:
|
|
199
|
+
self.load(entry.reference, replay=True)
|
|
200
|
+
except LibraryError as exc:
|
|
201
|
+
problems.append(f"{entry.reference}: {exc}")
|
|
202
|
+
return problems
|
|
203
|
+
|
|
204
|
+
def _replay(self, entry: Entry, artifact: Any) -> None:
|
|
205
|
+
path = self.root / "fixtures" / f"{entry.digest}.json"
|
|
206
|
+
if not path.exists():
|
|
207
|
+
return
|
|
208
|
+
for case in json.loads(path.read_text()):
|
|
209
|
+
facts = frozenset((p, v) for p, v in (tuple(pair) for pair in case["facts"]))
|
|
210
|
+
got = artifact.predict(facts)
|
|
211
|
+
if got != case["expect"]:
|
|
212
|
+
raise FixtureMismatch(f"{entry.reference}: fixture expected {case['expect']!r}, got {got!r}")
|
|
213
|
+
|
|
214
|
+
def _save(self) -> None:
|
|
215
|
+
tmp = self.manifest_path.with_suffix(".tmp")
|
|
216
|
+
tmp.write_text(json.dumps([e.to_dict() for e in self.entries], indent=1, default=str))
|
|
217
|
+
tmp.replace(self.manifest_path)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The hypothesis language: conditions over claims, generated from what was observed.
|
|
2
|
+
|
|
3
|
+
Induction needs a space of candidate conditions, and the honest way to get one is
|
|
4
|
+
to read it off the data rather than to write it down: every literal below is
|
|
5
|
+
generated from claims that actually occurred. Nothing here names a domain.
|
|
6
|
+
|
|
7
|
+
A literal costs bits (:meth:`Literal.cost`), which is what lets an induced rule
|
|
8
|
+
be charged for its own description length instead of growing until it fits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
from ..records import Claim, Ref, Store
|
|
18
|
+
|
|
19
|
+
#: One case for induction: the claims that held, and the outcome to predict.
|
|
20
|
+
Case = tuple[frozenset[tuple[str, Any]], Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Literal:
|
|
25
|
+
"""A readable condition over one case's facts."""
|
|
26
|
+
|
|
27
|
+
predicate: str
|
|
28
|
+
value: Any = None
|
|
29
|
+
negated: bool = False
|
|
30
|
+
kind: str = "equals" # "equals" | "present" | "at_least"
|
|
31
|
+
|
|
32
|
+
def holds(self, facts: Mapping[str, Any] | frozenset) -> bool:
|
|
33
|
+
table = dict(facts) if isinstance(facts, frozenset) else facts
|
|
34
|
+
got = table.get(self.predicate, _MISSING)
|
|
35
|
+
if self.kind == "present":
|
|
36
|
+
out = got is not _MISSING
|
|
37
|
+
elif self.kind == "at_least":
|
|
38
|
+
out = got is not _MISSING and isinstance(got, (int, float)) and got >= self.value
|
|
39
|
+
else:
|
|
40
|
+
out = got == self.value
|
|
41
|
+
return not out if self.negated else out
|
|
42
|
+
|
|
43
|
+
def cost(self) -> int:
|
|
44
|
+
"""Bits-ish: a name, a test, and a value."""
|
|
45
|
+
return 2 + (0 if self.value is None else 1) + (1 if self.negated else 0)
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
body = {"present": f"has({self.predicate})",
|
|
49
|
+
"at_least": f"{self.predicate}>={self.value}",
|
|
50
|
+
"equals": f"{self.predicate}={self.value!r}"}[self.kind]
|
|
51
|
+
return f"¬{body}" if self.negated else body
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Missing:
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return "∅"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_MISSING = _Missing()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def facts_of(store: Store, subject: Ref, *, predicates: Sequence[str] | None = None) -> frozenset[tuple[str, Any]]:
|
|
63
|
+
"""One subject's live claims as a flat fact table (missing predicates stay missing)."""
|
|
64
|
+
out = {}
|
|
65
|
+
for record in store.claims(subject):
|
|
66
|
+
if predicates is None or record.claim.predicate in predicates:
|
|
67
|
+
out[record.claim.predicate] = record.claim.object
|
|
68
|
+
return frozenset(out.items())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def candidate_literals(cases: Sequence[Case], *, min_count: int = 2, max_values: int = 12) -> list[Literal]:
|
|
72
|
+
"""Every condition worth trying, from the values that actually occur.
|
|
73
|
+
|
|
74
|
+
A predicate seen with many distinct values contributes a presence test rather
|
|
75
|
+
than one literal per value, which keeps the space from exploding on identifiers.
|
|
76
|
+
"""
|
|
77
|
+
values: dict[str, Counter] = defaultdict(Counter)
|
|
78
|
+
for facts, _ in cases:
|
|
79
|
+
for predicate, value in facts:
|
|
80
|
+
try:
|
|
81
|
+
values[predicate][value] += 1
|
|
82
|
+
except TypeError: # unhashable objects are still worth a presence test
|
|
83
|
+
values[predicate]["<unhashable>"] += 1
|
|
84
|
+
out: list[Literal] = []
|
|
85
|
+
for predicate, counts in sorted(values.items()):
|
|
86
|
+
out.append(Literal(predicate, kind="present"))
|
|
87
|
+
out.append(Literal(predicate, kind="present", negated=True))
|
|
88
|
+
if len(counts) > max_values:
|
|
89
|
+
continue
|
|
90
|
+
for value, count in sorted(counts.items(), key=lambda kv: (-kv[1], repr(kv[0]))):
|
|
91
|
+
if count < min_count or value == "<unhashable>":
|
|
92
|
+
continue
|
|
93
|
+
out.append(Literal(predicate, value))
|
|
94
|
+
out.append(Literal(predicate, value, negated=True))
|
|
95
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
96
|
+
out.append(Literal(predicate, value, kind="at_least"))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rename_case(case: Case, mapping: Mapping[Any, Any]) -> Case:
|
|
101
|
+
"""A case with its symbols consistently renamed — the input to the rename control."""
|
|
102
|
+
facts, label = case
|
|
103
|
+
renamed = frozenset((p, mapping.get(v, v)) for p, v in facts)
|
|
104
|
+
return renamed, mapping.get(label, label)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def rename_facts(facts: frozenset, mapping: Mapping[Any, Any]) -> frozenset:
|
|
108
|
+
"""Rename the symbols *inside* a case, leaving its label alone.
|
|
109
|
+
|
|
110
|
+
The renaming control asks whether an artifact's output is unchanged when the
|
|
111
|
+
vocabulary changes. Renaming the labels too would make every artifact fail it,
|
|
112
|
+
which measures nothing.
|
|
113
|
+
"""
|
|
114
|
+
return frozenset((p, mapping.get(v, v)) for p, v in facts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rename_map(cases: Sequence[Case], *, prefix: str = "sym", labels: bool = False) -> dict[Any, Any]:
|
|
118
|
+
"""A consistent, order-independent renaming of the symbols in the cases."""
|
|
119
|
+
seen: list[Any] = []
|
|
120
|
+
for facts, label in cases:
|
|
121
|
+
for _, value in facts:
|
|
122
|
+
if isinstance(value, str) and value not in seen:
|
|
123
|
+
seen.append(value)
|
|
124
|
+
if labels and isinstance(label, str) and label not in seen:
|
|
125
|
+
seen.append(label)
|
|
126
|
+
return {value: f"{prefix}{i}" for i, value in enumerate(sorted(seen))}
|