sofic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. sofic/__init__.py +185 -0
  2. sofic/automata/__init__.py +207 -0
  3. sofic/automata/_config_simulation.py +40 -0
  4. sofic/automata/active.py +611 -0
  5. sofic/automata/alergia.py +222 -0
  6. sofic/automata/algorithms.py +376 -0
  7. sofic/automata/atomaton.py +58 -0
  8. sofic/automata/base.py +161 -0
  9. sofic/automata/buchi.py +23 -0
  10. sofic/automata/buchi_simulation.py +67 -0
  11. sofic/automata/canonical_dual.py +18 -0
  12. sofic/automata/canonical_extraction.py +122 -0
  13. sofic/automata/dfa.py +85 -0
  14. sofic/automata/dfasat.py +195 -0
  15. sofic/automata/edsm.py +219 -0
  16. sofic/automata/enumeration.py +44 -0
  17. sofic/automata/icdfa.py +421 -0
  18. sofic/automata/idfa.py +363 -0
  19. sofic/automata/languages/__init__.py +39 -0
  20. sofic/automata/languages/_quotient_utils.py +64 -0
  21. sofic/automata/languages/atoms.py +31 -0
  22. sofic/automata/languages/automaton_ops.py +243 -0
  23. sofic/automata/languages/base.py +67 -0
  24. sofic/automata/languages/operations.py +78 -0
  25. sofic/automata/languages/quotients.py +66 -0
  26. sofic/automata/languages/residuals.py +25 -0
  27. sofic/automata/learning.py +79 -0
  28. sofic/automata/nfa.py +39 -0
  29. sofic/automata/nwa.py +343 -0
  30. sofic/automata/nwa_simulation.py +56 -0
  31. sofic/automata/observation.py +40 -0
  32. sofic/automata/papni.py +301 -0
  33. sofic/automata/regex.py +128 -0
  34. sofic/automata/rfsa.py +35 -0
  35. sofic/automata/rpni.py +193 -0
  36. sofic/automata/subsequential.py +201 -0
  37. sofic/automata/transducer_operations.py +350 -0
  38. sofic/automata/transducer_simulation.py +150 -0
  39. sofic/automata/transducers.py +365 -0
  40. sofic/automata/unifilar.py +107 -0
  41. sofic/automata/vpa.py +1373 -0
  42. sofic/automata/vpa_simulation.py +53 -0
  43. sofic/base.py +153 -0
  44. sofic/core.py +47 -0
  45. sofic/examples/__init__.py +86 -0
  46. sofic/examples/epsilon_machines.py +1089 -0
  47. sofic/examples/processes.py +1491 -0
  48. sofic/examples/shifts.py +144 -0
  49. sofic/exceptions.py +33 -0
  50. sofic/generators/__init__.py +115 -0
  51. sofic/generators/_word_measures.py +94 -0
  52. sofic/generators/alternative_complexity.py +104 -0
  53. sofic/generators/base.py +327 -0
  54. sofic/generators/bidirectional_construction.py +717 -0
  55. sofic/generators/bidirectional_epsilon_machine.py +689 -0
  56. sofic/generators/block_convergence.py +668 -0
  57. sofic/generators/block_entropy.py +578 -0
  58. sofic/generators/channel_measures.py +75 -0
  59. sofic/generators/conversions.py +182 -0
  60. sofic/generators/directional_flow.py +245 -0
  61. sofic/generators/edge_emissions.py +36 -0
  62. sofic/generators/edge_machine.py +178 -0
  63. sofic/generators/epsilon_construction.py +193 -0
  64. sofic/generators/epsilon_inference.py +703 -0
  65. sofic/generators/epsilon_machine.py +557 -0
  66. sofic/generators/epsilon_transducer.py +168 -0
  67. sofic/generators/epsilon_transducer_construction.py +185 -0
  68. sofic/generators/epsilon_transducer_inference.py +499 -0
  69. sofic/generators/hmm_inference.py +719 -0
  70. sofic/generators/information_diagram.py +428 -0
  71. sofic/generators/lumping.py +447 -0
  72. sofic/generators/markov.py +100 -0
  73. sofic/generators/mealy.py +156 -0
  74. sofic/generators/measures.py +257 -0
  75. sofic/generators/minimal_generative_model.py +821 -0
  76. sofic/generators/mixed_state.py +250 -0
  77. sofic/generators/mixed_state_construction.py +163 -0
  78. sofic/generators/moore.py +75 -0
  79. sofic/generators/nmachine.py +78 -0
  80. sofic/generators/nmachine_construction.py +70 -0
  81. sofic/generators/pfa.py +100 -0
  82. sofic/generators/prob.py +291 -0
  83. sofic/generators/process_equivalence.py +207 -0
  84. sofic/generators/quasi_inference.py +74 -0
  85. sofic/generators/quasi_realization.py +97 -0
  86. sofic/generators/reversal.py +66 -0
  87. sofic/generators/stack_hmm.py +426 -0
  88. sofic/generators/stack_inference.py +509 -0
  89. sofic/generators/stationary.py +134 -0
  90. sofic/generators/stochastic.py +65 -0
  91. sofic/generators/synchronization.py +407 -0
  92. sofic/generators/topological_epsilon_enumeration.py +349 -0
  93. sofic/generators/words.py +226 -0
  94. sofic/graph.py +135 -0
  95. sofic/indexing.py +31 -0
  96. sofic/inference/__init__.py +45 -0
  97. sofic/inference/bayesian/__init__.py +68 -0
  98. sofic/inference/bayesian/comparison.py +199 -0
  99. sofic/inference/bayesian/counts.py +219 -0
  100. sofic/inference/bayesian/diversity.py +254 -0
  101. sofic/inference/bayesian/epsilon.py +270 -0
  102. sofic/inference/bayesian/hdp_hmm.py +340 -0
  103. sofic/inference/bayesian/markov.py +294 -0
  104. sofic/inference/bayesian/pymc_backend.py +71 -0
  105. sofic/inference/bayesian/stack_hmm.py +215 -0
  106. sofic/inference/model_selection.py +365 -0
  107. sofic/inference/spectral.py +564 -0
  108. sofic/operations.py +16 -0
  109. sofic/properties.py +339 -0
  110. sofic/serialization.py +450 -0
  111. sofic/shifts/__init__.py +48 -0
  112. sofic/shifts/algorithms.py +84 -0
  113. sofic/shifts/base.py +49 -0
  114. sofic/shifts/cover_construction.py +76 -0
  115. sofic/shifts/covers.py +47 -0
  116. sofic/shifts/dyck_algorithms.py +100 -0
  117. sofic/shifts/dyck_enumeration.py +275 -0
  118. sofic/shifts/markov_dyck.py +172 -0
  119. sofic/shifts/parry_construction.py +82 -0
  120. sofic/shifts/sft.py +104 -0
  121. sofic/shifts/sft_construction.py +52 -0
  122. sofic/shifts/sliding_block_code.py +156 -0
  123. sofic/shifts/sofic.py +111 -0
  124. sofic/shifts/sofic_dyck.py +110 -0
  125. sofic/shifts/sofic_relation.py +64 -0
  126. sofic/shifts/textile.py +104 -0
  127. sofic/shifts/tmc.py +46 -0
  128. sofic/shifts/tmc_construction.py +58 -0
  129. sofic/shifts/topological_anatomy.py +150 -0
  130. sofic/states.py +27 -0
  131. sofic/testing/__init__.py +8 -0
  132. sofic/testing/strategies.py +154 -0
  133. sofic/viz/__init__.py +16 -0
  134. sofic/viz/_context.py +345 -0
  135. sofic/viz/_edge.py +216 -0
  136. sofic/viz/_format.py +89 -0
  137. sofic/viz/_labels.py +34 -0
  138. sofic/viz/_names.py +17 -0
  139. sofic/viz/_rational.py +20 -0
  140. sofic/viz/_tikz_compile.py +177 -0
  141. sofic/viz/_tikz_format.py +122 -0
  142. sofic/viz/_tikz_layout.py +218 -0
  143. sofic/viz/assets/vaucanson.tikz +71 -0
  144. sofic/viz/graphviz.py +158 -0
  145. sofic/viz/idiagram.py +350 -0
  146. sofic/viz/tikz.py +381 -0
  147. sofic-0.1.0.dist-info/METADATA +444 -0
  148. sofic-0.1.0.dist-info/RECORD +150 -0
  149. sofic-0.1.0.dist-info/WHEEL +4 -0
  150. sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
@@ -0,0 +1,168 @@
1
+ """Epsilon-transducers: minimal unifilar presentations of channels.
2
+
3
+ Computational mechanics quantifies structure in a single stochastic process via
4
+ its causal states, yielding the minimal optimal predictor -- the ε-machine.
5
+ Barnett & Crutchfield (*Computational Mechanics of Input-Output Processes:
6
+ Structured Transformations and the ε-Transducer*, J. Stat. Phys. 161:2 (2015)
7
+ 404-451, doi:10.1007/s10955-015-1327-5) extend this to communication channels
8
+ coupling two processes, obtaining the ε-transducer: the tuple
9
+ ``(X, Y, S, T)`` of input alphabet, output alphabet, causal states, and
10
+ conditional-symbol transition probabilities ``T(y, s' | s, x)``.
11
+
12
+ Structurally the ε-transducer is a unifilar, input-driven stochastic Mealy
13
+ machine, so :class:`EpsilonTransducer` extends the probability-aware
14
+ :class:`~sofic.automata.transducers.MealyMachine` with a causal-state initial
15
+ distribution, causal-state minimization, transCSSR reconstruction, and channel
16
+ information measures.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Hashable, Mapping, Sequence
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ import numpy as np
25
+
26
+ from sofic.automata.transducers import MealyMachine
27
+ from sofic.exceptions import StochasticValidationError, UnifilarityError
28
+
29
+ if TYPE_CHECKING:
30
+ from sofic.generators.base import HiddenMarkovModel
31
+
32
+
33
+ class EpsilonTransducer(MealyMachine):
34
+ """Unifilar stochastic Mealy machine: minimal causal presentation of a channel.
35
+
36
+ The channel law lives on edges as ``T(y, s' | s, x)``: each outgoing edge of
37
+ causal state ``s`` carries an input symbol ``x``, an output symbol ``y``, and
38
+ a probability, with the probabilities of all ``(y, s')`` sharing a given
39
+ ``(s, x)`` summing to one. Unifilarity means the observed pair ``(x, y)``
40
+ determines the successor causal state.
41
+
42
+ Examples
43
+ --------
44
+ >>> from sofic.examples.processes import BinaryChannel
45
+ >>> from sofic import EpsilonTransducer
46
+ >>> channel = BinaryChannel(0.1, 0.2)
47
+ >>> eps = EpsilonTransducer.from_channel(channel)
48
+ >>> eps.is_unifilar()
49
+ True
50
+ """
51
+
52
+ initial_distribution: dict[Hashable, float]
53
+
54
+ def __init__(
55
+ self,
56
+ input_alphabet: frozenset[Any] | None = None,
57
+ output_alphabet: frozenset[Any] | None = None,
58
+ initial_states: frozenset[Hashable] | None = None,
59
+ initial_distribution: Mapping[Hashable, float] | None = None,
60
+ **kwargs: Any,
61
+ ) -> None:
62
+ super().__init__(
63
+ input_alphabet=input_alphabet,
64
+ output_alphabet=output_alphabet,
65
+ initial_states=initial_states,
66
+ **kwargs,
67
+ )
68
+ self.initial_distribution = dict(initial_distribution or {})
69
+
70
+ def validate(self) -> None:
71
+ super().validate()
72
+ self.validate_stochastic()
73
+ self._check_unifilar()
74
+ self._validate_initial_distribution()
75
+
76
+ def _validate_initial_distribution(self) -> None:
77
+ if not self.initial_distribution:
78
+ return
79
+ total = float(sum(self.initial_distribution.values()))
80
+ if not np.isclose(total, 1.0):
81
+ raise StochasticValidationError(f"initial distribution sums to {total}, not 1")
82
+ for state, mass in self.initial_distribution.items():
83
+ if mass < 0:
84
+ raise StochasticValidationError(f"negative initial probability at {state!r}")
85
+ self._require(self.graph.has_state(state), f"unknown initial state {state!r}")
86
+
87
+ def _check_unifilar(self) -> None:
88
+ if self.is_unifilar():
89
+ return
90
+ raise UnifilarityError("epsilon-transducer must be unifilar on (state, input, output)")
91
+
92
+ def is_unifilar(self) -> bool:
93
+ """Return whether ``(state, input, output)`` determines the successor state."""
94
+ from sofic.properties import is_unifilar_transducer
95
+
96
+ return is_unifilar_transducer(self)
97
+
98
+ def causal_states(self) -> list[Hashable]:
99
+ """Return the causal states (an alias for :meth:`states`)."""
100
+ return list(self.states())
101
+
102
+ # -- construction ---------------------------------------------------------
103
+
104
+ @classmethod
105
+ def from_channel(cls, channel: MealyMachine, **kwargs: Any) -> EpsilonTransducer:
106
+ """Minimize a (joint-unifilar) stochastic transducer to causal states."""
107
+ from sofic.generators.epsilon_transducer_construction import build_epsilon_transducer
108
+
109
+ return build_epsilon_transducer(channel, **kwargs)
110
+
111
+ @classmethod
112
+ def from_iohmm(cls, channel: MealyMachine, **kwargs: Any) -> EpsilonTransducer:
113
+ """Alias for :meth:`from_channel` (an input-output HMM is a stochastic Mealy machine)."""
114
+ return cls.from_channel(channel, **kwargs)
115
+
116
+ @classmethod
117
+ def from_joint_generator(cls, generator: HiddenMarkovModel, **kwargs: Any) -> EpsilonTransducer:
118
+ """Build the ε-transducer from a generator emitting ``(input, output)`` pairs."""
119
+ from sofic.generators.epsilon_transducer_construction import from_joint_generator
120
+
121
+ return from_joint_generator(generator, **kwargs)
122
+
123
+ @classmethod
124
+ def from_paired_sequences(
125
+ cls,
126
+ inputs: Sequence[Any],
127
+ outputs: Sequence[Any],
128
+ **kwargs: Any,
129
+ ) -> EpsilonTransducer:
130
+ """Reconstruct an ε-transducer from paired input/output sequences via transCSSR."""
131
+ from sofic.generators.epsilon_transducer_inference import transcssr
132
+
133
+ return transcssr(inputs, outputs, **kwargs)
134
+
135
+ # -- channel measures -----------------------------------------------------
136
+
137
+ def statistical_complexity(self, input_process: HiddenMarkovModel) -> float:
138
+ """Channel statistical complexity ``H[S]`` when driven by ``input_process``."""
139
+ from sofic.generators.channel_measures import channel_statistical_complexity
140
+
141
+ return channel_statistical_complexity(self, input_process)
142
+
143
+ def driven_entropy_rate(self, input_process: HiddenMarkovModel) -> float:
144
+ """Entropy rate of the output process induced by ``input_process``."""
145
+ from sofic.generators.channel_measures import driven_entropy_rate
146
+
147
+ return driven_entropy_rate(self, input_process)
148
+
149
+ def directed_information(self, input_process: HiddenMarkovModel, *, length: int = 1) -> float:
150
+ """Directed information ``I(X^n -> Y^n)`` when driven by ``input_process``."""
151
+ from sofic.generators.channel_measures import directed_information
152
+
153
+ return directed_information(self, input_process, length=length)
154
+
155
+ def transfer_entropy(self, input_process: HiddenMarkovModel, *, history: int = 1) -> float:
156
+ """Transfer entropy from input to output when driven by ``input_process``."""
157
+ from sofic.generators.channel_measures import transfer_entropy
158
+
159
+ return transfer_entropy(self, input_process, history=history)
160
+
161
+ # -- conversions ----------------------------------------------------------
162
+
163
+ @classmethod
164
+ def from_wfst(cls, wfst: Any) -> EpsilonTransducer:
165
+ """Build an ε-transducer from a probability-semiring weighted transducer."""
166
+ if getattr(wfst, "semiring", "probability") != "probability":
167
+ raise ValueError("from_wfst requires a probability-semiring WFST")
168
+ return cls.from_channel(MealyMachine.from_networkx(wfst.to_networkx(), initial_states=wfst.initial_states))
@@ -0,0 +1,185 @@
1
+ """Epsilon-transducer construction via causal-state merging.
2
+
3
+ For a joint-unifilar stochastic transducer, merge channel-equivalent states by
4
+ Hopcroft-style partition refinement on ``(input, output, probability,
5
+ successor_block)`` transition signatures -- the input-output analog of the
6
+ ε-machine construction in :mod:`sofic.generators.epsilon_construction` (Barnett &
7
+ Crutchfield, J. Stat. Phys. 161:2 (2015)).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import defaultdict
13
+ from typing import Any
14
+
15
+ from sofic.automata.transducers import MealyMachine
16
+ from sofic.exceptions import StochasticValidationError
17
+ from sofic.generators.epsilon_transducer import EpsilonTransducer
18
+ from sofic.graph import ATTR_EMISSION, ATTR_OUTPUT, ATTR_PROB, ATTR_SYMBOL, EPSILON, TransitionGraph
19
+ from sofic.states import sequential_labels
20
+
21
+ TransducerSignature = tuple[tuple[Any, Any, Any, int], ...]
22
+
23
+ _PROB_DIGITS = 12
24
+
25
+
26
+ def build_epsilon_transducer(channel: MealyMachine, *, name: str | None = None) -> EpsilonTransducer:
27
+ """Minimize a joint-unifilar stochastic transducer to its causal states."""
28
+ from sofic.properties import is_unifilar_transducer
29
+
30
+ work = channel.copy()
31
+ if not is_unifilar_transducer(work):
32
+ raise StochasticValidationError(
33
+ "channel must be joint-unifilar (each (state, input, output) has one successor) "
34
+ "to build an epsilon-transducer; reconstruct from paired data with "
35
+ "EpsilonTransducer.from_paired_sequences instead"
36
+ )
37
+ _trim_unreachable(work)
38
+ partitions = _refine_partitions(work)
39
+ result = _quotient_transducer(work, partitions)
40
+ if name is not None:
41
+ result.name = name
42
+ return result
43
+
44
+
45
+ def _trim_unreachable(tr: MealyMachine) -> None:
46
+ """Drop states not forward-reachable from the initial states (in place)."""
47
+ if not tr.initial_states:
48
+ return
49
+ reachable = tr.graph.forward_reachable(set(tr.initial_states))
50
+ for state in list(tr.states()):
51
+ if state not in reachable:
52
+ tr.graph.nx.remove_node(state)
53
+
54
+
55
+ def _refine_partitions(tr: MealyMachine) -> list[set[Any]]:
56
+ partitions: list[set[Any]] = [set(tr.states())]
57
+ changed = True
58
+ while changed:
59
+ changed = False
60
+ state_to_block = _state_to_block_index(partitions)
61
+ new_partitions: list[set[Any]] = []
62
+ for block in partitions:
63
+ subblocks = _split_block(tr, block, state_to_block)
64
+ if len(subblocks) > 1:
65
+ changed = True
66
+ new_partitions.extend(subblocks)
67
+ partitions = new_partitions
68
+ return partitions
69
+
70
+
71
+ def _state_to_block_index(partitions: list[set[Any]]) -> dict[Any, int]:
72
+ mapping: dict[Any, int] = {}
73
+ for index, block in enumerate(partitions):
74
+ for state in block:
75
+ mapping[state] = index
76
+ return mapping
77
+
78
+
79
+ def _split_block(tr: MealyMachine, block: set[Any], state_to_block: dict[Any, int]) -> list[set[Any]]:
80
+ signatures: dict[TransducerSignature, set[Any]] = defaultdict(set)
81
+ for state in block:
82
+ signatures[_transition_signature(tr, state, state_to_block)].add(state)
83
+ return list(signatures.values())
84
+
85
+
86
+ def _transition_signature(tr: MealyMachine, state: Any, state_to_block: dict[Any, int]) -> TransducerSignature:
87
+ triples: list[tuple[Any, Any, Any, int]] = []
88
+ for transition in tr.graph.out_transitions(state):
89
+ symbol = transition.data.get(ATTR_SYMBOL, EPSILON)
90
+ output = transition.data.get(ATTR_OUTPUT, EPSILON)
91
+ prob = round(float(transition.data.get(ATTR_PROB, 1.0)), _PROB_DIGITS)
92
+ triples.append((symbol, output, prob, state_to_block[transition.target]))
93
+ return tuple(sorted(triples, key=repr))
94
+
95
+
96
+ def _quotient_transducer(tr: MealyMachine, partitions: list[set[Any]]) -> EpsilonTransducer:
97
+ state_map: dict[Any, int] = _state_to_block_index(partitions)
98
+ labels = sequential_labels(len(partitions))
99
+
100
+ graph = TransitionGraph()
101
+ for label in labels:
102
+ graph.add_state(label)
103
+
104
+ inputs: set[Any] = set()
105
+ outputs: set[Any] = set()
106
+ for block_index, block in enumerate(partitions):
107
+ representative = next(iter(block))
108
+ source = labels[block_index]
109
+ for transition in tr.graph.out_transitions(representative):
110
+ symbol = transition.data.get(ATTR_SYMBOL, EPSILON)
111
+ output = transition.data.get(ATTR_OUTPUT, EPSILON)
112
+ prob = float(transition.data.get(ATTR_PROB, 1.0))
113
+ target = labels[state_map[transition.target]]
114
+ attrs: dict[str, Any] = {ATTR_SYMBOL: symbol, ATTR_PROB: prob}
115
+ if output is not EPSILON:
116
+ attrs[ATTR_OUTPUT] = output
117
+ graph.add_transition(source, target, **attrs)
118
+ if symbol is not EPSILON and symbol is not None:
119
+ inputs.add(symbol)
120
+ if output is not EPSILON and output is not None:
121
+ outputs.add(output)
122
+
123
+ initial_states = frozenset(labels[state_map[state]] for state in tr.initial_states if state in state_map)
124
+ support = initial_states or frozenset(labels)
125
+ mass = 1.0 / len(support)
126
+ initial_distribution = dict.fromkeys(support, mass)
127
+
128
+ result = EpsilonTransducer(
129
+ input_alphabet=frozenset(inputs),
130
+ output_alphabet=frozenset(outputs),
131
+ initial_states=initial_states,
132
+ initial_distribution=initial_distribution,
133
+ graph=graph,
134
+ )
135
+ result.validate()
136
+ return result
137
+
138
+
139
+ def from_joint_generator(generator: Any) -> EpsilonTransducer:
140
+ """Build the ε-transducer from a generator emitting ``(input, output)`` pairs.
141
+
142
+ The joint process is first reduced to its (joint-unifilar) ε-machine, then
143
+ conditionalized to the channel law ``T(y, s' | s, x) = P(x, y, s' | s) /
144
+ P(x | s)`` before causal-state minimization.
145
+ """
146
+ from sofic.generators.epsilon_machine import EpsilonMachine
147
+
148
+ mealy = generator.to_mealy()
149
+ joint = EpsilonMachine.from_hmm(mealy)
150
+ channel = _channel_from_joint(joint)
151
+ return build_epsilon_transducer(channel)
152
+
153
+
154
+ def _channel_from_joint(joint: Any) -> MealyMachine:
155
+ marginals: dict[tuple[Any, Any], float] = defaultdict(float)
156
+ inputs: set[Any] = set()
157
+ outputs: set[Any] = set()
158
+ for transition in joint.transitions():
159
+ emission = transition.data.get(ATTR_EMISSION)
160
+ pair = _as_pair(emission)
161
+ marginals[(transition.source, pair[0])] += float(transition.data.get(ATTR_PROB, 0.0))
162
+
163
+ channel = MealyMachine(initial_states=frozenset(joint.initial_distribution))
164
+ for state in joint.states():
165
+ channel.graph.add_state(state)
166
+ for transition in joint.transitions():
167
+ emission = transition.data.get(ATTR_EMISSION)
168
+ x, y = _as_pair(emission)
169
+ prob = float(transition.data.get(ATTR_PROB, 0.0))
170
+ denom = marginals[(transition.source, x)]
171
+ if denom <= 0.0:
172
+ continue
173
+ channel.add_transition(transition.source, transition.target, x, y, prob=prob / denom)
174
+ inputs.add(x)
175
+ outputs.add(y)
176
+ channel.input_alphabet = frozenset(inputs)
177
+ channel.output_alphabet = frozenset(outputs)
178
+ channel.validate()
179
+ return channel
180
+
181
+
182
+ def _as_pair(emission: Any) -> tuple[Any, Any]:
183
+ if not isinstance(emission, tuple) or len(emission) != 2:
184
+ raise TypeError("joint generator must emit length-2 (input, output) tuples")
185
+ return emission