diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e064a..81240fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ brute-force reference or a closed-form value and has a regression test. `verify_weight_cap_certificate` rechecks a certificate without an LP solver. On the `(2, inf)` process the caps are exactly 1/2 and 3/4, so `C_g = 3/2` (computer-assisted proof). +- `generator_problem_spec.window_spec` and `_GeneratorProblem.from_spec`: generator equations from + edge tensors alone, with the reverse causal state replaced by the next ``L`` symbols. Every + generator satisfies the window equations, so generator caps and coverage on a window spec are + lower bounds on `C_g` that need only the forward ε-machine, for processes whose reverse + ε-machine is infinite. On `(2, inf)` a two-symbol window gives `C_g >= 3/2`. + +- `targeted_line_split_seeds`: iterated local search after COBYLA (``hop_chains`` best decompositions, + ``hops`` perturbations of the broad terms' logits each, keeping improvements), and only the + ``polish_best`` lowest-entropy decompositions get the coupled-ALM polish. On the widest survey + bracket it reaches the known 0.970-bit generator in 8 of 8 seeds (COBYLA alone: 0.978 to 1.10). + The kernel LP is now assembled with `einsum` (same LP, 1.5-3.5x faster to build). + - `targeted_line_split_seeds`: searches the line-split family directly (broad terms at their largest fitting weight plus point-mass row/column states, an exact decomposition fixed by the broad terms' beliefs). From each exact-CI optimum's heaviest terms, Nelder-Mead reaches kernel-LP diff --git a/docs/generators/generative_models.rst b/docs/generators/generative_models.rst index f2b5640..3c0f142 100644 --- a/docs/generators/generative_models.rst +++ b/docs/generators/generative_models.rst @@ -293,6 +293,17 @@ without an LP solver. On the :math:`(2, \infty)` process the caps are exactly :m :math:`(1/2, 1/4, 1/4)`, so :math:`C_g \ge \operatorname{H}(1/2, 1/4, 1/4) = 3/2`. The ε-machine attains this, so :math:`C_g = 3/2` (computer-assisted proof). +When the reverse ε-machine is infinite there is no bidirectional machine, but the generator +caps still apply with the reverse causal state replaced by the window of the next ``L`` +symbols (:func:`~sofic.generators.generator_problem_spec.window_spec`): a generator state +separates the forward causal state from the window, the window at time 0 is a function of +:math:`X_0` and the window at time 1, and the later window is screened off by the next +generator state. Every generator therefore satisfies the window equations, and +:meth:`_GeneratorProblem.from_spec ` +feeds them to :func:`~sofic.generators.generator_caps.generator_coverage_bound` for lower bounds +on :math:`C_g` from the forward ε-machine alone. On the :math:`(2, \infty)` process a +two-symbol window already gives :math:`C_g \ge 3/2`. + API === @@ -331,6 +342,7 @@ Coupled generators and bounds .. autofunction:: sofic.generators.certificates.certified_lp_minimum .. autofunction:: sofic.generators.certificates.dual_bound .. autofunction:: sofic.generators.certificates.certified_psd +.. autofunction:: sofic.generators.generator_problem_spec.window_spec .. autofunction:: sofic.generators.coverage_bound.lp_rank_k_weights .. autofunction:: sofic.generators.coverage_bound.mu1_search .. autofunction:: sofic.generators.coupled_generators.coupled_generative_model diff --git a/sofic/generators/coupled_generators.py b/sofic/generators/coupled_generators.py index ebf2070..02ed32e 100644 --- a/sofic/generators/coupled_generators.py +++ b/sofic/generators/coupled_generators.py @@ -30,12 +30,14 @@ from collections.abc import Sequence from dataclasses import dataclass +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, cast import numpy as np from scipy.optimize import minimize from sofic.exceptions import StochasticValidationError +from sofic.generators.generator_problem_spec import GeneratorProblemSpec from sofic.generators.lifted_common_information import _LiftedProblem from sofic.generators.mealy import MealyHMM from sofic.generators.minimal_generative_model import _normalized_joint_distribution @@ -70,45 +72,13 @@ def _kernel_lp_beliefs( k, X = len(pi), problem.n_sym A = pi[:, None] * Q nK = k * X * k - idx = lambda w, x, v: (w * X + x) * k + v # noqa: E731 - rows, rhs = [], [] - - def add(terms, target: float) -> None: - row = np.zeros(nK) - for j, val in terms: - row[j] += val - rows.append(row) - rhs.append(target) - - for a in range(problem.n_plus): - for x in range(X): - for b in range(problem.n_minus): - add(((idx(w, x, v), A[w, a] * R[v, b]) for w in range(k) for v in range(k)), problem.Pe[a, x, b]) - for v in range(k): - for c_ in range(problem.n_plus): - add( - ( - (idx(w, x, v), A[w, a] * problem.F[a, x, c_]) - for w in range(k) - for x in range(X) - for a in range(problem.n_plus) - ), - A[v, c_], - ) - for w in range(k): - for b in range(problem.n_minus): - add( - ( - (idx(w, x, v), pi[w] * R[v, c_] * problem.G[x, c_, b]) - for x in range(X) - for v in range(k) - for c_ in range(problem.n_minus) - ), - pi[w] * R[w, b], - ) - dense = np.array(rows) - target = np.array(rhs) - m = len(rows) + # Columns are K[w, x, v] in C order. Edge law rows (a, x, b), forward rows (v, a'), reverse rows (w, b). + edge = np.einsum("wa,vb,xy->axbwyv", A, R, np.eye(X)).reshape(-1, nK) + forward = np.einsum("wxc,uv->ucwxv", np.einsum("wa,axc->wxc", A, problem.F), np.eye(k)).reshape(-1, nK) + reverse = np.einsum("w,uw,xvb->ubwxv", pi, np.eye(k), np.einsum("vc,xcb->xvb", R, problem.G)).reshape(-1, nK) + dense = np.vstack([edge, forward, reverse]) + target = np.concatenate([problem.Pe.ravel(), A.ravel(), (pi[:, None] * R).ravel()]) + m = len(dense) A_ub = np.block([[dense, -np.ones((m, 1))], [-dense, -np.ones((m, 1))]]) b_ub = np.concatenate([target, -target]) A_eq = np.zeros((k, nK + 1)) @@ -158,6 +128,29 @@ def constraint_weights(self) -> _ConstraintWeights: ) return self._weights + @classmethod + def from_spec(cls, spec: GeneratorProblemSpec) -> _GeneratorProblem: + """A problem from edge tensors alone (no bidirectional machine). + + Supports what needs only the tensors: the kernel LP, generator residuals and the generator caps. + Word-level verification needs ``bidir`` and is unavailable. + """ + if spec.pairs is None: + raise StochasticValidationError("GeneratorProblemSpec.pairs is required to build a problem") + problem = cls.__new__(cls) + n = len(spec.pairs) + plus, minus = np.zeros((n, spec.n_plus)), np.zeros((n, spec.n_minus)) + for i, (a, b) in enumerate(spec.pairs): + plus[i, a] = minus[i, b] = 1.0 + problem.bidir = None + problem.lifted = SimpleNamespace(plus=plus, minus=minus, p=np.asarray(spec.p_pair, float), pairs=spec.pairs) + problem.symbols = list(spec.symbols) + problem.Pe, problem.F, problem.G = spec.Pe, spec.F, spec.G + problem.p_pair, problem.p_plus, problem.p_minus = spec.p_pair, spec.p_plus, spec.p_minus + problem.n_plus, problem.n_minus, problem.n_sym = spec.n_plus, spec.n_minus, spec.n_sym + problem._weights = None + return problem + @classmethod def from_edge_tensors( cls, @@ -823,6 +816,10 @@ def targeted_line_split_seeds( *, configs: Sequence[tuple[int, int]] = ((1, 0), (1, 1), (2, 0), (2, 1)), perturbations: int = 2, + polish_best: int | None = 3, + hop_chains: int = 2, + hops: int = 30, + hop_scales: Sequence[float] = (0.03, 0.05, 0.1), residual_atol: float = 1e-7, rng: np.random.Generator | None = None, ) -> list[tuple[str, Seed]]: @@ -833,12 +830,16 @@ def targeted_line_split_seeds( an exact decomposition fixed by the broad terms' beliefs. From the heaviest terms of each channel's decomposition (and ``perturbations`` perturbations of them), Nelder-Mead reaches a decomposition whose kernel LP is feasible, then COBYLA minimizes its state entropy subject to staying feasible. - Each result is polished by the coupled ALM from several logit floors; seeds that reach the - generator equations to ``residual_atol`` are returned. ``configs`` lists ``(broad, axis)`` pairs. + COBYLA stops in nearby local minima, so the ``hop_chains`` best decompositions then run ``hops`` + steps of iterated local search (perturb the broad terms' logits at a scale cycling through + ``hop_scales``, re-run the local search, keep improvements). The ``polish_best`` lowest-entropy + decompositions (all if ``None``) are polished by the coupled ALM from several logit floors; seeds + that reach the generator equations to ``residual_atol`` are returned. ``configs`` lists + ``(broad, axis)`` pairs. """ rng = rng or np.random.default_rng() P = problem.lifted.plus.T @ (problem.p_pair[:, None] * problem.lifted.minus) - out: list[tuple[str, Seed]] = [] + found: list[tuple[float, str, np.ndarray, int, int]] = [] for i, c in enumerate(channels): pi0, Q0, R0, _ = problem.channel_seed(c) order = np.argsort(-pi0) @@ -846,54 +847,79 @@ def targeted_line_split_seeds( if broad > int((pi0 > 1e-9).sum()): continue base = np.concatenate([np.log(np.maximum(np.concatenate([Q0[w], R0[w]]), 1e-6)) for w in order[:broad]]) - - def violation(z: np.ndarray, broad: int = broad, axis: int = axis) -> float: - return _kernel_lp_beliefs(problem, *_line_split_beliefs(P, z, broad, axis))[0] - - def entropy(z: np.ndarray, broad: int = broad, axis: int = axis) -> float: - return _entropy(_line_split_beliefs(P, z, broad, axis)[0]) - starts = [base] + [base + rng.normal(scale=0.3, size=base.shape) for _ in range(perturbations)] + lines = "columns" if axis == 0 else "rows" for s, z in enumerate(starts): - if violation(z) >= 1e-6: - res = minimize( - violation, - z, - method="Nelder-Mead", - options={"maxiter": 300 * len(z), "xatol": 1e-9, "fatol": 1e-13}, - ) - if res.fun >= 1e-6: - continue - z = res.x - constraint = [{"type": "ineq", "fun": lambda y, v=violation: 1e-9 - v(y)}] - for rho in (0.5, 0.1): - res = minimize( - entropy, - z, - method="COBYLA", - constraints=constraint, - options={"rhobeg": rho, "maxiter": 3000, "tol": 1e-10}, - ) - if violation(res.x) < 1e-6: - z = res.x - beliefs = _line_split_beliefs(P, z, broad, axis) - _violation, K = _kernel_lp_beliefs(problem, *beliefs) - k = len(beliefs[0]) - lines = "columns" if axis == 0 else "rows" - polished = [] - for floor in (1e-12, 1e-5, 1e-3): - theta = _optimize_coupled( - problem, problem.logits(*beliefs, K, floor=floor), k, 3000, residual_atol=residual_atol - ) - seed = problem.unpack(theta, k) - if problem.residual(*seed) <= residual_atol: - polished.append((_entropy(seed[0]), seed)) - if polished: - seed = min(polished, key=lambda pair: pair[0])[1] - out.append((f"targeted line split {i}.{s} ({broad} broad + {lines})", cast(Seed, seed))) + local = _line_split_local(problem, P, z, broad, axis, nm_factor=300, cobyla_iter=3000) + if local is not None: + name = f"targeted line split {i}.{s} ({broad} broad + {lines})" + found.append((local[0], name, local[1], broad, axis)) + found.sort(key=lambda item: item[0]) + for j, (h, name, z, broad, axis) in enumerate(found[:hop_chains]): + for step in range(hops): + scale = hop_scales[step % len(hop_scales)] + local = _line_split_local(problem, P, z + rng.normal(scale=scale, size=z.shape), broad, axis) + if local is not None and local[0] < h - 1e-6: + h, z = local + found[j] = (h, name if h >= found[j][0] - 1e-6 else f"{name}, hops", z, broad, axis) + found.sort(key=lambda item: item[0]) + out: list[tuple[str, Seed]] = [] + for _h, name, z, broad, axis in found if polish_best is None else found[:polish_best]: + beliefs = _line_split_beliefs(P, z, broad, axis) + _violation, K = _kernel_lp_beliefs(problem, *beliefs) + k = len(beliefs[0]) + polished = [] + for floor in (1e-12, 1e-5, 1e-3): + theta = _optimize_coupled( + problem, problem.logits(*beliefs, K, floor=floor), k, 3000, residual_atol=residual_atol + ) + seed = problem.unpack(theta, k) + if problem.residual(*seed) <= residual_atol: + polished.append((_entropy(seed[0]), seed)) + if polished: + out.append((name, cast(Seed, min(polished, key=lambda pair: pair[0])[1]))) return out +def _line_split_local( + problem: _GeneratorProblem, + P: np.ndarray, + z: np.ndarray, + broad: int, + axis: int, + *, + nm_factor: int = 100, + cobyla_iter: int = 1000, +) -> tuple[float, np.ndarray] | None: + """Nelder-Mead to kernel-LP feasibility, then COBYLA on the state entropy; ``(entropy, z)`` or ``None``.""" + + def violation(y: np.ndarray) -> float: + return _kernel_lp_beliefs(problem, *_line_split_beliefs(P, y, broad, axis))[0] + + def entropy(y: np.ndarray) -> float: + return _entropy(_line_split_beliefs(P, y, broad, axis)[0]) + + if violation(z) >= 1e-6: + res = minimize( + violation, z, method="Nelder-Mead", options={"maxiter": nm_factor * len(z), "xatol": 1e-9, "fatol": 1e-13} + ) + if res.fun >= 1e-6: + return None + z = res.x + constraint = [{"type": "ineq", "fun": lambda y: 1e-9 - violation(y)}] + for rho in (0.5, 0.1): + res = minimize( + entropy, + z, + method="COBYLA", + constraints=constraint, + options={"rhobeg": rho, "maxiter": cobyla_iter, "tol": 1e-10}, + ) + if violation(res.x) < 1e-6: + z = res.x + return entropy(z), z + + def _pad(seed: Seed, k: int, rng: np.random.Generator, noise: float) -> Seed: """Fit a seed with fewer or more states into ``k`` states, adding small mass to empty states.""" pi, Q, R, K = seed diff --git a/sofic/generators/generator_caps.py b/sofic/generators/generator_caps.py index ab54d09..c291569 100644 --- a/sofic/generators/generator_caps.py +++ b/sofic/generators/generator_caps.py @@ -63,6 +63,7 @@ def __init__(self, problem: _GeneratorProblem, k: int, *, data_denominator: int var[name] = np.arange(offset, offset + int(np.prod(shape))).reshape(shape) offset += int(np.prod(shape)) self.n = offset + self.var = var m, y, z, T = var["m"], var["y"], var["z"], var["T"] phi, Fl, Hr, Gt = var["phi"], var["Fl"], var["Hr"], var["Gt"] diff --git a/sofic/generators/generator_problem_spec.py b/sofic/generators/generator_problem_spec.py index e5d27d8..b8e3b6a 100644 --- a/sofic/generators/generator_problem_spec.py +++ b/sofic/generators/generator_problem_spec.py @@ -1,23 +1,27 @@ -"""Edge-tensor specification for generator search without a full bidirectional graph. - -When the reverse ε-machine is infinite and truncation error must be tracked explicitly, -build a :class:`GeneratorProblemSpec` from ``(Pe, F, G, p_pair)`` instead of -:class:`~sofic.generators.coupled_generators._GeneratorProblem` from a -:class:`~sofic.generators.bidirectional_epsilon_machine.BidirectionalEpsilonMachine`. - -**Rigorous lowers** on truncated presentations require bounding -``||Pe_approx - Pe_true||`` (or word-level reproduction error). **Upper bounds** -from a generator verified against the forward ε-machine remain valid when -``edge_reproduction_error`` is below tolerance. - -Future work: ``_GeneratorProblem.from_edge_tensors(spec)`` sharing the same -``generator_kernel`` / ``coupled_search`` interface as the bidirectional-backed -problem. +r"""Edge-tensor specification for generator search without a full bidirectional graph. + +A :class:`GeneratorProblemSpec` holds the data the generator equations need: the edge law +``Pe = Pr(S+_0, X_0, S-_1)``, the forward and reverse maps ``F`` and ``G``, and the causal-state pairs +with their masses. :meth:`~sofic.generators.coupled_generators._GeneratorProblem.from_spec` turns it +into a problem for the kernel LP and the generator caps of :mod:`~sofic.generators.generator_caps`. + +**Future windows** (:func:`window_spec`). When the reverse ε-machine is infinite, replace the reverse +causal state by the window :math:`Y_t = X_{t:t+L}` of the next ``L`` symbols. The generator-cap +relaxation uses three properties of :math:`S^-`, and all hold for :math:`Y`: a generator state +:math:`W_t` separates :math:`S^+_t` from :math:`Y_t` (it separates past from future); +:math:`Y_0 = g(X_0, Y_1)` with :math:`g` keeping :math:`X_0` and dropping the last symbol of +:math:`Y_1`; and :math:`Y_1` is independent of :math:`(W_0, X_0)` given :math:`W_1`. So every generator +satisfies the window equations, and caps and coverage bounds computed from a window spec are lower +bounds on :math:`C_g` (Löhr & Ay :cite:`LohrAy2009`) that need only the forward ε-machine. The tensors +are exact products of the forward symbol matrices. A solution of the window equations need not +generate the process, so upper bounds still need word-level verification. """ from __future__ import annotations +import itertools from dataclasses import dataclass +from typing import Any import numpy as np @@ -31,14 +35,19 @@ class GeneratorProblemSpec: F: np.ndarray """Forward causal map indicators, shape ``(n_plus, n_sym, n_plus)``.""" G: np.ndarray - """Reverse causal map indicators, shape ``(n_sym, n_minus, n_minus)``.""" + """Reverse causal map indicators, shape ``(n_sym, n_minus, n_minus)``: ``G[x, b', b] = 1`` when + ``S-_0 = b`` precedes ``S-_1 = b'`` on ``x``.""" p_pair: np.ndarray """Pair marginal on lifted joint indices (same layout as ``_LiftedProblem.p``).""" p_plus: np.ndarray """Marginal on forward causal states.""" p_minus: np.ndarray """Marginal on reverse causal states.""" - symbols: tuple[int, ...] | tuple[str, ...] + symbols: tuple[Any, ...] + pairs: tuple[tuple[int, int], ...] | None = None + """``(plus index, minus index)`` of each entry of ``p_pair``.""" + minus_labels: tuple[Any, ...] | None = None + """Names of the reverse states (future windows for :func:`window_spec`).""" pe_drift: float | None = None """Max entrywise change in ``Pe`` between reverse-MSP caps (survey ladder).""" edge_reproduction_error: float | None = None @@ -57,8 +66,12 @@ def n_sym(self) -> int: return int(self.Pe.shape[1]) -def spec_from_generator_problem(problem: object) -> GeneratorProblemSpec: +def spec_from_generator_problem(problem: Any) -> GeneratorProblemSpec: """Snapshot tensors from an existing ``_GeneratorProblem``.""" + pairs = tuple( + (int(a), int(b)) + for a, b in zip(np.argmax(problem.lifted.plus, axis=1), np.argmax(problem.lifted.minus, axis=1), strict=True) + ) return GeneratorProblemSpec( Pe=np.asarray(problem.Pe, float), F=np.asarray(problem.F, float), @@ -67,7 +80,58 @@ def spec_from_generator_problem(problem: object) -> GeneratorProblemSpec: p_plus=np.asarray(problem.p_plus, float), p_minus=np.asarray(problem.p_minus, float), symbols=tuple(problem.symbols), + pairs=pairs, + ) + + +def window_spec(forward: Any, length: int, *, atol: float = 1e-15) -> GeneratorProblemSpec: + """Generator equations with the reverse causal state replaced by the next ``length`` symbols. + + ``forward`` is a (unifilar) forward ε-machine. See the module docstring for why the window + equations relax the generator equations. + """ + from sofic.generators.matrices import symbol_matrices + + if length < 1: + raise ValueError("length must be at least 1") + mats = {x: np.asarray(T, dtype=float) for x, T in symbol_matrices(forward).items()} + symbols = tuple(mats) + n = next(iter(mats.values())).shape[0] + total = np.sum(list(mats.values()), axis=0) + vals, vecs = np.linalg.eig(total.T) + pi = np.real(vecs[:, int(np.argmin(np.abs(vals - 1)))]) + pi = pi / pi.sum() + + def tail(word: tuple[Any, ...]) -> np.ndarray: + v = np.ones(n) + for x in reversed(word): + v = mats[x] @ v + return v + + words = [w for w in itertools.product(symbols, repeat=length) if pi @ tail(w) > atol] + index = {w: j for j, w in enumerate(words)} + P = np.array([pi * tail(w) for w in words]).T + Pe = np.zeros((n, len(symbols), len(words))) + G = np.zeros((len(symbols), len(words), len(words))) + for i, x in enumerate(symbols): + for j, w in enumerate(words): + Pe[:, i, j] = pi * (mats[x] @ tail(w)) + if Pe[:, i, j].sum() > atol: + G[i, j, index[(x, *w[:-1])]] = 1.0 + F = np.stack([(mats[x] > 0).astype(float) for x in symbols], axis=1) + cells = [(a, j) for a in range(n) for j in range(len(words)) if P[a, j] > atol] + p_pair = np.array([P[a, j] for a, j in cells]) + return GeneratorProblemSpec( + Pe=Pe / Pe.sum(), + F=F, + G=G, + p_pair=p_pair / p_pair.sum(), + p_plus=pi, + p_minus=P.sum(axis=0), + symbols=symbols, + pairs=tuple(cells), + minus_labels=tuple(words), ) -__all__ = ["GeneratorProblemSpec", "spec_from_generator_problem"] +__all__ = ["GeneratorProblemSpec", "spec_from_generator_problem", "window_spec"] diff --git a/tests/test_generator_problem_spec.py b/tests/test_generator_problem_spec.py index b8befd4..11ceaf4 100644 --- a/tests/test_generator_problem_spec.py +++ b/tests/test_generator_problem_spec.py @@ -1,10 +1,16 @@ -"""Tests for GeneratorProblemSpec snapshots.""" +"""Tests for GeneratorProblemSpec snapshots and future-window specs.""" from __future__ import annotations +import numpy as np +import pytest + from sofic.examples.epsilon_machines import golden_mean_bidirectional -from sofic.generators.coupled_generators import _GeneratorProblem -from sofic.generators.generator_problem_spec import spec_from_generator_problem +from sofic.generators.coupled_generators import _GeneratorProblem, _kernel_lp_beliefs +from sofic.generators.generator_caps import generator_coverage_bound, generator_weight_cap +from sofic.generators.generator_problem_spec import spec_from_generator_problem, window_spec +from tests.test_coupled_generators import _rll +from tests.test_generator_caps import _SURVEY_UPPER, _survey_machine def test_spec_from_generator_problem_roundtrip_shapes() -> None: @@ -12,3 +18,39 @@ def test_spec_from_generator_problem_roundtrip_shapes() -> None: spec = spec_from_generator_problem(problem) assert spec.Pe.shape == problem.Pe.shape assert spec.n_sym == problem.n_sym + + +def test_problem_from_spec_reproduces_caps_and_kernel_lp() -> None: + problem = _GeneratorProblem(_survey_machine()) + rebuilt = _GeneratorProblem.from_spec(spec_from_generator_problem(problem)) + assert generator_weight_cap(rebuilt, 1, max_nodes=40) == generator_weight_cap(problem, 1, max_nodes=40) + seed = problem.epsilon_seed() + assert _kernel_lp_beliefs(rebuilt, *seed[:3])[0] == pytest.approx(_kernel_lp_beliefs(problem, *seed[:3])[0]) + + +@pytest.mark.parametrize("length", [1, 2, 3]) +def test_window_spec_is_a_consistent_edge_law(length: int) -> None: + spec = window_spec(_survey_machine().forward_machine, length) + assert spec.Pe.sum() == pytest.approx(1.0) + # S+_0 marginal is stationary; Y_1 = X_{1:L+1} has the same law as Y_0 = X_{0:L} + assert spec.Pe.sum(axis=(1, 2)) == pytest.approx(spec.p_plus) + assert spec.Pe.sum(axis=(0, 1)) == pytest.approx(spec.p_minus) + # Y_0 = g(X_0, Y_1): each (x, y') with mass maps to exactly one window + live = spec.Pe.sum(axis=0) > 0 + assert np.all(spec.G.sum(axis=2)[live] == 1) + # every generator satisfies the window equations, in particular the forward epsilon-machine + problem = _GeneratorProblem.from_spec(spec) + pi, Q, R, _K = problem.epsilon_seed() + assert _kernel_lp_beliefs(problem, pi, Q, R)[0] < 1e-9 + + +def test_two_symbol_window_gives_three_halves_for_rll() -> None: + problem = _GeneratorProblem.from_spec(window_spec(_rll().forward_machine, 2)) + assert problem.n_minus == 3 + assert generator_coverage_bound(problem, explicit=2, max_nodes=(300, 600)).value == pytest.approx(1.5, abs=1e-6) + + +def test_window_bounds_stay_below_the_known_upper_bound() -> None: + problem = _GeneratorProblem.from_spec(window_spec(_survey_machine().forward_machine, 2)) + value = generator_coverage_bound(problem, explicit=2, max_nodes=(200, 400)).value + assert 0.9 < value <= _SURVEY_UPPER diff --git a/tests/test_line_split_seeds.py b/tests/test_line_split_seeds.py index 0651ee7..7c789b6 100644 --- a/tests/test_line_split_seeds.py +++ b/tests/test_line_split_seeds.py @@ -34,11 +34,11 @@ def test_targeted_search_finds_low_entropy_generator() -> None: problem = cg._GeneratorProblem(_widest_survey_machine()) _best, optima = cg.separating_optima(problem, starts=10, rng=np.random.default_rng(0)) seeds = cg.targeted_line_split_seeds(problem, optima, configs=((1, 1), (2, 0)), rng=np.random.default_rng(0)) - assert seeds + assert 0 < len(seeds) <= 3 assert all(problem.residual(*seed) <= 1e-7 for _name, seed in seeds) - # the pipeline's other stages reach 1.371 here and a 0.970-bit generator is known; the local - # optimizers land between the two depending on platform floating point (0.98 to 1.02 seen) - assert min(cg._entropy(seed[0]) for _name, seed in seeds) < 1.1 + # the pipeline's other stages reach 1.371 here and a 0.970-bit generator is known; COBYLA alone + # stops at 0.98 to 1.10 depending on the seed, and the local-search hops reach 0.970 to 0.975 + assert min(cg._entropy(seed[0]) for _name, seed in seeds) < 0.98 def test_line_split_seeds_are_exact_decompositions_with_point_mass_states() -> None: