Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/automata/subsequential.rst
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
.. subsequential.rst
.. py:module:: sofic.automata.subsequential

************************************
**************************************
Subsequential and Weighted Transducers
************************************
**************************************

The deterministic and weighted branches of the finite-state transducer
hierarchy :cite:`Mohri2009`.
Expand Down
2 changes: 1 addition & 1 deletion docs/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ @article{jurgens2026taxonomy
archivePrefix = {arXiv},
}

@article{jurgens2026information,
@misc{jurgens2026information,
author = {Jurgens, Alexandra M. and Crutchfield, James P.},
title = {Information Machines: Presentations of Information Dynamics},
year = {2026},
Expand Down
21 changes: 17 additions & 4 deletions sofic/automata/transducers.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,29 +126,42 @@ def complete(
error_output: Any = ERROR_SYMBOL,
copy: bool = True,
) -> MealyMachine:
"""Return a complete transducer by adding reject/error transitions."""
"""Return a complete transducer by adding reject/error transitions.

If every state already has an outgoing edge for every symbol in
``alphabet``, the reject sink is not added — an unused absorbing error
component would make the driven joint process reducible with a
non-unique stationary law.
"""
result = self.copy() if copy else self
symbols = alphabet if alphabet is not None else result.alphabets()[0]
if not symbols:
return result

result.graph.add_state(reject)
gaps: list[tuple[Hashable, Any]] = []
for state in list(result.states()):
outgoing = {
transition.data.get(ATTR_SYMBOL)
for transition in result.graph.out_transitions(state)
if transition.data.get(ATTR_SYMBOL) is not EPSILON
}
for symbol in symbols - outgoing:
result.add_transition(state, reject, symbol, error_output, prob=1.0)
gaps.append((state, symbol))

result.input_alphabet = result.input_alphabet | frozenset(symbols)
if not gaps:
return result

result.graph.add_state(reject)
for state, symbol in gaps:
result.add_transition(state, reject, symbol, error_output, prob=1.0)

for symbol in symbols:
if not any(
transition.data.get(ATTR_SYMBOL) == symbol for transition in result.graph.out_transitions(reject)
):
result.add_transition(reject, reject, symbol, error_output, prob=1.0)

result.input_alphabet = result.input_alphabet | frozenset(symbols)
result.output_alphabet = result.output_alphabet | frozenset({error_output})
return result

Expand Down
4 changes: 1 addition & 3 deletions sofic/generators/epsilon_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,7 @@ def information_diagram(
tol: float = 1e-9,
) -> Any:
"""Five-variable information-anatomy I-diagram (31 atoms) over the step joint."""
return self.to_bidirectional().information_diagram(
show_zero=show_zero, atoms=atoms, tol=tol
)
return self.to_bidirectional().information_diagram(show_zero=show_zero, atoms=atoms, tol=tol)

def plot_information_diagram(self, **kwargs: Any) -> Any:
"""Draw the five-variable information anatomy as a colour-coded UpSet plot."""
Expand Down
42 changes: 40 additions & 2 deletions sofic/generators/hmm_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,36 @@ def _emission_transition_tensors(
return _emission_transition_tensors_from_mealy(_as_mealy_hmm(hmm))


def _limit_distribution_from_initial(pi_initial: np.ndarray, transition: np.ndarray) -> np.ndarray | None:
"""Return the limiting occupation law of ``pi_initial`` under ``transition``.

On reducible chains the left-eigenvector stationary law is not unique; the
process measure is the limit reached from the model's initial distribution.
"""
pi = np.asarray(pi_initial, dtype=float).copy()
total = float(pi.sum())
if total <= 0.0:
return None
pi /= total
matrix = np.asarray(transition, dtype=float)
n = len(pi)
for _ in range(max(100, 20 * n)):
nxt = pi @ matrix
mass = float(nxt.sum())
if mass <= 0.0:
return None
nxt /= mass
if np.allclose(nxt, pi, rtol=1e-12, atol=1e-14):
pi = nxt
break
pi = nxt
pi[np.isclose(pi, 0.0, atol=1e-15)] = 0.0
mass = float(pi.sum())
if mass <= 0.0:
return None
return pi / mass


def _stationary_emission_tensors(
hmm: HiddenMarkovModel,
) -> tuple[np.ndarray, dict[Any, np.ndarray]]:
Expand All @@ -68,8 +98,12 @@ def _stationary_emission_tensors(
by the stationary distribution, not by the model's (possibly transient)
``initial_distribution``. The stationary vector is recovered directly from the
summed emission-transition matrices so it stays aligned with ``joint``'s state
indexing; it falls back to the initial vector only when no stationary law can be
found (e.g. a degenerate generator).
indexing.

When the chain is reducible (multiple absorbing classes), the eigenvector
stationary law is not unique — prefer the limiting occupation reached from
``initial_distribution``. Fall back to the eigenvector solution, then to the
initial vector, only when the limit cannot be formed.
"""
from sofic.generators.prob import zeros
from sofic.generators.stationary import stationary_distribution_from_transition
Expand All @@ -82,6 +116,10 @@ def _stationary_emission_tensors(
transition = zeros((n, n), symbolic=symbolic)
for matrix in joint.values():
transition = transition + matrix
if not symbolic:
limited = _limit_distribution_from_initial(pi_initial, transition)
if limited is not None and np.allclose(limited @ transition, limited, rtol=1e-8, atol=1e-10):
return limited, joint
try:
pi = stationary_distribution_from_transition(transition)
except Exception:
Expand Down
7 changes: 6 additions & 1 deletion sofic/viz/_tikz_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def compilation_document(body: str) -> str:
"\n"
r"\usepackage{xcolor}"
"\n"
r"\pagecolor{white}"
"\n"
r"\definecolor{honeydew}{RGB}{240,255,240}"
"\n"
r"\definecolor{mistyrose}{RGB}{255,228,225}"
Expand Down Expand Up @@ -127,7 +129,10 @@ def _pdf_to_png(pdf_path: Path, png_path: Path) -> bytes:
gs,
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=pngalpha",
# Opaque RGB (not pngalpha) so the page background is white.
"-sDEVICE=png16m",
"-dGraphicsAlphaBits=4",
"-dTextAlphaBits=4",
"-r200",
"-dFirstPage=1",
"-dLastPage=1",
Expand Down
Loading
Loading