diff --git a/docs/examples.rst b/docs/examples.rst index 05d2da2..babf928 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -40,11 +40,25 @@ Other literature processes Tent map (Misiurewicz point) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Read through the two-letter kneading partition, split at the critical point +``c = 1/2`` (James et al. :cite:`James2013`, supplement Figs.~6--8): + * :func:`tent_map_misiurewicz_hmm` — non-unifilar HMM * :func:`tent_map_misiurewicz_forward`, :func:`tent_map_misiurewicz_reverse` * :func:`tent_map_misiurewicz_bidirectional` — information anatomy reference * :func:`tent_map_misiurewicz_information_expected` — expected measure dict +Refining that partition by both order-1 preimages of the critical point, +``L = 1/(2a)`` and ``R = 1 - 1/(2a)``, gives a four-letter alphabet and a +five-state machine for the *same* dynamics. Both partitions are generating, so +both have entropy rate ``log2(a)``; only the anatomy split differs, and the +refined one has ``r_mu = 0``. Derived from the interval Markov chain — the 2013 +supplement figures cover only the kneading partition: + +* :func:`tent_map_misiurewicz_preimage_forward` — five states over ``{0, 1, 2, 3}`` +* :func:`tent_map_misiurewicz_preimage_symbol_matrices` — the ``T^(x)`` matrices +* :func:`tent_map_misiurewicz_preimage_information_expected` — expected measure dict + Sofic-Dyck shifts ~~~~~~~~~~~~~~~~~ @@ -144,6 +158,9 @@ API .. autofunction:: tent_map_misiurewicz_bidirectional .. autofunction:: tent_map_misiurewicz_a .. autofunction:: tent_map_misiurewicz_information_expected +.. autofunction:: tent_map_misiurewicz_preimage_forward +.. autofunction:: tent_map_misiurewicz_preimage_symbol_matrices +.. autofunction:: tent_map_misiurewicz_preimage_information_expected .. autofunction:: dyck_shift_order .. autofunction:: motzkin_shift .. autofunction:: sofic_dyck_fig1_shift diff --git a/sofic/examples/__init__.py b/sofic/examples/__init__.py index c74872b..4d3af86 100644 --- a/sofic/examples/__init__.py +++ b/sofic/examples/__init__.py @@ -25,6 +25,9 @@ tent_map_misiurewicz_forward, tent_map_misiurewicz_hmm, tent_map_misiurewicz_information_expected, + tent_map_misiurewicz_preimage_forward, + tent_map_misiurewicz_preimage_information_expected, + tent_map_misiurewicz_preimage_symbol_matrices, tent_map_misiurewicz_reverse, ) from sofic.examples.processes import * @@ -68,6 +71,9 @@ "tent_map_misiurewicz_forward", "tent_map_misiurewicz_hmm", "tent_map_misiurewicz_information_expected", + "tent_map_misiurewicz_preimage_forward", + "tent_map_misiurewicz_preimage_information_expected", + "tent_map_misiurewicz_preimage_symbol_matrices", "tent_map_misiurewicz_reverse", ] __all__ += _process_all diff --git a/sofic/examples/epsilon_machines.py b/sofic/examples/epsilon_machines.py index 73b8bf7..bd95d57 100644 --- a/sofic/examples/epsilon_machines.py +++ b/sofic/examples/epsilon_machines.py @@ -12,7 +12,10 @@ - Golden-mean shift (forbid ``11``, Parry max-entropy): standard symbolic dynamics; see e.g. Ellison et al., arXiv:1107.2168 Fig.~2. - Tent map (Misiurewicz point): James, Burke & Crutchfield (2013), supplement - to *Chaos Forgets and Remembers*; Figs.~6--8. + to *Chaos Forgets and Remembers*; Figs.~6--8. The four-letter + ``preimage`` variants read the same dynamics through a partition refined by + both preimages of the critical point; that presentation is derived from the + interval Markov chain rather than taken from a figure. """ from __future__ import annotations @@ -465,6 +468,103 @@ def tent_map_misiurewicz_forward(a: Any | None = None) -> EpsilonMachine: return from_symbol_matrices(states, symbols, matrices) +def tent_map_misiurewicz_preimage_symbol_matrices( + a: Any | None = None, +) -> tuple[tuple[str, ...], tuple[int, ...], dict[int, np.ndarray]]: + """Symbol matrices for the tent map read through the partition ``{L, c, R}``. + + The kneading partition of :func:`tent_map_misiurewicz_fig7_symbol_matrices` + cuts ``[0, 1]`` only at the critical point ``c = 1/2``. Refining it by the + two order-1 preimages of ``c`` -- ``L = 1/(2a)`` and ``R = 1 - 1/(2a)`` -- + gives the four-letter generating partition + + ========== ================== + Symbol Cell + ========== ================== + ``0`` ``[0, L)`` + ``1`` ``[L, c)`` + ``2`` ``[c, R)`` + ``3`` ``[R, 1]`` + ========== ================== + + under which the map has the five-state ε-machine ``A``--``E``. Reducing by + the parameter's minimal polynomial ``a**3 = 2a + 2`` turns every transition + probability into a quadratic in ``a`` with rational coefficients, so unlike + the kneading presentation none of them carries an ``a``-dependent + denominator. + + Derived from the exact interval Markov chain on the forward-orbit closure of + ``{c, L, R}``. The tent map, the Misiurewicz parameter and the information + anatomy this presentation is used for are from James, Burke & Crutchfield, + *Chaos Forgets and Remembers* (2013) :cite:`James2013`; that paper's figures + cover only the kneading partition, so this refined presentation has no + published figure to cite. + """ + from sofic.generators.prob import is_symbolic, zeros + + if a is None: + a = tent_map_misiurewicz_a() + states = ("A", "B", "C", "D", "E") + symbolic = is_symbolic(a) + one = 1 if symbolic else 1.0 + matrices = {symbol: zeros((5, 5), symbolic=symbolic) for symbol in (0, 1, 2, 3)} + # A=0, B=1, C=2, D=3, E=4. Rows sum to one identically in ``a``. + matrices[2][0, 0] = (a**2 - 2) / 2 # A → A on 2 + matrices[3][0, 1] = (4 - a**2) / 2 # A → B on 3 + matrices[0][1, 3] = (a**2 - 2 * a + 2) / 6 # B → D on 0 + matrices[1][1, 0] = (4 + 2 * a - a**2) / 6 # B → A on 1 + matrices[2][2, 4] = (2 + a - a**2) / 2 # C → E on 2 + matrices[3][2, 1] = (a**2 - a) / 2 # C → B on 3 + matrices[1][3, 2] = one # D → C on 1 + matrices[2][4, 2] = one # E → C on 2 + return states, (0, 1, 2, 3), matrices + + +def tent_map_misiurewicz_preimage_forward(a: Any | None = None) -> EpsilonMachine: + """Forward ε-machine of the tent map under the four-letter ``{L, c, R}`` partition. + + Companion to :func:`tent_map_misiurewicz_forward`, which reads the same + dynamics through the two-letter kneading partition. Refining by both + preimages of the critical point trades states for letters: five causal + states over a four-letter alphabet instead of four over two. The process + stays strictly sofic (infinite Markov and cryptic order) but its ephemeral + information vanishes -- see + :func:`tent_map_misiurewicz_preimage_information_expected`. + """ + states, symbols, matrices = tent_map_misiurewicz_preimage_symbol_matrices(a) + return from_symbol_matrices(states, symbols, matrices) + + +def tent_map_misiurewicz_preimage_information_expected(a: Any | None = None) -> dict[str, Any]: + """Closed-form anatomy of the four-letter ``{L, c, R}`` tent-map partition. + + The ephemeral rate is *exactly* zero, so ``b_mu = h_mu = log2(a)``. The + reason is structural rather than numerical: the machine of + :func:`tent_map_misiurewicz_preimage_forward` is unifilar, no two of its + edges share both a source and a target, and every branch leads to a state + with a distinguishable future (``A`` versus ``B``, ``D`` versus ``A``, ``E`` + versus ``B``). Knowing the past fixes the current causal state, the future + then fixes the successor, and the two together name the emitted symbol -- + leaving nothing for ``r_mu = H[X_0 | past, future]`` to measure. + + Contrast :func:`tent_map_misiurewicz_information_expected`, where the + coarser kneading partition of the *same* dynamics splits the same entropy + rate into a nonzero ephemeral part (James, Burke & Crutchfield, 2013 + :cite:`James2013`). + """ + from sofic.generators.prob import is_symbolic + + if a is None: + a = tent_map_misiurewicz_a() + if is_symbolic(a): + import sympy as sp + + h_mu = sp.log(a, 2) + return {"bound_mu": h_mu, "ephemeral_mu": sp.Integer(0), "entropy_rate": h_mu} + h_mu = math.log2(a) + return {"bound_mu": h_mu, "ephemeral_mu": 0.0, "entropy_rate": h_mu} + + def tent_map_misiurewicz_hmm(a: Any | None = None) -> MealyHMM: """Non-unifilar HMM from supplement Fig.~6 (right). diff --git a/tests/test_information_anatomy.py b/tests/test_information_anatomy.py index 21700e9..f42278c 100644 --- a/tests/test_information_anatomy.py +++ b/tests/test_information_anatomy.py @@ -2,6 +2,8 @@ from __future__ import annotations +import math + import pytest from sofic.examples import ( @@ -13,9 +15,12 @@ golden_mean_forward, golden_mean_reverse, nemo_process, + tent_map_misiurewicz_a, tent_map_misiurewicz_bidirectional, tent_map_misiurewicz_forward, tent_map_misiurewicz_information_expected, + tent_map_misiurewicz_preimage_forward, + tent_map_misiurewicz_preimage_information_expected, ) from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine @@ -481,6 +486,102 @@ def test_tent_map_misiurewicz_bidirectional_regression(): assert bidir.crypticity() == pytest.approx(bidir.statistical_complexity() - bidir.excess_entropy(), abs=1e-9) +def test_tent_map_preimage_partition_presentation(): + """Refining by both preimages of ``c`` gives five states over four letters.""" + forward = tent_map_misiurewicz_preimage_forward() + forward.validate_stochastic() + + assert sorted(forward.states()) == ["A", "B", "C", "D", "E"] + assert sorted(forward.observation_alphabet) == [0, 1, 2, 3] + assert forward.is_unifilar() + # Still strictly sofic: r_μ = 0 does not buy finite memory. + assert forward.is_strictly_sofic() + + # Reduced by the parameter's minimal polynomial ``a**3 = 2a + 2``, every + # branching probability is a quadratic in ``a``. + a = tent_map_misiurewicz_a() + expected = { + ("A", 2, "A"): (a**2 - 2) / 2, + ("A", 3, "B"): (4 - a**2) / 2, + ("B", 0, "D"): (a**2 - 2 * a + 2) / 6, + ("B", 1, "A"): (4 + 2 * a - a**2) / 6, + ("C", 2, "E"): (2 + a - a**2) / 2, + ("C", 3, "B"): (a**2 - a) / 2, + ("D", 1, "C"): 1.0, + ("E", 2, "C"): 1.0, + } + edges = {(t.source, t.data["emission"], t.target): float(t.data["prob"]) for t in forward.graph.transitions()} + assert edges.keys() == expected.keys() + for edge, probability in expected.items(): + assert edges[edge] == pytest.approx(probability, abs=1e-12) + + +def test_tent_map_preimage_partition_stationary_distribution(): + """Causal-state weights of the four-letter tent-map presentation.""" + forward = tent_map_misiurewicz_preimage_forward() + index = forward.reindex() + pi = forward.stationary_distribution() + weights = {index.state(i): float(pi[i]) for i in range(len(index.states))} + + assert weights == pytest.approx( + { + "A": 0.4870384416, + "B": 0.2882345739, + "C": 0.1123634923, + "D": 0.0764691477, + "E": 0.0358943445, + }, + abs=1e-9, + ) + + +def test_tent_map_preimage_partition_forbidden_blocks(): + """Nine of the sixteen two-letter blocks are forbidden by the refined partition.""" + forward = tent_map_misiurewicz_preimage_forward() + words = forward.word_probabilities(2) + forbidden = {(0, 0), (0, 2), (0, 3), (1, 0), (1, 1), (2, 0), (2, 1), (3, 2), (3, 3)} + + for word in forbidden: + assert float(words.get(word, 0.0)) == pytest.approx(0.0, abs=1e-12) + allowed = {word for word, p in words.items() if float(p) > 1e-12} + assert len(allowed) == 7 + assert allowed.isdisjoint(forbidden) + + +def test_tent_map_preimage_partition_is_generating(): + """Both partitions of the same dynamics share the entropy rate ``log2(a)``.""" + pytest.importorskip("dit") + a = tent_map_misiurewicz_a() + refined = tent_map_misiurewicz_preimage_forward() + + assert refined.entropy_rate() == pytest.approx(math.log2(a), abs=1e-9) + assert refined.entropy_rate() == pytest.approx(tent_map_misiurewicz_forward().entropy_rate(), abs=1e-9) + + +def test_tent_map_preimage_ephemeral_information_vanishes(): + """The refined partition moves the whole entropy rate into bound information.""" + pytest.importorskip("dit") + expected = tent_map_misiurewicz_preimage_information_expected() + refined = tent_map_misiurewicz_preimage_forward() + + assert refined.ephemeral_information() == pytest.approx(0.0, abs=1e-9) + assert refined.bound_information() == pytest.approx(expected["bound_mu"], abs=1e-9) + assert refined.entropy_rate() == pytest.approx(expected["entropy_rate"], abs=1e-9) + + # The coarser kneading partition of the same dynamics does not. + assert tent_map_misiurewicz_information_expected()["ephemeral_mu"] == pytest.approx(0.648258, abs=1e-4) + + +def test_tent_map_preimage_statistical_complexity_exceeds_excess_entropy(): + """Regression on C_μ and E; the gap is the machine's crypticity.""" + pytest.importorskip("dit") + refined = tent_map_misiurewicz_preimage_forward() + + assert refined.statistical_complexity() == pytest.approx(1.833069440568067, abs=1e-9) + assert refined.excess_entropy() == pytest.approx(1.1407151497907773, abs=1e-9) + assert refined.statistical_complexity() > refined.excess_entropy() + + def test_tent_forward_matches_generator_path(): pytest.importorskip("dit") from sofic.examples.epsilon_machines import tent_map_misiurewicz_hmm diff --git a/tests/test_symbolic_hmm.py b/tests/test_symbolic_hmm.py index 142ec95..7033897 100644 --- a/tests/test_symbolic_hmm.py +++ b/tests/test_symbolic_hmm.py @@ -17,6 +17,8 @@ tent_map_misiurewicz_forward, tent_map_misiurewicz_hmm, tent_map_misiurewicz_information_expected, + tent_map_misiurewicz_preimage_forward, + tent_map_misiurewicz_preimage_symbol_matrices, ) from sofic.generators.epsilon_machine import EpsilonMachine from sofic.generators.prob import ( @@ -28,6 +30,35 @@ from sofic.generators.words import hmm_word_probability +def test_symbolic_preimage_rows_sum_to_one_identically(): + """The refined partition's rows normalize for a free ``a``, not just at the root.""" + a = sp.symbols("a", positive=True) + states, symbols, matrices = tent_map_misiurewicz_preimage_symbol_matrices(a) + for i in range(len(states)): + row = sum(matrices[x][i, j] for x in symbols for j in range(len(states))) + assert sp.simplify(row) == 1 + + +def test_symbolic_preimage_substitution_matches_numeric(): + """Substituting the Misiurewicz root into the symbolic machine recovers the floats.""" + a = sp.symbols("a", positive=True) + a_num = tent_map_misiurewicz_a() + symbolic = tent_map_misiurewicz_preimage_forward(a) + numeric = tent_map_misiurewicz_preimage_forward() + + def edges(machine, substitute): + # Certain transitions are stored as an exact ``1``, so sympify before substituting. + return { + (t.source, t.data["emission"], t.target): float( + sp.sympify(t.data["prob"]).subs(a, a_num) if substitute else t.data["prob"] + ) + for t in machine.graph.transitions() + } + + assert edges(symbolic, True) == pytest.approx(edges(numeric, False), abs=1e-12) + assert float(symbolic.entropy_rate().subs(a, a_num)) == pytest.approx(math.log2(a_num), abs=1e-9) + + def test_symbolic_edge_probabilities_preserved(): a = sp.symbols("a", positive=True) hmm = tent_map_misiurewicz_forward(a)