From 45d6e6fd97cf5b51043f514f26638d5b413aac82 Mon Sep 17 00:00:00 2001 From: Ryan James Date: Tue, 11 Aug 2026 14:13:25 -0600 Subject: [PATCH] =?UTF-8?q?Add=20tent-map=20=CE=B5-machine=20for=20the=20p?= =?UTF-8?q?reimage-refined=20partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tent-map examples read the Misiurewicz-point dynamics through the two-letter kneading partition, split only at the critical point. Refining by both order-1 preimages of the critical point gives a four-letter generating partition and a five-state ε-machine for the same dynamics, which is the natural companion for comparing how the anatomy split depends on the partition: both presentations share the entropy rate log2(a), but the refined one has r_mu = 0. Probabilities are derived in Q(a) from the exact interval Markov chain on the forward-orbit closure of {c, L, R}. Reducing by the minimal polynomial a**3 = 2a + 2 leaves every transition a quadratic in a with rational coefficients, so unlike the kneading presentation none carries an a-dependent denominator, and the rows normalize identically rather than only at the root. The 2013 supplement figures cover only the kneading partition, so the docstrings cite James et al. for the map, parameter and anatomy framework while stating that this presentation is derived rather than reproduced. Co-authored-by: Cursor --- docs/examples.rst | 17 +++++ sofic/examples/__init__.py | 6 ++ sofic/examples/epsilon_machines.py | 102 ++++++++++++++++++++++++++++- tests/test_information_anatomy.py | 101 ++++++++++++++++++++++++++++ tests/test_symbolic_hmm.py | 31 +++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) 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)