sofic 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.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Matrix-native quasi-realization (pi, D, tau)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.exceptions import QuasiStochasticValidationError
|
|
11
|
+
from sofic.generators.base import QuasiStochasticModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuasiRealization(QuasiStochasticModel):
|
|
15
|
+
"""GPT quasi-realization quadruple (pi, D, tau)."""
|
|
16
|
+
|
|
17
|
+
pi: np.ndarray
|
|
18
|
+
tau: np.ndarray
|
|
19
|
+
symbol_maps: dict[Any, np.ndarray]
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
pi: np.ndarray,
|
|
24
|
+
tau: np.ndarray,
|
|
25
|
+
symbol_maps: dict[Any, np.ndarray],
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(**kwargs)
|
|
29
|
+
self.pi = np.asarray(pi, dtype=float)
|
|
30
|
+
self.tau = np.asarray(tau, dtype=float)
|
|
31
|
+
self.symbol_maps = {k: np.asarray(v, dtype=float) for k, v in symbol_maps.items()}
|
|
32
|
+
|
|
33
|
+
def validate_quasistochastic(self) -> None:
|
|
34
|
+
if not np.isclose(self.pi.sum(), 1.0):
|
|
35
|
+
raise QuasiStochasticValidationError(f"pi sums to {self.pi.sum()}, not 1")
|
|
36
|
+
|
|
37
|
+
def transition_matrices(self) -> dict[Any, np.ndarray]:
|
|
38
|
+
return dict(self.symbol_maps)
|
|
39
|
+
|
|
40
|
+
def stationary_quasidistribution(self) -> np.ndarray:
|
|
41
|
+
if self.pi.size == 0:
|
|
42
|
+
return np.array([], dtype=float)
|
|
43
|
+
combined = np.zeros((self.pi.size, self.pi.size), dtype=float)
|
|
44
|
+
for matrix in self.symbol_maps.values():
|
|
45
|
+
combined += matrix
|
|
46
|
+
return _stationary_left_quasivector(combined)
|
|
47
|
+
|
|
48
|
+
def word_probability(self, word: Sequence[Any]) -> float:
|
|
49
|
+
result = self.pi.copy()
|
|
50
|
+
for symbol in word:
|
|
51
|
+
matrix = self.symbol_maps.get(symbol)
|
|
52
|
+
if matrix is None:
|
|
53
|
+
return 0.0
|
|
54
|
+
result = result @ matrix
|
|
55
|
+
return float(result @ self.tau)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_nmachine(cls, nm: Any) -> QuasiRealization:
|
|
59
|
+
from sofic.generators.conversions import quasi_realization_from_nmachine
|
|
60
|
+
|
|
61
|
+
return quasi_realization_from_nmachine(nm)
|
|
62
|
+
|
|
63
|
+
def to_nmachine(self) -> Any:
|
|
64
|
+
from sofic.generators.conversions import nmachine_from_quasi_realization
|
|
65
|
+
|
|
66
|
+
return nmachine_from_quasi_realization(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stationary_left_quasivector(transition: np.ndarray) -> np.ndarray:
|
|
70
|
+
matrix = np.asarray(transition, dtype=float)
|
|
71
|
+
n = matrix.shape[0]
|
|
72
|
+
if matrix.shape != (n, n):
|
|
73
|
+
raise ValueError("transition matrix must be square")
|
|
74
|
+
|
|
75
|
+
eigenvalues, eigenvectors = np.linalg.eig(matrix.T)
|
|
76
|
+
candidates = sorted(range(n), key=lambda i: abs(eigenvalues[i] - 1.0))
|
|
77
|
+
for index in candidates:
|
|
78
|
+
if not np.isclose(eigenvalues[index], 1.0, rtol=1e-9, atol=1e-10):
|
|
79
|
+
continue
|
|
80
|
+
vector = np.real_if_close(eigenvectors[:, index], tol=1000)
|
|
81
|
+
if np.iscomplexobj(vector):
|
|
82
|
+
continue
|
|
83
|
+
distribution = np.asarray(vector, dtype=float)
|
|
84
|
+
total = float(distribution.sum())
|
|
85
|
+
if np.isclose(total, 0.0, atol=1e-12):
|
|
86
|
+
continue
|
|
87
|
+
distribution = distribution / total
|
|
88
|
+
if np.allclose(distribution @ matrix, distribution, rtol=1e-8, atol=1e-10):
|
|
89
|
+
return distribution
|
|
90
|
+
|
|
91
|
+
augmented = np.vstack([matrix.T - np.eye(n), np.ones(n)])
|
|
92
|
+
target = np.zeros(n + 1, dtype=float)
|
|
93
|
+
target[-1] = 1.0
|
|
94
|
+
solution, *_ = np.linalg.lstsq(augmented, target, rcond=None)
|
|
95
|
+
if not np.allclose(solution @ matrix, solution, rtol=1e-8, atol=1e-10):
|
|
96
|
+
raise QuasiStochasticValidationError("failed to compute an invariant stationary quasidistribution")
|
|
97
|
+
return solution
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Time-reversal helpers for stochastic generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from sofic.base import StateMachine
|
|
8
|
+
from sofic.generators.prob import (
|
|
9
|
+
as_prob,
|
|
10
|
+
has_symbolic,
|
|
11
|
+
is_positive_mass,
|
|
12
|
+
is_zero,
|
|
13
|
+
simplify_prob,
|
|
14
|
+
)
|
|
15
|
+
from sofic.graph import ATTR_EMISSION, ATTR_EMISSION_DIST, ATTR_PROB, TransitionGraph
|
|
16
|
+
|
|
17
|
+
S = TypeVar("S", bound=StateMachine)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_markov_like(model: StateMachine) -> bool:
|
|
21
|
+
"""Return whether ``model`` has only Markov transition probabilities on edges."""
|
|
22
|
+
for state in model.states():
|
|
23
|
+
if model.graph.state_attrs(state).get(ATTR_EMISSION_DIST) is not None:
|
|
24
|
+
return False
|
|
25
|
+
for transition in model.transitions():
|
|
26
|
+
data = transition.data
|
|
27
|
+
if ATTR_EMISSION in data or ATTR_EMISSION_DIST in data:
|
|
28
|
+
return False
|
|
29
|
+
if ATTR_PROB not in data:
|
|
30
|
+
return False
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def time_reverse_stochastic(model: S) -> S: # noqa: UP047 - keep Python 3.11 compatibility.
|
|
35
|
+
"""Build the time-reversed chain using the forward stationary distribution."""
|
|
36
|
+
pi = model.stationary_distribution()
|
|
37
|
+
idx = model.reindex()
|
|
38
|
+
rev = model.copy()
|
|
39
|
+
rev.graph = TransitionGraph()
|
|
40
|
+
for state in idx.states:
|
|
41
|
+
rev.graph.add_state(state)
|
|
42
|
+
|
|
43
|
+
symbolic = pi.dtype == object or has_symbolic(pi.ravel())
|
|
44
|
+
for source in idx.states:
|
|
45
|
+
i = idx.index(source)
|
|
46
|
+
for transition in model.graph.out_transitions(source):
|
|
47
|
+
target = transition.target
|
|
48
|
+
j = idx.index(target)
|
|
49
|
+
prob = as_prob(transition.data.get(ATTR_PROB, 0.0))
|
|
50
|
+
if not is_positive_mass(prob) or is_zero(pi[j]):
|
|
51
|
+
continue
|
|
52
|
+
if symbolic or has_symbolic([prob]):
|
|
53
|
+
rev_prob = simplify_prob(as_prob(pi[i]) * as_prob(prob) / as_prob(pi[j]))
|
|
54
|
+
else:
|
|
55
|
+
rev_prob = float(pi[i] * float(prob) / float(pi[j]))
|
|
56
|
+
attrs: dict[str, Any] = {ATTR_PROB: as_prob(rev_prob)}
|
|
57
|
+
if ATTR_EMISSION in transition.data:
|
|
58
|
+
attrs[ATTR_EMISSION] = transition.data[ATTR_EMISSION]
|
|
59
|
+
rev.graph.add_transition(target, source, **attrs)
|
|
60
|
+
|
|
61
|
+
if hasattr(rev, "initial_distribution"):
|
|
62
|
+
if symbolic:
|
|
63
|
+
rev.initial_distribution = {idx.state(i): as_prob(pi[i]) for i in range(len(idx))}
|
|
64
|
+
else:
|
|
65
|
+
rev.initial_distribution = {idx.state(i): float(pi[i]) for i in range(len(idx))}
|
|
66
|
+
return rev
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Hidden Markov generators with visibly pushdown stack state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from sofic.exceptions import StochasticValidationError
|
|
11
|
+
from sofic.generators.base import StochasticModel
|
|
12
|
+
from sofic.generators.stationary import stationary_distribution_from_transition
|
|
13
|
+
from sofic.graph import (
|
|
14
|
+
ATTR_KIND,
|
|
15
|
+
ATTR_PROB,
|
|
16
|
+
ATTR_SYMBOL,
|
|
17
|
+
KIND_CALL,
|
|
18
|
+
KIND_INTERNAL,
|
|
19
|
+
KIND_RETURN,
|
|
20
|
+
Transition,
|
|
21
|
+
TransitionGraph,
|
|
22
|
+
)
|
|
23
|
+
from sofic.shifts.sofic_dyck import MatchedEdge, SoficDyckShift, TransitionRef, transition_ref
|
|
24
|
+
|
|
25
|
+
Configuration = tuple[Hashable, tuple[TransitionRef, ...]]
|
|
26
|
+
_KINDS = frozenset({KIND_CALL, KIND_RETURN, KIND_INTERNAL})
|
|
27
|
+
_TOL = 1e-15
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HiddenMarkovStackModel(StochasticModel):
|
|
31
|
+
"""Stochastic visibly-pushdown generator over a finite control graph.
|
|
32
|
+
|
|
33
|
+
The hidden configuration is a finite control state together with an
|
|
34
|
+
unbounded stack of call-edge references. Outgoing edge probabilities are
|
|
35
|
+
interpreted as weights over the transitions enabled by the current stack.
|
|
36
|
+
The enabled weights are normalized at each step, so return transitions that
|
|
37
|
+
are disabled by the stack do not make the stochastic row invalid.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
call_alphabet: frozenset[Any]
|
|
41
|
+
return_alphabet: frozenset[Any]
|
|
42
|
+
internal_alphabet: frozenset[Any]
|
|
43
|
+
symbol_alphabet: frozenset[Any]
|
|
44
|
+
matched_edges: frozenset[MatchedEdge]
|
|
45
|
+
allow_empty_stack_returns: bool
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
call_alphabet: frozenset[Any] | None = None,
|
|
50
|
+
return_alphabet: frozenset[Any] | None = None,
|
|
51
|
+
internal_alphabet: frozenset[Any] | None = None,
|
|
52
|
+
matched_edges: set[MatchedEdge] | frozenset[MatchedEdge] | None = None,
|
|
53
|
+
symbol_alphabet: frozenset[Any] | None = None,
|
|
54
|
+
allow_empty_stack_returns: bool = True,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.call_alphabet = call_alphabet if call_alphabet is not None else frozenset()
|
|
58
|
+
self.return_alphabet = return_alphabet if return_alphabet is not None else frozenset()
|
|
59
|
+
self.internal_alphabet = internal_alphabet if internal_alphabet is not None else frozenset()
|
|
60
|
+
inferred_alphabet = self.call_alphabet | self.return_alphabet | self.internal_alphabet
|
|
61
|
+
self.symbol_alphabet = symbol_alphabet if symbol_alphabet is not None else inferred_alphabet
|
|
62
|
+
self.matched_edges = frozenset(matched_edges or frozenset())
|
|
63
|
+
self.allow_empty_stack_returns = allow_empty_stack_returns
|
|
64
|
+
super().__init__(**kwargs)
|
|
65
|
+
|
|
66
|
+
def validate_stochastic(self) -> None:
|
|
67
|
+
super().validate_stochastic()
|
|
68
|
+
role_alphabet = self.call_alphabet | self.return_alphabet | self.internal_alphabet
|
|
69
|
+
self._require(
|
|
70
|
+
len(self.call_alphabet) + len(self.return_alphabet) + len(self.internal_alphabet) == len(role_alphabet),
|
|
71
|
+
"call, return, and internal alphabets must be disjoint",
|
|
72
|
+
)
|
|
73
|
+
self._require(self.symbol_alphabet == role_alphabet, "symbol_alphabet must equal the visible role alphabets")
|
|
74
|
+
|
|
75
|
+
call_edges: set[TransitionRef] = set()
|
|
76
|
+
return_edges: set[TransitionRef] = set()
|
|
77
|
+
all_edges: set[TransitionRef] = set()
|
|
78
|
+
outgoing_mass = dict.fromkeys(self.states(), 0.0)
|
|
79
|
+
for transition in self.transitions():
|
|
80
|
+
ref = transition_ref(transition)
|
|
81
|
+
all_edges.add(ref)
|
|
82
|
+
kind = transition.data.get(ATTR_KIND)
|
|
83
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
84
|
+
prob = float(transition.data.get(ATTR_PROB, 0.0))
|
|
85
|
+
if prob < 0.0:
|
|
86
|
+
raise StochasticValidationError(f"negative transition probability on {transition}")
|
|
87
|
+
outgoing_mass[transition.source] = outgoing_mass.get(transition.source, 0.0) + prob
|
|
88
|
+
self._require(kind in _KINDS, f"invalid stack edge kind {kind!r}")
|
|
89
|
+
self._require(symbol is not None, "stack generator transitions require a symbol")
|
|
90
|
+
if kind == KIND_CALL:
|
|
91
|
+
self._require(symbol in self.call_alphabet, f"{symbol!r} not in call alphabet")
|
|
92
|
+
call_edges.add(ref)
|
|
93
|
+
elif kind == KIND_RETURN:
|
|
94
|
+
self._require(symbol in self.return_alphabet, f"{symbol!r} not in return alphabet")
|
|
95
|
+
return_edges.add(ref)
|
|
96
|
+
else:
|
|
97
|
+
self._require(symbol in self.internal_alphabet, f"{symbol!r} not in internal alphabet")
|
|
98
|
+
|
|
99
|
+
for state, mass in outgoing_mass.items():
|
|
100
|
+
if mass <= 0.0:
|
|
101
|
+
raise StochasticValidationError(f"outgoing transition mass from {state!r} is not positive")
|
|
102
|
+
|
|
103
|
+
for call_ref, return_ref in self.matched_edges:
|
|
104
|
+
self._require(call_ref in all_edges, f"matched call edge {call_ref!r} is missing")
|
|
105
|
+
self._require(return_ref in all_edges, f"matched return edge {return_ref!r} is missing")
|
|
106
|
+
self._require(call_ref in call_edges, f"matched edge {call_ref!r} is not a call transition")
|
|
107
|
+
self._require(return_ref in return_edges, f"matched edge {return_ref!r} is not a return transition")
|
|
108
|
+
|
|
109
|
+
def add_call_transition(
|
|
110
|
+
self,
|
|
111
|
+
source: Hashable,
|
|
112
|
+
target: Hashable,
|
|
113
|
+
symbol: Any,
|
|
114
|
+
prob: float,
|
|
115
|
+
**attrs: Any,
|
|
116
|
+
) -> TransitionRef:
|
|
117
|
+
"""Add a call transition and return its stable edge reference."""
|
|
118
|
+
data = {**attrs, ATTR_KIND: KIND_CALL, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
|
|
119
|
+
key = self.graph.add_transition(source, target, **data)
|
|
120
|
+
return source, target, key
|
|
121
|
+
|
|
122
|
+
def add_return_transition(
|
|
123
|
+
self,
|
|
124
|
+
source: Hashable,
|
|
125
|
+
target: Hashable,
|
|
126
|
+
symbol: Any,
|
|
127
|
+
prob: float,
|
|
128
|
+
**attrs: Any,
|
|
129
|
+
) -> TransitionRef:
|
|
130
|
+
"""Add a return transition and return its stable edge reference."""
|
|
131
|
+
data = {**attrs, ATTR_KIND: KIND_RETURN, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
|
|
132
|
+
key = self.graph.add_transition(source, target, **data)
|
|
133
|
+
return source, target, key
|
|
134
|
+
|
|
135
|
+
def add_internal_transition(
|
|
136
|
+
self,
|
|
137
|
+
source: Hashable,
|
|
138
|
+
target: Hashable,
|
|
139
|
+
symbol: Any,
|
|
140
|
+
prob: float,
|
|
141
|
+
**attrs: Any,
|
|
142
|
+
) -> TransitionRef:
|
|
143
|
+
"""Add an internal transition and return its stable edge reference."""
|
|
144
|
+
data = {**attrs, ATTR_KIND: KIND_INTERNAL, ATTR_SYMBOL: symbol, ATTR_PROB: prob}
|
|
145
|
+
key = self.graph.add_transition(source, target, **data)
|
|
146
|
+
return source, target, key
|
|
147
|
+
|
|
148
|
+
def add_matched_pair(self, call_ref: TransitionRef, return_ref: TransitionRef) -> None:
|
|
149
|
+
"""Mark ``call_ref`` and ``return_ref`` as a legal call-return pair."""
|
|
150
|
+
self.matched_edges = frozenset({*self.matched_edges, (call_ref, return_ref)})
|
|
151
|
+
|
|
152
|
+
def sample(
|
|
153
|
+
self,
|
|
154
|
+
n: int,
|
|
155
|
+
rng: np.random.Generator | None = None,
|
|
156
|
+
) -> tuple[list[Any], list[Configuration]]:
|
|
157
|
+
"""Generate up to ``n`` symbols and the pre-emission configurations."""
|
|
158
|
+
if n < 0:
|
|
159
|
+
raise ValueError("n must be nonnegative")
|
|
160
|
+
generator = rng if rng is not None else np.random.default_rng()
|
|
161
|
+
initial_states = [state for state, mass in self.initial_distribution.items() if mass > 0.0]
|
|
162
|
+
if not initial_states:
|
|
163
|
+
return [], []
|
|
164
|
+
initial_probs = np.array([float(self.initial_distribution[state]) for state in initial_states], dtype=float)
|
|
165
|
+
state = initial_states[int(generator.choice(len(initial_states), p=initial_probs / initial_probs.sum()))]
|
|
166
|
+
config: Configuration = (state, ())
|
|
167
|
+
|
|
168
|
+
observations: list[Any] = []
|
|
169
|
+
configurations: list[Configuration] = []
|
|
170
|
+
for _ in range(n):
|
|
171
|
+
successors = self._normalized_successors(config)
|
|
172
|
+
if not successors:
|
|
173
|
+
break
|
|
174
|
+
probs = np.array([prob for _transition, prob, _next_config in successors], dtype=float)
|
|
175
|
+
transition, _prob, next_config = successors[int(generator.choice(len(successors), p=probs))]
|
|
176
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
177
|
+
if symbol is None:
|
|
178
|
+
break
|
|
179
|
+
configurations.append(config)
|
|
180
|
+
observations.append(symbol)
|
|
181
|
+
config = next_config
|
|
182
|
+
return observations, configurations
|
|
183
|
+
|
|
184
|
+
def word_probability(self, word: Sequence[Any]) -> float:
|
|
185
|
+
"""Return the probability of emitting ``word`` from the initial law."""
|
|
186
|
+
word = tuple(word)
|
|
187
|
+
if not word:
|
|
188
|
+
return float(sum(self.initial_distribution.values()))
|
|
189
|
+
if any(symbol not in self.symbol_alphabet for symbol in word):
|
|
190
|
+
return 0.0
|
|
191
|
+
|
|
192
|
+
current: dict[Configuration, float] = {
|
|
193
|
+
(state, ()): float(prob) for state, prob in self.initial_distribution.items() if prob > _TOL
|
|
194
|
+
}
|
|
195
|
+
for symbol in word:
|
|
196
|
+
next_masses: dict[Configuration, float] = {}
|
|
197
|
+
for config, mass in current.items():
|
|
198
|
+
for transition, prob, next_config in self._normalized_successors(config):
|
|
199
|
+
if transition.data.get(ATTR_SYMBOL) != symbol:
|
|
200
|
+
continue
|
|
201
|
+
next_masses[next_config] = next_masses.get(next_config, 0.0) + mass * prob
|
|
202
|
+
current = {config: mass for config, mass in next_masses.items() if mass > _TOL}
|
|
203
|
+
if not current:
|
|
204
|
+
return 0.0
|
|
205
|
+
return float(sum(current.values()))
|
|
206
|
+
|
|
207
|
+
def words_of_length(self, length: int) -> dict[tuple[Any, ...], float]:
|
|
208
|
+
"""Return emitted words of ``length`` and their probabilities."""
|
|
209
|
+
if length < 0:
|
|
210
|
+
raise ValueError("length must be nonnegative")
|
|
211
|
+
if length == 0:
|
|
212
|
+
total = float(sum(self.initial_distribution.values()))
|
|
213
|
+
return {(): total} if total > _TOL else {}
|
|
214
|
+
|
|
215
|
+
layers: dict[tuple[Any, ...], dict[Configuration, float]] = {
|
|
216
|
+
(): {(state, ()): float(prob) for state, prob in self.initial_distribution.items() if prob > _TOL}
|
|
217
|
+
}
|
|
218
|
+
for _ in range(length):
|
|
219
|
+
next_layers: dict[tuple[Any, ...], dict[Configuration, float]] = {}
|
|
220
|
+
for prefix, configs in layers.items():
|
|
221
|
+
for config, mass in configs.items():
|
|
222
|
+
for transition, prob, next_config in self._normalized_successors(config):
|
|
223
|
+
symbol = transition.data.get(ATTR_SYMBOL)
|
|
224
|
+
if symbol is None:
|
|
225
|
+
continue
|
|
226
|
+
next_prefix = prefix + (symbol,)
|
|
227
|
+
bucket = next_layers.setdefault(next_prefix, {})
|
|
228
|
+
bucket[next_config] = bucket.get(next_config, 0.0) + mass * prob
|
|
229
|
+
layers = next_layers
|
|
230
|
+
if not layers:
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
distribution: dict[tuple[Any, ...], float] = {}
|
|
234
|
+
for word, configs in layers.items():
|
|
235
|
+
probability = float(sum(configs.values()))
|
|
236
|
+
if probability > _TOL:
|
|
237
|
+
distribution[word] = probability
|
|
238
|
+
return distribution
|
|
239
|
+
|
|
240
|
+
def reachable_configurations(self, max_stack_depth: int) -> tuple[Configuration, ...]:
|
|
241
|
+
"""Return configurations reachable from the initial law up to stack depth."""
|
|
242
|
+
if max_stack_depth < 0:
|
|
243
|
+
raise ValueError("max_stack_depth must be nonnegative")
|
|
244
|
+
starts = [(state, ()) for state, prob in self.initial_distribution.items() if prob > _TOL]
|
|
245
|
+
configurations: list[Configuration] = []
|
|
246
|
+
seen: set[Configuration] = set()
|
|
247
|
+
queue = list(starts)
|
|
248
|
+
for config in starts:
|
|
249
|
+
seen.add(config)
|
|
250
|
+
|
|
251
|
+
while queue:
|
|
252
|
+
config = queue.pop(0)
|
|
253
|
+
configurations.append(config)
|
|
254
|
+
for _transition, _prob, next_config in self._normalized_successors(
|
|
255
|
+
config,
|
|
256
|
+
max_stack_depth=max_stack_depth,
|
|
257
|
+
):
|
|
258
|
+
if next_config in seen:
|
|
259
|
+
continue
|
|
260
|
+
seen.add(next_config)
|
|
261
|
+
queue.append(next_config)
|
|
262
|
+
return tuple(configurations)
|
|
263
|
+
|
|
264
|
+
def configuration_transition_matrix(self, max_stack_depth: int) -> np.ndarray:
|
|
265
|
+
"""Return the finite-depth transition matrix over reachable configurations."""
|
|
266
|
+
matrix, _configs = self._configuration_transition_matrix_and_configs(max_stack_depth)
|
|
267
|
+
return matrix
|
|
268
|
+
|
|
269
|
+
def stationary_distribution(
|
|
270
|
+
self,
|
|
271
|
+
max_stack_depth: int,
|
|
272
|
+
marginal: Literal["control", "configuration"] = "control",
|
|
273
|
+
) -> np.ndarray:
|
|
274
|
+
"""Return a finite-depth stationary distribution.
|
|
275
|
+
|
|
276
|
+
``marginal="configuration"`` returns the distribution over truncated
|
|
277
|
+
stack configurations. ``marginal="control"`` sums those masses over the
|
|
278
|
+
finite control states in this model's normal state order.
|
|
279
|
+
"""
|
|
280
|
+
matrix, configs = self._configuration_transition_matrix_and_configs(max_stack_depth)
|
|
281
|
+
pi = stationary_distribution_from_transition(matrix)
|
|
282
|
+
if marginal == "configuration":
|
|
283
|
+
return pi
|
|
284
|
+
if marginal != "control":
|
|
285
|
+
raise ValueError("marginal must be 'control' or 'configuration'")
|
|
286
|
+
|
|
287
|
+
idx = self.reindex()
|
|
288
|
+
control = np.zeros(len(idx), dtype=float)
|
|
289
|
+
for mass, (state, _stack) in zip(pi, configs, strict=True):
|
|
290
|
+
control[idx.index(state)] += float(mass)
|
|
291
|
+
return control
|
|
292
|
+
|
|
293
|
+
def to_sofic_dyck_shift(self) -> SoficDyckShift:
|
|
294
|
+
"""Strip probabilities and return the positive-probability Dyck support."""
|
|
295
|
+
graph = TransitionGraph()
|
|
296
|
+
for state in self.states():
|
|
297
|
+
graph.add_state(state, **self.graph.state_attrs(state))
|
|
298
|
+
|
|
299
|
+
edge_map: dict[TransitionRef, TransitionRef] = {}
|
|
300
|
+
for transition in self.transitions():
|
|
301
|
+
if float(transition.data.get(ATTR_PROB, 0.0)) <= 0.0:
|
|
302
|
+
continue
|
|
303
|
+
data = {key: value for key, value in transition.data.items() if key != ATTR_PROB}
|
|
304
|
+
key = graph.add_transition(transition.source, transition.target, **data)
|
|
305
|
+
edge_map[transition_ref(transition)] = (transition.source, transition.target, key)
|
|
306
|
+
|
|
307
|
+
matched_edges = frozenset(
|
|
308
|
+
(edge_map[call_ref], edge_map[return_ref])
|
|
309
|
+
for call_ref, return_ref in self.matched_edges
|
|
310
|
+
if call_ref in edge_map and return_ref in edge_map
|
|
311
|
+
)
|
|
312
|
+
return SoficDyckShift(
|
|
313
|
+
graph=graph,
|
|
314
|
+
call_alphabet=self.call_alphabet,
|
|
315
|
+
return_alphabet=self.return_alphabet,
|
|
316
|
+
internal_alphabet=self.internal_alphabet,
|
|
317
|
+
matched_edges=matched_edges,
|
|
318
|
+
symbol_alphabet=self.symbol_alphabet,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def from_sofic_dyck_shift(
|
|
323
|
+
cls,
|
|
324
|
+
shift: SoficDyckShift,
|
|
325
|
+
probabilities: Mapping[TransitionRef, float],
|
|
326
|
+
initial_distribution: Mapping[Hashable, float] | None = None,
|
|
327
|
+
**kwargs: Any,
|
|
328
|
+
) -> HiddenMarkovStackModel:
|
|
329
|
+
"""Build a stochastic stack model by assigning probabilities to a Dyck shift."""
|
|
330
|
+
graph = TransitionGraph()
|
|
331
|
+
for state in shift.states():
|
|
332
|
+
graph.add_state(state, **shift.graph.state_attrs(state))
|
|
333
|
+
|
|
334
|
+
edge_map: dict[TransitionRef, TransitionRef] = {}
|
|
335
|
+
for transition in shift.transitions():
|
|
336
|
+
ref = transition_ref(transition)
|
|
337
|
+
if ref not in probabilities:
|
|
338
|
+
raise ValueError(f"missing probability for transition {ref!r}")
|
|
339
|
+
data = dict(transition.data)
|
|
340
|
+
data[ATTR_PROB] = float(probabilities[ref])
|
|
341
|
+
key = graph.add_transition(transition.source, transition.target, **data)
|
|
342
|
+
edge_map[ref] = (transition.source, transition.target, key)
|
|
343
|
+
|
|
344
|
+
matched_edges = frozenset(
|
|
345
|
+
(edge_map[call_ref], edge_map[return_ref]) for call_ref, return_ref in shift.matched_edges
|
|
346
|
+
)
|
|
347
|
+
initial = (
|
|
348
|
+
dict(initial_distribution) if initial_distribution is not None else _uniform_initial_distribution(shift)
|
|
349
|
+
)
|
|
350
|
+
return cls(
|
|
351
|
+
graph=graph,
|
|
352
|
+
initial_distribution=initial,
|
|
353
|
+
call_alphabet=shift.call_alphabet,
|
|
354
|
+
return_alphabet=shift.return_alphabet,
|
|
355
|
+
internal_alphabet=shift.internal_alphabet,
|
|
356
|
+
matched_edges=matched_edges,
|
|
357
|
+
symbol_alphabet=shift.symbol_alphabet,
|
|
358
|
+
**kwargs,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _configuration_transition_matrix_and_configs(
|
|
362
|
+
self,
|
|
363
|
+
max_stack_depth: int,
|
|
364
|
+
) -> tuple[np.ndarray, tuple[Configuration, ...]]:
|
|
365
|
+
configs = self.reachable_configurations(max_stack_depth)
|
|
366
|
+
matrix = np.zeros((len(configs), len(configs)), dtype=float)
|
|
367
|
+
config_index = {config: i for i, config in enumerate(configs)}
|
|
368
|
+
for i, config in enumerate(configs):
|
|
369
|
+
successors = self._normalized_successors(config, max_stack_depth=max_stack_depth)
|
|
370
|
+
if not successors:
|
|
371
|
+
matrix[i, i] = 1.0
|
|
372
|
+
continue
|
|
373
|
+
for _transition, prob, next_config in successors:
|
|
374
|
+
matrix[i, config_index[next_config]] += prob
|
|
375
|
+
return matrix, configs
|
|
376
|
+
|
|
377
|
+
def _normalized_successors(
|
|
378
|
+
self,
|
|
379
|
+
config: Configuration,
|
|
380
|
+
max_stack_depth: int | None = None,
|
|
381
|
+
) -> tuple[tuple[Transition, float, Configuration], ...]:
|
|
382
|
+
state, stack = config
|
|
383
|
+
weighted: list[tuple[Transition, float, Configuration]] = []
|
|
384
|
+
total = 0.0
|
|
385
|
+
for transition in self.graph.out_transitions(state):
|
|
386
|
+
weight = float(transition.data.get(ATTR_PROB, 0.0))
|
|
387
|
+
if weight <= 0.0:
|
|
388
|
+
continue
|
|
389
|
+
next_stack = self._next_stack(transition, stack, max_stack_depth=max_stack_depth)
|
|
390
|
+
if next_stack is None:
|
|
391
|
+
continue
|
|
392
|
+
weighted.append((transition, weight, (transition.target, next_stack)))
|
|
393
|
+
total += weight
|
|
394
|
+
if total <= 0.0:
|
|
395
|
+
return ()
|
|
396
|
+
return tuple((transition, weight / total, next_config) for transition, weight, next_config in weighted)
|
|
397
|
+
|
|
398
|
+
def _next_stack(
|
|
399
|
+
self,
|
|
400
|
+
transition: Transition,
|
|
401
|
+
stack: tuple[TransitionRef, ...],
|
|
402
|
+
max_stack_depth: int | None = None,
|
|
403
|
+
) -> tuple[TransitionRef, ...] | None:
|
|
404
|
+
kind = transition.data.get(ATTR_KIND)
|
|
405
|
+
ref = transition_ref(transition)
|
|
406
|
+
if kind == KIND_CALL:
|
|
407
|
+
if max_stack_depth is not None and len(stack) >= max_stack_depth:
|
|
408
|
+
return None
|
|
409
|
+
return stack + (ref,)
|
|
410
|
+
if kind == KIND_RETURN:
|
|
411
|
+
if not stack:
|
|
412
|
+
return stack if self.allow_empty_stack_returns else None
|
|
413
|
+
if (stack[-1], ref) in self.matched_edges:
|
|
414
|
+
return stack[:-1]
|
|
415
|
+
return None
|
|
416
|
+
if kind == KIND_INTERNAL:
|
|
417
|
+
return stack
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _uniform_initial_distribution(shift: SoficDyckShift) -> dict[Hashable, float]:
|
|
422
|
+
states = tuple(shift.states())
|
|
423
|
+
if not states:
|
|
424
|
+
return {}
|
|
425
|
+
probability = 1.0 / len(states)
|
|
426
|
+
return dict.fromkeys(states, probability)
|