statelock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statelock/__init__.py +18 -0
- statelock/discover.py +114 -0
- statelock/induce.py +90 -0
- statelock/layer.py +70 -0
- statelock/verify.py +65 -0
- statelock-0.1.0.dist-info/METADATA +128 -0
- statelock-0.1.0.dist-info/RECORD +10 -0
- statelock-0.1.0.dist-info/WHEEL +5 -0
- statelock-0.1.0.dist-info/licenses/LICENSE +21 -0
- statelock-0.1.0.dist-info/top_level.txt +1 -0
statelock/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""statelock -- verified finite machines inside neural networks.
|
|
2
|
+
|
|
3
|
+
Three things, in the order you would use them:
|
|
4
|
+
induce(...) work the machine out from labelled strings, by search
|
|
5
|
+
StateDiscoverer or let a network find it by gradient descent
|
|
6
|
+
equivalent(...) check the result against a reference by exhaustive BFS
|
|
7
|
+
StateLock attach the verified machine to your model
|
|
8
|
+
|
|
9
|
+
Everything here is decided by exact equivalence, not by held-out accuracy.
|
|
10
|
+
"""
|
|
11
|
+
from .verify import equivalent, reachable, minimal_size
|
|
12
|
+
from .induce import induce
|
|
13
|
+
from .discover import StateDiscoverer, fit
|
|
14
|
+
from .layer import StateLock, attach
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["induce", "equivalent", "reachable", "minimal_size",
|
|
18
|
+
"StateDiscoverer", "fit", "StateLock", "attach"]
|
statelock/discover.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Discover the automaton by gradient descent, with no oracle-driven search.
|
|
2
|
+
|
|
3
|
+
The state is a learned transition table run as a hard argmax scan with a
|
|
4
|
+
straight-through gradient. What makes it work, and what four earlier attempts
|
|
5
|
+
lacked, is the target: the state is trained to predict its MYHILL-NERODE ROW --
|
|
6
|
+
the labels the sequence would receive under a bundle of probe continuations --
|
|
7
|
+
rather than only the current label. Predicting the current label gives the
|
|
8
|
+
optimiser no reason to separate two states that agree now and differ later, and
|
|
9
|
+
a partition that is coarser than the target's cannot be repaired afterwards,
|
|
10
|
+
because merging only coarsens.
|
|
11
|
+
|
|
12
|
+
Three settings matter and were each fixed by measurement, not taste:
|
|
13
|
+
* train on strings that COVER the alphabet, not only in-distribution ones.
|
|
14
|
+
With grammatical data alone, loss reached 0.0 and every held-out label was
|
|
15
|
+
right, and exact equivalence still failed, because the table was
|
|
16
|
+
unconstrained on strings the generator never emits.
|
|
17
|
+
* interleave sequence lengths rather than staging them, or the short gradient
|
|
18
|
+
path is forgotten.
|
|
19
|
+
* hold the straight-through temperature constant. The forward pass is a hard
|
|
20
|
+
argmax at any temperature, so annealing only sharpens the surrogate until
|
|
21
|
+
the gradient vanishes.
|
|
22
|
+
|
|
23
|
+
Exactness tracks the training loss: runs reaching zero verified exact, runs that
|
|
24
|
+
stalled did not. So `fit` reports the loss and you restart on that, without
|
|
25
|
+
needing an answer key.
|
|
26
|
+
"""
|
|
27
|
+
import numpy as np
|
|
28
|
+
import torch
|
|
29
|
+
import torch.nn as nn
|
|
30
|
+
|
|
31
|
+
__all__ = ["StateDiscoverer", "fit"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Scan(nn.Module):
|
|
35
|
+
def __init__(self, V, K, d, seed=0):
|
|
36
|
+
super().__init__()
|
|
37
|
+
g = torch.Generator().manual_seed(seed)
|
|
38
|
+
self.W = nn.Parameter(torch.randn(V, K, K, generator=g) * 0.5)
|
|
39
|
+
self.s0 = nn.Parameter(torch.randn(K, generator=g) * 0.5)
|
|
40
|
+
self.emb = nn.Embedding(K, d)
|
|
41
|
+
with torch.no_grad():
|
|
42
|
+
self.emb.weight.normal_(0, 1.0, generator=g)
|
|
43
|
+
self.V, self.K = V, K
|
|
44
|
+
|
|
45
|
+
def forward(self, x, tau=1.0):
|
|
46
|
+
B, L = x.shape
|
|
47
|
+
p = torch.softmax(self.s0 / tau, -1).expand(B, self.K)
|
|
48
|
+
hard = torch.zeros_like(p).scatter_(1, p.argmax(-1, keepdim=True), 1.0)
|
|
49
|
+
st = hard + p - p.detach()
|
|
50
|
+
outs = []
|
|
51
|
+
for t in range(L):
|
|
52
|
+
logits = torch.einsum('bk,bkj->bj', st, self.W[x[:, t]])
|
|
53
|
+
p = torch.softmax(logits / tau, -1)
|
|
54
|
+
hard = torch.zeros_like(p).scatter_(1, p.argmax(-1, keepdim=True), 1.0)
|
|
55
|
+
st = hard + p - p.detach()
|
|
56
|
+
outs.append(st)
|
|
57
|
+
return torch.stack(outs, 1) @ self.emb.weight
|
|
58
|
+
|
|
59
|
+
def table(self):
|
|
60
|
+
return (self.W.argmax(-1).detach().cpu().numpy().astype(np.int64),
|
|
61
|
+
int(self.s0.argmax().item()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StateDiscoverer(nn.Module):
|
|
65
|
+
"""A discrete state layer that learns its own transition table."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, alphabet_size, n_slots, n_probes, d=64, seed=0):
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.scan = _Scan(alphabet_size, n_slots, d, seed)
|
|
70
|
+
self.row = nn.Linear(d, n_probes)
|
|
71
|
+
|
|
72
|
+
def forward(self, x, tau=1.0):
|
|
73
|
+
"""Returns per-position row logits."""
|
|
74
|
+
return self.row(self.scan(x, tau))
|
|
75
|
+
|
|
76
|
+
def states(self, x):
|
|
77
|
+
"""The discovered state index at each position (no gradient)."""
|
|
78
|
+
delta, s0 = self.scan.table()
|
|
79
|
+
x = x.cpu().numpy()
|
|
80
|
+
s = np.full(x.shape[0], s0, np.int64)
|
|
81
|
+
out = np.empty(x.shape, np.int64)
|
|
82
|
+
for t in range(x.shape[1]):
|
|
83
|
+
s = delta[x[:, t], s]; out[:, t] = s
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
def table(self):
|
|
87
|
+
return self.scan.table()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fit(model, batches, steps=40000, lr=2e-2, tau=1.0, log_every=0):
|
|
91
|
+
"""Train on an iterable-returning callable `batches(step) -> (x, rows)`.
|
|
92
|
+
|
|
93
|
+
x: LongTensor (B, L) of symbols
|
|
94
|
+
rows: FloatTensor (B, L, n_probes) of 0/1 probe labels
|
|
95
|
+
Returns the smoothed final loss. Exactness tracks this: restart if it does
|
|
96
|
+
not approach zero.
|
|
97
|
+
"""
|
|
98
|
+
head = [p for n, p in model.named_parameters() if not n.startswith("scan.W")
|
|
99
|
+
and not n.startswith("scan.s0")]
|
|
100
|
+
opt = torch.optim.AdamW([{"params": [model.scan.W, model.scan.s0], "lr": lr},
|
|
101
|
+
{"params": head, "lr": lr / 3}], weight_decay=0.0)
|
|
102
|
+
sch = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=[lr, lr / 3], total_steps=steps)
|
|
103
|
+
lf = nn.BCEWithLogitsLoss(); ema = None
|
|
104
|
+
for it in range(steps):
|
|
105
|
+
x, rows = batches(it)
|
|
106
|
+
loss = lf(model(x, tau), rows)
|
|
107
|
+
opt.zero_grad(); loss.backward()
|
|
108
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
|
109
|
+
opt.step(); sch.step()
|
|
110
|
+
v = float(loss.detach())
|
|
111
|
+
ema = v if ema is None else 0.98 * ema + 0.02 * v
|
|
112
|
+
if log_every and it % log_every == 0:
|
|
113
|
+
print(f"step {it} loss {ema:.5f}", flush=True)
|
|
114
|
+
return ema
|
statelock/induce.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Induce an exact automaton from (string, label) pairs, by search.
|
|
2
|
+
|
|
3
|
+
The learner never sees a state. It needs an oracle it can query for the label of
|
|
4
|
+
a string it chooses -- `label_fn(word) -> int` -- which is the same access a
|
|
5
|
+
person has when they can run the system they are modelling on inputs of their
|
|
6
|
+
choosing.
|
|
7
|
+
|
|
8
|
+
Construction: a cover set holding one representative prefix per discovered
|
|
9
|
+
state, closed under the alphabet; a suffix set grown by consistency repair when
|
|
10
|
+
two prefixes share a row but their extensions do not. Both corrections were
|
|
11
|
+
forced by verification failures, not chosen for elegance:
|
|
12
|
+
* sampling prefixes does not close the table (64k sampled prefixes still left
|
|
13
|
+
transitions undefined, and the verifier refused);
|
|
14
|
+
* a fixed suffix set under-separates (K = 149 against a minimal 152, with no
|
|
15
|
+
unresolved transitions -- a wrong merge that looks nearly right).
|
|
16
|
+
"""
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
__all__ = ["induce"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def induce(alphabet_size, label_fn, suffixes=None, max_rounds=200, max_states=100000):
|
|
23
|
+
"""Return (delta, out, start, info).
|
|
24
|
+
|
|
25
|
+
alphabet_size: number of input symbols, which are 0..alphabet_size-1.
|
|
26
|
+
label_fn: callable taking a tuple of symbols, returning an int label.
|
|
27
|
+
suffixes: optional starting suffix set (list of tuples). Defaults to the
|
|
28
|
+
empty string plus every single symbol; more are added as needed.
|
|
29
|
+
"""
|
|
30
|
+
V = alphabet_size
|
|
31
|
+
S = list(suffixes) if suffixes else [()] + [(a,) for a in range(V)]
|
|
32
|
+
P = [()]
|
|
33
|
+
cache = {}
|
|
34
|
+
|
|
35
|
+
def lab(w):
|
|
36
|
+
if w not in cache: cache[w] = int(label_fn(w))
|
|
37
|
+
return cache[w]
|
|
38
|
+
|
|
39
|
+
def row(p): return tuple(lab(p + s) for s in S)
|
|
40
|
+
|
|
41
|
+
rounds = added = 0
|
|
42
|
+
reps = {}; sid = {}; cover = {}
|
|
43
|
+
while rounds < max_rounds:
|
|
44
|
+
rounds += 1
|
|
45
|
+
e1 = {p + (a,) for p in P for a in range(V)}
|
|
46
|
+
e2 = {q + (b,) for q in e1 for b in range(V)}
|
|
47
|
+
cand = sorted(set(P) | e1 | e2, key=lambda w: (len(w), w))
|
|
48
|
+
rows = {p: row(p) for p in cand}
|
|
49
|
+
reps = {}; sid = {}; cover = {}
|
|
50
|
+
for p in cand:
|
|
51
|
+
r = rows[p]
|
|
52
|
+
if r not in reps:
|
|
53
|
+
reps[r] = len(reps); cover[reps[r]] = p
|
|
54
|
+
sid[p] = reps[r]
|
|
55
|
+
if len(reps) > max_states:
|
|
56
|
+
raise RuntimeError("state count exceeded max_states; the target "
|
|
57
|
+
"may not be finite-state at this resolution")
|
|
58
|
+
bad = None
|
|
59
|
+
bystate = {}
|
|
60
|
+
for p in cand: bystate.setdefault(sid[p], []).append(p)
|
|
61
|
+
for _, ps in bystate.items():
|
|
62
|
+
if len(ps) < 2: continue
|
|
63
|
+
p0 = ps[0]
|
|
64
|
+
for q in ps[1:]:
|
|
65
|
+
for a in range(V):
|
|
66
|
+
u, w = p0 + (a,), q + (a,)
|
|
67
|
+
if u in rows and w in rows and rows[u] != rows[w]:
|
|
68
|
+
j = next(i for i in range(len(S)) if rows[u][i] != rows[w][i])
|
|
69
|
+
bad = (a,) + S[j]; break
|
|
70
|
+
if bad: break
|
|
71
|
+
if bad: break
|
|
72
|
+
if bad is not None and bad not in S:
|
|
73
|
+
S.append(bad); added += 1; continue
|
|
74
|
+
newP = sorted(set(cover.values()), key=lambda w: (len(w), w))
|
|
75
|
+
if set(newP) == set(P): break
|
|
76
|
+
P = newP
|
|
77
|
+
|
|
78
|
+
K = len(reps)
|
|
79
|
+
delta = np.zeros((V, K), np.int64); unresolved = 0
|
|
80
|
+
for i in range(K):
|
|
81
|
+
p = cover[i]
|
|
82
|
+
for a in range(V):
|
|
83
|
+
q = p + (a,)
|
|
84
|
+
if q in sid: delta[a, i] = sid[q]
|
|
85
|
+
else: unresolved += 1
|
|
86
|
+
out = np.array([lab(cover[i]) for i in range(K)], np.int64)
|
|
87
|
+
info = {"states": K, "suffixes": len(S), "suffixes_added": added,
|
|
88
|
+
"rounds": rounds, "unresolved_transitions": unresolved,
|
|
89
|
+
"queries": len(cache)}
|
|
90
|
+
return delta, out, sid[()], info
|
statelock/layer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Attach a verified machine to a model.
|
|
2
|
+
|
|
3
|
+
`StateLock` turns a transition table into an embedding lookup over machine
|
|
4
|
+
states and adds it to a hidden-state tensor. The table is fixed: it is never
|
|
5
|
+
trained, so whatever was verified about it stays true inside the network.
|
|
6
|
+
|
|
7
|
+
Two ways to use it:
|
|
8
|
+
* directly, adding the state embedding to your own hidden states;
|
|
9
|
+
* `attach(hf_model, lock, input_ids_fn)` installs a forward hook on a Hugging
|
|
10
|
+
Face model's embedding layer, so the state is added to the token embeddings
|
|
11
|
+
with no change to the model's code.
|
|
12
|
+
"""
|
|
13
|
+
import numpy as np
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn as nn
|
|
16
|
+
|
|
17
|
+
__all__ = ["StateLock", "attach"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StateLock(nn.Module):
|
|
21
|
+
def __init__(self, delta, start=0, d_model=768, out=None):
|
|
22
|
+
super().__init__()
|
|
23
|
+
delta = np.asarray(delta, dtype=np.int64)
|
|
24
|
+
self.register_buffer("delta", torch.from_numpy(delta))
|
|
25
|
+
self.start = int(start)
|
|
26
|
+
self.n_states = delta.shape[1]
|
|
27
|
+
self.emb = nn.Embedding(self.n_states, d_model)
|
|
28
|
+
nn.init.normal_(self.emb.weight, std=0.02)
|
|
29
|
+
self.register_buffer("out", torch.from_numpy(np.asarray(out, dtype=np.int64))
|
|
30
|
+
if out is not None else torch.zeros(0, dtype=torch.long))
|
|
31
|
+
|
|
32
|
+
@torch.no_grad()
|
|
33
|
+
def run(self, x):
|
|
34
|
+
"""State index at every position. Exact, O(length), never trained."""
|
|
35
|
+
d = self.delta.cpu().numpy(); xi = x.cpu().numpy()
|
|
36
|
+
s = np.full(xi.shape[0], self.start, np.int64)
|
|
37
|
+
st = np.empty(xi.shape, np.int64)
|
|
38
|
+
for t in range(xi.shape[1]):
|
|
39
|
+
s = d[xi[:, t], s]; st[:, t] = s
|
|
40
|
+
return torch.from_numpy(st).to(x.device)
|
|
41
|
+
|
|
42
|
+
def forward(self, hidden, x):
|
|
43
|
+
"""hidden: (B, L, d). x: (B, L) symbols. Returns hidden + state embedding."""
|
|
44
|
+
return hidden + self.emb(self.run(x))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def attach(hf_model, lock, symbol_fn=None):
|
|
48
|
+
"""Add the machine's state to a Hugging Face model's token embeddings.
|
|
49
|
+
|
|
50
|
+
symbol_fn maps input_ids to machine symbols; omit it when the model's token
|
|
51
|
+
ids already are the machine's alphabet. Returns a handle; call
|
|
52
|
+
handle.remove() to detach.
|
|
53
|
+
"""
|
|
54
|
+
emb = hf_model.get_input_embeddings()
|
|
55
|
+
state = {"ids": None}
|
|
56
|
+
|
|
57
|
+
def pre_hook(_module, args, kwargs):
|
|
58
|
+
ids = args[0] if args else kwargs.get("input_ids")
|
|
59
|
+
state["ids"] = ids
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def hook(_module, args, output):
|
|
63
|
+
ids = args[0] if args else state["ids"]
|
|
64
|
+
if ids is None:
|
|
65
|
+
return output
|
|
66
|
+
sym = ids if symbol_fn is None else symbol_fn(ids)
|
|
67
|
+
return output + lock.emb(lock.run(sym)).to(output.dtype)
|
|
68
|
+
|
|
69
|
+
hf_model.register_forward_pre_hook(pre_hook, with_kwargs=True)
|
|
70
|
+
return emb.register_forward_hook(hook)
|
statelock/verify.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exact equivalence checking. This is the load-bearing function of the whole
|
|
2
|
+
library: it decides whether two automata agree on EVERY string, by breadth-first
|
|
3
|
+
search over reachable pairs of states, with no length cutoff.
|
|
4
|
+
|
|
5
|
+
Accuracy on sampled strings cannot substitute. In the work this library comes
|
|
6
|
+
from, a bounded enumeration to length 11 reported 6/6 correct and disagreed with
|
|
7
|
+
true equivalence on 22 of 48 deliberately corrupted targets.
|
|
8
|
+
"""
|
|
9
|
+
from collections import deque
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__ = ["equivalent", "reachable", "minimal_size"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def equivalent(delta_a, out_a, delta_b, out_b, start_a=0, start_b=0):
|
|
16
|
+
"""True if the two DFAs produce the same output on every input string.
|
|
17
|
+
|
|
18
|
+
delta_*: int array (alphabet_size, n_states); delta[x, s] is the next state.
|
|
19
|
+
out_*: int array (n_states,); the output symbol emitted in that state.
|
|
20
|
+
"""
|
|
21
|
+
delta_a, out_a = np.asarray(delta_a), np.asarray(out_a)
|
|
22
|
+
delta_b, out_b = np.asarray(delta_b), np.asarray(out_b)
|
|
23
|
+
A = delta_a.shape[0]
|
|
24
|
+
if delta_b.shape[0] != A:
|
|
25
|
+
raise ValueError("alphabet sizes differ")
|
|
26
|
+
seen = {(int(start_a), int(start_b))}
|
|
27
|
+
q = deque(seen)
|
|
28
|
+
while q:
|
|
29
|
+
a, b = q.popleft()
|
|
30
|
+
for x in range(A):
|
|
31
|
+
a2, b2 = int(delta_a[x, a]), int(delta_b[x, b])
|
|
32
|
+
if int(out_a[a2]) != int(out_b[b2]):
|
|
33
|
+
return False
|
|
34
|
+
if (a2, b2) not in seen:
|
|
35
|
+
seen.add((a2, b2)); q.append((a2, b2))
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reachable(delta, start=0):
|
|
40
|
+
"""Indices of states reachable from `start`."""
|
|
41
|
+
delta = np.asarray(delta); A = delta.shape[0]
|
|
42
|
+
seen = {int(start)}; q = deque(seen)
|
|
43
|
+
while q:
|
|
44
|
+
s = q.popleft()
|
|
45
|
+
for x in range(A):
|
|
46
|
+
t = int(delta[x, s])
|
|
47
|
+
if t not in seen:
|
|
48
|
+
seen.add(t); q.append(t)
|
|
49
|
+
return sorted(seen)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def minimal_size(delta, out):
|
|
53
|
+
"""Number of states in the minimal equivalent DFA (Moore refinement)."""
|
|
54
|
+
delta, out = np.asarray(delta), np.asarray(out)
|
|
55
|
+
K = delta.shape[1]; part = out.copy()
|
|
56
|
+
while True:
|
|
57
|
+
sig = [tuple([int(part[i])] + [int(part[delta[x, i]]) for x in range(delta.shape[0])])
|
|
58
|
+
for i in range(K)]
|
|
59
|
+
m = {}; new = np.empty(K, np.int64)
|
|
60
|
+
for i, s in enumerate(sig):
|
|
61
|
+
if s not in m: m[s] = len(m)
|
|
62
|
+
new[i] = m[s]
|
|
63
|
+
if len(m) == len(set(part.tolist())):
|
|
64
|
+
return len(set(part.tolist()))
|
|
65
|
+
part = new
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statelock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verified finite machines inside neural networks: induce one from labelled strings or discover it by gradient descent, check it by exhaustive equivalence, and attach it to your model.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/RaheemNazir/statelock
|
|
7
|
+
Keywords: state tracking,automata,length generalization,transformers,neural algorithmic reasoning,verification
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.22
|
|
12
|
+
Requires-Dist: torch>=2.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# statelock
|
|
16
|
+
|
|
17
|
+
Transformers lose track of state as inputs get longer. On a prose connectivity
|
|
18
|
+
task, a model goes from 0.92 accuracy at its training length to 0.50 at 16x
|
|
19
|
+
length, and growing it from 1.3M to 88M parameters does not help: the largest
|
|
20
|
+
model is the best in distribution and the worst at length.
|
|
21
|
+
|
|
22
|
+
`statelock` gives the model a finite machine to consult, and the machine is
|
|
23
|
+
**checked exhaustively** rather than measured on a test set. With one attached,
|
|
24
|
+
the same model holds 1.0000 at 16x length.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from statelock import induce, equivalent, StateLock
|
|
28
|
+
|
|
29
|
+
delta, out, start, info = induce(alphabet_size=8, label_fn=my_system) # find it
|
|
30
|
+
assert equivalent(delta, out, ref_delta, ref_out, start, ref_start) # check it
|
|
31
|
+
lock = StateLock(delta, start, d_model=768) # attach it
|
|
32
|
+
hidden = lock(hidden, symbols) # inside your model's forward
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That is the whole interface.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install statelock
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The three things it does
|
|
44
|
+
|
|
45
|
+
**Induce.** Work the machine out from labelled strings. No state supervision, no
|
|
46
|
+
hints. You supply `label_fn(word) -> int`, the label your system gives a string
|
|
47
|
+
you choose. On a 152-state target it recovers exactly 152 states, equal to the
|
|
48
|
+
minimal machine, and passes exact equivalence.
|
|
49
|
+
|
|
50
|
+
**Discover.** Or let a network find the machine during ordinary training, with
|
|
51
|
+
no search at all:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from statelock import StateDiscoverer, fit
|
|
55
|
+
model = StateDiscoverer(alphabet_size=6, n_slots=32, n_probes=64)
|
|
56
|
+
loss = fit(model, batches, steps=3000) # exactness tracks this loss
|
|
57
|
+
delta, start = model.table()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The state is trained to predict its Myhill-Nerode row, the labels a bundle of
|
|
61
|
+
probe continuations would produce, not just the current label. Predicting the
|
|
62
|
+
current label alone gives the optimiser no reason to separate two states that
|
|
63
|
+
agree now and differ later, and four earlier attempts failed for exactly that
|
|
64
|
+
reason. Exactness tracks the training loss: a run that reaches zero verifies,
|
|
65
|
+
one that stalls does not, so you restart on the loss without needing an answer
|
|
66
|
+
key.
|
|
67
|
+
|
|
68
|
+
**Verify.** `equivalent(...)` decides agreement on *every* string by BFS over
|
|
69
|
+
reachable state pairs, with no length cutoff. This is not optional rigour. A
|
|
70
|
+
bounded enumeration to length 11 once reported 6/6 correct here and disagreed
|
|
71
|
+
with true equivalence on 22 of 48 corrupted targets.
|
|
72
|
+
|
|
73
|
+
## With Hugging Face
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from statelock import StateLock, attach
|
|
77
|
+
lock = StateLock(delta, start, d_model=model.config.hidden_size)
|
|
78
|
+
handle = attach(model, lock, symbol_fn=my_tokens_to_symbols) # forward hook
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
No change to the model's code. `handle.remove()` detaches.
|
|
82
|
+
|
|
83
|
+
## What it is for, and what it is not for
|
|
84
|
+
|
|
85
|
+
Use it where the task has a discrete core with a state count in the hundreds:
|
|
86
|
+
entity and relation tracking across a document, protocol and permission state,
|
|
87
|
+
bracket and scope structure, small formal languages, board and game state.
|
|
88
|
+
|
|
89
|
+
It will not help where there is no such core, and it does not make a model
|
|
90
|
+
better at anything except keeping that state. The machine is a component you
|
|
91
|
+
attach, and attaching it is a design decision about your task.
|
|
92
|
+
|
|
93
|
+
## Measured
|
|
94
|
+
|
|
95
|
+
Prose connectivity, dense per-position labels, trained at length 128:
|
|
96
|
+
|
|
97
|
+
| model | arm | L=128 | L=2048 (16x) |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| 1.3M | none | 0.9155 | 0.5848 |
|
|
100
|
+
| 1.3M | shuffled machine | 0.8984 | 0.6033 |
|
|
101
|
+
| 1.3M | statelock | 1.0000 | 0.9998 |
|
|
102
|
+
| 27M | none | 0.9099 | 0.6191 |
|
|
103
|
+
| 27M | statelock | 1.0000 | 1.0000 |
|
|
104
|
+
| 88M | none | 0.9167 | 0.5028 |
|
|
105
|
+
| 88M | statelock | 1.0000 | 1.0000 |
|
|
106
|
+
|
|
107
|
+
Constant-predictor baseline 0.5204. The shuffled-machine row is the control that
|
|
108
|
+
matters: same parameters, same embedding, random transitions, no gain.
|
|
109
|
+
|
|
110
|
+
Discovery (the network finds the machine itself, then it is verified):
|
|
111
|
+
|
|
112
|
+
| target minimal states | slots offered | exact | loss on exact runs |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| 11 | 32 | 2/3 seeds | 0.0 |
|
|
115
|
+
| 38 | 128 | 1/3 seeds | 0.0 |
|
|
116
|
+
|
|
117
|
+
## Reproduce
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
python tests/test_all.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Induction to 152 states, 40 corruptions all flagged by the verifier, exact
|
|
124
|
+
running at length 4096, and a discovery run that verifies. Runs on CPU.
|
|
125
|
+
|
|
126
|
+
## Licence
|
|
127
|
+
|
|
128
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
statelock/__init__.py,sha256=bYYRj2IVMb-l0qv2k4RcyYyWtcbxWJ4BAlO2-7QoDvI,775
|
|
2
|
+
statelock/discover.py,sha256=7GDOIzW7KACAK5QJUiS5PgGmv2_iy6j1J7mvZKJ212Q,4817
|
|
3
|
+
statelock/induce.py,sha256=6n7VyKTvYutcbLjvLPyW2GgK-oqGUDNs-ioEsHeESEA,3689
|
|
4
|
+
statelock/layer.py,sha256=V39porJMt2sWKy-9gjIswUOrQ5CUvVD8nvIAtd27Pck,2705
|
|
5
|
+
statelock/verify.py,sha256=sFhV01_iggZE1dnQMu4doWX2EngsysRPZBr9S7KzYtE,2435
|
|
6
|
+
statelock-0.1.0.dist-info/licenses/LICENSE,sha256=JK2p0N_CKg82aKhciBBf1_pONnCSyhuOAAn3Gw0P7WU,1075
|
|
7
|
+
statelock-0.1.0.dist-info/METADATA,sha256=_--5kw7Tau553QUWlgDDRDuL7MtmQ2JxPjKpqq1L328,4728
|
|
8
|
+
statelock-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
statelock-0.1.0.dist-info/top_level.txt,sha256=OHWmWuALtqlMVydbdI0I3LPMLEFJe7XJj5HaEAF__PI,10
|
|
10
|
+
statelock-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdul Raheem Nazir
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
statelock
|