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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
A second correctness review: every fix below was reproduced against a
brute-force reference or a closed-form value and has a regression test.

### Bidirectional builds

- `build_bidirectional_epsilon_machine` prunes joint states to a fixed point: dropping states
outside the stationary support could leave others with incomplete outgoing rows, so builds from
truncated reverse machines failed with "joint masses from ... sum to 0.86". Restriction to the
stationary support and to states with complete rows now alternate until neither removes anything.

### State labels

- `sofic.states.sequential_labels` no longer stops at 26 states: after ``A``–``Z`` it continues
Expand Down
21 changes: 11 additions & 10 deletions sofic/generators/bidirectional_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,17 @@ def _prune_to_stationary_support(

keep = _recurrent_support(graph) or set(states)
trimmed = _restrict_graph(graph, keep)
initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
if not initial:
return trimmed, {}

trimmed = _restrict_graph(trimmed, set(initial.keys()))
keep = _recurrent_support(trimmed) or set(initial.keys())
trimmed = _restrict_graph(trimmed, keep)
initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
if initial:
trimmed = _restrict_graph(trimmed, set(initial.keys()))
# Dropping states outside the stationary support can leave others with incomplete rows, and
# dropping those changes the support, so alternate the two until neither removes anything.
for _ in range(len(states) + 1):
initial = _joint_pi_minimum_support(trimmed, forward, reverse, tol=tol)
if not initial:
return trimmed, {}
current = set(trimmed.states())
keep = _recurrent_support(_restrict_graph(trimmed, set(initial))) or set(initial)
if keep == current:
break
trimmed = _restrict_graph(trimmed, keep)
return trimmed, initial


Expand Down
24 changes: 24 additions & 0 deletions tests/test_bidirectional_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,27 @@ def test_survey_explosion_code_may_build_with_ladder() -> None:
else:
built = try_build_bidirectional(forward)
assert built.bidir is not None


def test_truncated_reverse_machine_prunes_to_complete_rows() -> None:
"""Regression: dropping sub-tolerance joint states left (2, 'CE') with 86% of its outgoing mass."""
mats = {x: np.zeros((4, 4)) for x in (0, 1)}
for x, i, j, p in (
(0, 1, 2, 0.8848986395305858),
(0, 2, 3, 0.42866489513010786),
(0, 3, 1, 0.3568362368298324),
(1, 0, 1, 1.0),
(1, 1, 0, 0.11510136046941416),
(1, 2, 1, 0.571335104869892),
(1, 3, 2, 0.6431637631701677),
):
mats[x][i, j] = p
forward = from_symbol_matrices(tuple(range(4)), (0, 1), mats)
built = try_build_bidirectional(forward)
assert built.preflight.bidir_class == "truncated"
assert built.bidir is not None
bidir_pi = built.bidir.forward_machine.stationary_distribution()
marginal = np.zeros(4)
for (alpha, _gamma), mass in built.bidir._joint_pi.items():
marginal[alpha] += float(mass)
assert np.allclose(marginal, bidir_pi, atol=1e-8)
Loading