tstate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
tstate-0.1.0/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danny Gerhardt
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
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
tstate-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: tstate
3
+ Version: 0.1.0
4
+ Summary: Explicit-state reachability, closure, and attractor computation
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # tstate
11
+
12
+ Explicit-state reachability, closure, and attractor computation for finite
13
+ transition systems. Pure Python, no dependencies.
14
+
15
+ ## Install
16
+
17
+ pip install -e .
18
+
19
+ ## API
20
+
21
+ from tstate import TransitionSystem, make_quotient_system
22
+
23
+ edges = {"s0": ["s1"], "s1": ["s2"], "s2": ["s2"]}
24
+ ts = TransitionSystem(["s0"], lambda s: edges[s])
25
+
26
+ ts.reachable()
27
+ ts.is_closed(frozenset({"s2"}))
28
+ ts.closure_theorem()
29
+ ts.existential_attractor(frozenset({"s2"}))
30
+ ts.universal_attractor(frozenset({"s2"}))
31
+
32
+ ts = make_quotient_system(n=1000, k=3, local_succ=lambda v: {1} if v == 0 else {2})
33
+ len(ts.reachable()) # 501501
34
+
35
+ ## Scaling
36
+
37
+ | n | full (3^n) | quotient |
38
+ |------|------------|-------------|
39
+ | 9 | 19,683 | — |
40
+ | 100 | 3^100 | 5,151 |
41
+ | 1000 | 3^1000 | 501,501 |
42
+ | 5000 | 3^5000 | 12,507,501 |
43
+
44
+ ## Test
45
+
46
+ pytest -q
tstate-0.1.0/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # tstate
2
+
3
+ Explicit-state reachability, closure, and attractor computation for finite
4
+ transition systems. Pure Python, no dependencies.
5
+
6
+ ## Install
7
+
8
+ pip install -e .
9
+
10
+ ## API
11
+
12
+ from tstate import TransitionSystem, make_quotient_system
13
+
14
+ edges = {"s0": ["s1"], "s1": ["s2"], "s2": ["s2"]}
15
+ ts = TransitionSystem(["s0"], lambda s: edges[s])
16
+
17
+ ts.reachable()
18
+ ts.is_closed(frozenset({"s2"}))
19
+ ts.closure_theorem()
20
+ ts.existential_attractor(frozenset({"s2"}))
21
+ ts.universal_attractor(frozenset({"s2"}))
22
+
23
+ ts = make_quotient_system(n=1000, k=3, local_succ=lambda v: {1} if v == 0 else {2})
24
+ len(ts.reachable()) # 501501
25
+
26
+ ## Scaling
27
+
28
+ | n | full (3^n) | quotient |
29
+ |------|------------|-------------|
30
+ | 9 | 19,683 | — |
31
+ | 100 | 3^100 | 5,151 |
32
+ | 1000 | 3^1000 | 501,501 |
33
+ | 5000 | 3^5000 | 12,507,501 |
34
+
35
+ ## Test
36
+
37
+ pytest -q
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "tstate"
3
+ version = "0.1.0"
4
+ description = "Explicit-state reachability, closure, and attractor computation"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+
9
+ [build-system]
10
+ requires = ["hatchling"]
11
+ build-backend = "hatchling.build"
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["src/tstate"]
15
+
16
+ [tool.ruff]
17
+ line-length = 100
18
+ target-version = "py312"
19
+ src = ["src", "tests"]
20
+
21
+ [tool.ruff.lint]
22
+ select = ["E", "F", "W", "I", "UP", "B"]
23
+ ignore = ["E501", "UP046"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ pythonpath = ["src"]
28
+
29
+ [tool.coverage.run]
30
+ source = ["tstate"]
@@ -0,0 +1,19 @@
1
+ """tstate — explicit-state reachability and attractor computation."""
2
+
3
+ from tstate.core import (
4
+ TransitionSystem,
5
+ from_count_vector,
6
+ make_quotient_system,
7
+ make_system,
8
+ to_count_vector,
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "TransitionSystem",
14
+ "from_count_vector",
15
+ "make_quotient_system",
16
+ "make_system",
17
+ "to_count_vector",
18
+ "__version__",
19
+ ]
@@ -0,0 +1,168 @@
1
+ """Reference implementation of transition-system closure concepts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Callable, Hashable, Iterable
7
+ from itertools import product
8
+ from typing import Generic, TypeVar
9
+
10
+ S = TypeVar("S", bound=Hashable)
11
+
12
+
13
+ class TransitionSystem(Generic[S]):
14
+ """A finite transition system (S, ->)."""
15
+
16
+ def __init__(
17
+ self,
18
+ init_states: Iterable[S],
19
+ succ: Callable[[S], Iterable[S]],
20
+ ) -> None:
21
+ self.init_states: frozenset[S] = frozenset(init_states)
22
+ self._succ = succ
23
+
24
+ def succ(self, s: S) -> frozenset[S]:
25
+ return frozenset(self._succ(s))
26
+
27
+ def post(self, X: frozenset[S]) -> frozenset[S]:
28
+ return frozenset(s2 for s in X for s2 in self.succ(s))
29
+
30
+ def pre(self, X: frozenset[S], universe: frozenset[S]) -> frozenset[S]:
31
+ return frozenset(s for s in universe if self.succ(s) & X)
32
+
33
+ def reachable(self) -> frozenset[S]:
34
+ visited: set[S] = set(self.init_states)
35
+ queue: deque[S] = deque(self.init_states)
36
+ while queue:
37
+ s = queue.popleft()
38
+ for s2 in self.succ(s):
39
+ if s2 not in visited:
40
+ visited.add(s2)
41
+ queue.append(s2)
42
+ return frozenset(visited)
43
+
44
+ def reachable_lfp(self) -> frozenset[S]:
45
+ X: frozenset[S] = frozenset()
46
+ while True:
47
+ nxt = self.init_states | self.post(X)
48
+ if nxt == X:
49
+ return X
50
+ X = nxt
51
+
52
+ def is_closed(self, X: frozenset[S]) -> bool:
53
+ return self.post(X) <= X
54
+
55
+ def closure_theorem(self) -> dict:
56
+ R = self.reachable()
57
+ contains_init = self.init_states <= R
58
+ closed = self.is_closed(R)
59
+ minimal = True
60
+ for s in R:
61
+ if s in self.init_states:
62
+ continue
63
+ candidate = R - {s}
64
+ if self.init_states <= candidate and self.is_closed(candidate):
65
+ minimal = False
66
+ break
67
+ return {
68
+ "reachable_size": len(R),
69
+ "contains_init": contains_init,
70
+ "closed": closed,
71
+ "minimal": minimal,
72
+ "theorem_holds": contains_init and closed and minimal,
73
+ }
74
+
75
+ def existential_attractor(self, A: frozenset[S]) -> frozenset[S]:
76
+ universe = self.reachable()
77
+ X: frozenset[S] = frozenset()
78
+ while True:
79
+ nxt = A | self.pre(X, universe)
80
+ if nxt == X:
81
+ return X
82
+ X = nxt
83
+
84
+ def universal_attractor(self, A: frozenset[S]) -> frozenset[S]:
85
+ universe = self.reachable()
86
+ X: frozenset[S] = frozenset()
87
+ while True:
88
+ nxt = A
89
+ for s in universe:
90
+ post_s = self.succ(s)
91
+ if post_s and post_s <= X:
92
+ nxt = nxt | {s}
93
+ if nxt == X:
94
+ return X
95
+ X = nxt
96
+
97
+
98
+ def make_system(
99
+ n: int,
100
+ k: int,
101
+ local_succ: Callable[[int], set[int]],
102
+ *,
103
+ async_update: bool = True,
104
+ ) -> TransitionSystem[tuple[int, ...]]:
105
+ init = (0,) * n
106
+
107
+ if async_update:
108
+
109
+ def succ(state: tuple[int, ...]) -> set[tuple[int, ...]]:
110
+ if len(state) != n or not all(0 <= v < k for v in state):
111
+ return set()
112
+ out: set[tuple[int, ...]] = set()
113
+ for i in range(n):
114
+ for new_v in local_succ(state[i]):
115
+ if new_v == state[i]:
116
+ continue
117
+ s = list(state)
118
+ s[i] = new_v
119
+ out.add(tuple(s))
120
+ return out
121
+
122
+ else:
123
+
124
+ def succ(state: tuple[int, ...]) -> set[tuple[int, ...]]:
125
+ if len(state) != n or not all(0 <= v < k for v in state):
126
+ return set()
127
+ options = [sorted(local_succ(v)) for v in state]
128
+ return {tuple(p) for p in product(*options)}
129
+
130
+ return TransitionSystem([init], succ)
131
+
132
+
133
+ def to_count_vector(state: tuple[int, ...], k: int) -> tuple[int, ...]:
134
+ counts = [0] * k
135
+ for v in state:
136
+ counts[v] += 1
137
+ return tuple(counts)
138
+
139
+
140
+ def from_count_vector(cv: tuple[int, ...]) -> tuple[int, ...]:
141
+ out: list[int] = []
142
+ for v, c in enumerate(cv):
143
+ out.extend([v] * c)
144
+ return tuple(out)
145
+
146
+
147
+ def make_quotient_system(
148
+ n: int,
149
+ k: int,
150
+ local_succ: Callable[[int], set[int]],
151
+ ) -> TransitionSystem[tuple[int, ...]]:
152
+ init_cv = (n,) + (0,) * (k - 1)
153
+
154
+ def cv_succ(cv: tuple[int, ...]) -> set[tuple[int, ...]]:
155
+ out: set[tuple[int, ...]] = set()
156
+ for v in range(k):
157
+ if cv[v] == 0:
158
+ continue
159
+ for new_v in local_succ(v):
160
+ if new_v == v:
161
+ continue
162
+ nxt = list(cv)
163
+ nxt[v] -= 1
164
+ nxt[new_v] += 1
165
+ out.add(tuple(nxt))
166
+ return out
167
+
168
+ return TransitionSystem([init_cv], cv_succ)
@@ -0,0 +1,113 @@
1
+ """Tests for reference/closure_reference.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from tstate.core import (
8
+ TransitionSystem,
9
+ from_count_vector,
10
+ make_quotient_system,
11
+ make_system,
12
+ to_count_vector,
13
+ )
14
+
15
+
16
+ def _simple_chain() -> TransitionSystem[str]:
17
+ edges = {"s0": ["s1"], "s1": ["s2"], "s2": ["s2"]}
18
+ return TransitionSystem(["s0"], lambda s: edges[s])
19
+
20
+
21
+ def test_reachable_chain() -> None:
22
+ assert _simple_chain().reachable() == frozenset({"s0", "s1", "s2"})
23
+
24
+
25
+ def test_reachable_lfp_matches_bfs() -> None:
26
+ ts = _simple_chain()
27
+ assert ts.reachable_lfp() == ts.reachable()
28
+
29
+
30
+ def test_is_closed() -> None:
31
+ ts = _simple_chain()
32
+ assert ts.is_closed(frozenset({"s0", "s1", "s2"}))
33
+ assert ts.is_closed(frozenset({"s2"}))
34
+ assert not ts.is_closed(frozenset({"s0", "s1"}))
35
+ assert not ts.is_closed(frozenset({"s0"}))
36
+
37
+
38
+ def test_closure_theorem_chain() -> None:
39
+ r = _simple_chain().closure_theorem()
40
+ assert r["theorem_holds"]
41
+ assert r["reachable_size"] == 3
42
+
43
+
44
+ def _branching() -> TransitionSystem[str]:
45
+ edges = {"s0": ["s0", "s1"], "s1": ["s2"], "s2": ["s2"]}
46
+ return TransitionSystem(["s0"], lambda s: edges[s])
47
+
48
+
49
+ def test_existential_attractor_branching() -> None:
50
+ ts = _branching()
51
+ assert ts.existential_attractor(frozenset({"s2"})) == frozenset({"s0", "s1", "s2"})
52
+
53
+
54
+ def test_universal_attractor_branching() -> None:
55
+ ts = _branching()
56
+ assert ts.universal_attractor(frozenset({"s2"})) == frozenset({"s1", "s2"})
57
+
58
+
59
+ def test_universal_attractor_self() -> None:
60
+ ts = _branching()
61
+ assert ts.universal_attractor(frozenset({"s0"})) == frozenset({"s0"})
62
+
63
+
64
+ def _k2(v: int) -> set[int]:
65
+ return {0, 1}
66
+
67
+
68
+ def _k3_progress(v: int) -> set[int]:
69
+ return {1} if v == 0 else {2}
70
+
71
+
72
+ def test_agents_3_reachable() -> None:
73
+ assert len(make_system(3, 2, _k2).reachable()) == 8
74
+
75
+
76
+ def test_agents_3_closure_theorem() -> None:
77
+ assert make_system(3, 2, _k2).closure_theorem()["theorem_holds"]
78
+
79
+
80
+ def test_agents_10_quotient() -> None:
81
+ ts = make_quotient_system(10, 3, _k3_progress)
82
+ R = ts.reachable()
83
+ assert ts.closure_theorem()["theorem_holds"]
84
+ assert len(R) == (10 + 1) * (10 + 2) // 2
85
+
86
+
87
+ def test_agents_100_quotient() -> None:
88
+ ts = make_quotient_system(100, 3, _k3_progress)
89
+ assert ts.closure_theorem()["theorem_holds"]
90
+ assert len(ts.reachable()) == (100 + 1) * (100 + 2) // 2
91
+
92
+
93
+ def test_agents_1000_quotient() -> None:
94
+ ts = make_quotient_system(1000, 3, _k3_progress)
95
+ assert len(ts.reachable()) == (1000 + 1) * (1000 + 2) // 2
96
+
97
+
98
+ def test_quotient_matches_full_modulo_symmetry() -> None:
99
+ n, k = 4, 3
100
+ full = make_system(n, k, _k3_progress).reachable()
101
+ quot = make_quotient_system(n, k, _k3_progress).reachable()
102
+ full_cvs = {to_count_vector(s, k) for s in full}
103
+ assert full_cvs == quot
104
+
105
+
106
+ def test_count_vector_roundtrip() -> None:
107
+ for state in [(0, 1, 2), (1, 1, 1), (2, 2, 0)]:
108
+ assert from_count_vector(to_count_vector(state, 3)) == tuple(sorted(state))
109
+
110
+
111
+ def test_unknown_state_raises() -> None:
112
+ with pytest.raises(KeyError):
113
+ _simple_chain().succ("nonexistent")