From ca8bfaf35fab4a5c81169f3abb8802b742c6467f Mon Sep 17 00:00:00 2001 From: Ryan James Date: Tue, 10 Nov 2026 20:41:48 -0700 Subject: [PATCH] Bidirectional build: prune joint states to a fixed point Co-authored-by: Cursor --- CHANGELOG.md | 7 ++++++ .../generators/bidirectional_construction.py | 21 ++++++++-------- tests/test_bidirectional_build.py | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81240fc..e89a15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sofic/generators/bidirectional_construction.py b/sofic/generators/bidirectional_construction.py index cd1cc14..2203dae 100644 --- a/sofic/generators/bidirectional_construction.py +++ b/sofic/generators/bidirectional_construction.py @@ -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 diff --git a/tests/test_bidirectional_build.py b/tests/test_bidirectional_build.py index a42c51d..a90fe78 100644 --- a/tests/test_bidirectional_build.py +++ b/tests/test_bidirectional_build.py @@ -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)