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
sofic/viz/_context.py ADDED
@@ -0,0 +1,345 @@
1
+ """Per-model styling metadata for Graphviz diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Hashable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from sofic.base import StateMachine
10
+ from sofic.graph import (
11
+ ATTR_EMISSION,
12
+ ATTR_EMISSION_DIST,
13
+ ATTR_FUTURE_SYMBOL,
14
+ ATTR_KIND,
15
+ ATTR_OUTPUT,
16
+ EPSILON,
17
+ KIND_CALL,
18
+ KIND_INTERNAL,
19
+ KIND_RETURN,
20
+ Transition,
21
+ )
22
+ from sofic.viz._edge import (
23
+ PART_EMISSION,
24
+ PART_KIND,
25
+ PART_MATCH_TAG,
26
+ PART_MULTIPLICITY,
27
+ PART_PROB,
28
+ PART_QUASIPROB,
29
+ PART_STACK,
30
+ PART_SYMBOL,
31
+ EdgePart,
32
+ EdgeSpec,
33
+ edge_spec,
34
+ part_value,
35
+ )
36
+ from sofic.viz._format import (
37
+ format_belief,
38
+ format_distribution,
39
+ format_prob_label,
40
+ format_state,
41
+ format_symbol,
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class VizContext:
47
+ """Rendering policy for one :class:`~sofic.base.StateMachine`."""
48
+
49
+ title: str
50
+ initial_states: frozenset[Hashable]
51
+ accepting_states: frozenset[Hashable]
52
+ state_labels: Mapping[Hashable, str]
53
+ edge_label: Callable[[Transition], str]
54
+ edge_color: Callable[[Transition], str | None] = field(default=lambda _t: None)
55
+ edge_style: Callable[[Transition], str | None] = field(default=lambda _t: None)
56
+ state_tooltip: Callable[[Hashable, Mapping[str, Any]], str | None] = field(default=lambda _s, _a: None)
57
+ node_fillcolor: Callable[[Hashable], str | None] = field(default=lambda _s: None)
58
+ show_start_node: bool = True
59
+ highlight_initial_states: bool = True
60
+ graph_engine: str | None = None
61
+ rankdir: str | None = None
62
+
63
+
64
+ def _stochastic_initials(model: StateMachine) -> frozenset[Hashable]:
65
+ dist = getattr(model, "initial_distribution", None)
66
+ if isinstance(dist, Mapping) and dist:
67
+ return frozenset(dist)
68
+ quasidist = getattr(model, "initial_quasidistribution", None)
69
+ if isinstance(quasidist, Mapping) and quasidist:
70
+ return frozenset(quasidist)
71
+ return frozenset()
72
+
73
+
74
+ def _state_label_with_attrs(state: Hashable, attrs: Mapping[str, Any], extras: list[str]) -> str:
75
+ base = format_state(state)
76
+ lines = [base, *extras]
77
+ future = attrs.get(ATTR_FUTURE_SYMBOL)
78
+ if future is not None:
79
+ lines.append(f"γ={format_symbol(future)}")
80
+ output = attrs.get(ATTR_OUTPUT)
81
+ if output is not None:
82
+ lines.append(f"out={format_symbol(output)}")
83
+ emission_dist = attrs.get(ATTR_EMISSION_DIST)
84
+ if emission_dist:
85
+ lines.append(format_distribution(emission_dist))
86
+ return "\\n".join(lines)
87
+
88
+
89
+ def _edge_state_label_with_attrs(attrs: Mapping[str, Any]) -> str | None:
90
+ from sofic.generators.edge_machine import ATTR_EDGE_SOURCE, ATTR_EDGE_TARGET
91
+
92
+ if ATTR_EDGE_SOURCE not in attrs or ATTR_EMISSION not in attrs or ATTR_EDGE_TARGET not in attrs:
93
+ return None
94
+ source = format_state(attrs[ATTR_EDGE_SOURCE])
95
+ emission = format_symbol(attrs[ATTR_EMISSION])
96
+ target = format_state(attrs[ATTR_EDGE_TARGET])
97
+ return f"({source}, {emission}, {target})"
98
+
99
+
100
+ def _render_dot_part(part: EdgePart) -> str:
101
+ if part.kind == PART_KIND:
102
+ return str(part.value)
103
+ if part.kind == PART_STACK:
104
+ return f"↑{format_symbol(part.value)}"
105
+ if part.kind == PART_MULTIPLICITY:
106
+ return f"×{part.value}"
107
+ if part.kind == PART_MATCH_TAG:
108
+ return str(part.value)
109
+ if part.kind in (PART_PROB, PART_QUASIPROB):
110
+ return format_prob_label(part.value)
111
+ # symbol / emission / output
112
+ return format_symbol(part.value)
113
+
114
+
115
+ def _dot_edge_label(spec: EdgeSpec) -> str:
116
+ parts = [_render_dot_part(part) for part in spec.parts]
117
+ return " | ".join(parts) if parts else ""
118
+
119
+
120
+ def _dyck_edge_color(kind: Any) -> str | None:
121
+ if kind == KIND_CALL:
122
+ return "seagreen"
123
+ if kind == KIND_RETURN:
124
+ return "firebrick"
125
+ if kind == KIND_INTERNAL:
126
+ return "steelblue"
127
+ return None
128
+
129
+
130
+ # Tableau 10 — Graphviz hex, stable assignment by sorted ``repr`` of the symbol.
131
+ EMISSION_PALETTE: tuple[str, ...] = (
132
+ "#1f77b4",
133
+ "#ff7f0e",
134
+ "#2ca02c",
135
+ "#d62728",
136
+ "#9467bd",
137
+ "#8c564b",
138
+ "#e377c2",
139
+ "#7f7f7f",
140
+ "#bcbd22",
141
+ "#17becf",
142
+ )
143
+
144
+ _NAMED_RGB: dict[str, tuple[int, int, int]] = {
145
+ "seagreen": (46, 139, 87),
146
+ "firebrick": (178, 34, 34),
147
+ "steelblue": (70, 130, 180),
148
+ }
149
+
150
+
151
+ def _rgb_from_graphviz_color(color: str) -> tuple[int, int, int] | None:
152
+ if color.startswith("#") and len(color) == 7:
153
+ return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
154
+ return _NAMED_RGB.get(color)
155
+
156
+
157
+ def tikz_draw_color(color: str) -> str:
158
+ """TikZ ``draw=`` value for a Graphviz color string (hex or named)."""
159
+ rgb = _rgb_from_graphviz_color(color)
160
+ if rgb is None:
161
+ return color
162
+ red, green, blue = rgb
163
+ return f"{{rgb,255:red,{red};green,{green};blue,{blue}}}"
164
+
165
+
166
+ def _emission_color_key(model: StateMachine, transition: Transition) -> Any:
167
+ """Emission (else input/label symbol) used to color ``transition``, or None."""
168
+ key = part_value(edge_spec(model, transition), PART_EMISSION, PART_SYMBOL)
169
+ if key is None or key is EPSILON:
170
+ return None
171
+ return key
172
+
173
+
174
+ def _emission_color_map(model: StateMachine) -> dict[Any, str]:
175
+ keys = sorted(
176
+ {key for transition in model.transitions() if (key := _emission_color_key(model, transition)) is not None},
177
+ key=repr,
178
+ )
179
+ return {key: EMISSION_PALETTE[index % len(EMISSION_PALETTE)] for index, key in enumerate(keys)}
180
+
181
+
182
+ _TRANSIENT_FILL = "mistyrose"
183
+ _RECURRENT_FILL = "honeydew"
184
+ _RECURRENCE_ATOL = 1e-12
185
+
186
+
187
+ def _recurrence_fill_sets(
188
+ model: StateMachine,
189
+ *,
190
+ initial_states: frozenset[Hashable],
191
+ ) -> tuple[frozenset[Hashable], frozenset[Hashable]]:
192
+ """Return ``(transient, recurrent_highlight)`` state sets for node fill colors."""
193
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
194
+ from sofic.generators.epsilon_machine import EpsilonMachine
195
+ from sofic.generators.mixed_state import MixedStatePresentation
196
+
197
+ if isinstance(model, MixedStatePresentation):
198
+ return model.transient_states, model.pure_states
199
+
200
+ if isinstance(model, (EpsilonMachine, BidirectionalEpsilonMachine)):
201
+ recurrent = model.graph.terminal_recurrent_states()
202
+ if isinstance(model, BidirectionalEpsilonMachine):
203
+ from sofic.generators.prob import is_positive_mass
204
+
205
+ starts = {
206
+ state
207
+ for state, mass in model.joint_distribution().items()
208
+ if is_positive_mass(mass, atol=_RECURRENCE_ATOL)
209
+ }
210
+ if not starts:
211
+ starts = set(model.states())
212
+ elif initial_states:
213
+ starts = set(initial_states)
214
+ else:
215
+ starts = set(model.states())
216
+ reachable = model.graph.forward_reachable(starts)
217
+ transient = frozenset(reachable - recurrent)
218
+ return transient, frozenset(reachable & recurrent)
219
+
220
+ return frozenset(), frozenset()
221
+
222
+
223
+ def viz_context(
224
+ model: StateMachine,
225
+ *,
226
+ style: str = "auto",
227
+ color_by_emission: bool = True,
228
+ ) -> VizContext:
229
+ from sofic.automata.base import LabeledAutomaton
230
+ from sofic.automata.transducers import Transducer
231
+ from sofic.automata.vpa import VisiblyPushdownAutomaton
232
+ from sofic.generators.base import QuasiStochasticModel, StochasticModel
233
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
234
+ from sofic.generators.epsilon_machine import EpsilonMachine
235
+ from sofic.generators.mealy import MealyHMM
236
+ from sofic.generators.mixed_state import MixedState, MixedStatePresentation, pure_state_index
237
+ from sofic.generators.moore import MooreHMM
238
+ from sofic.generators.nmachine import NMachine
239
+ from sofic.shifts.sofic_dyck import SoficDyckShift
240
+
241
+ paper_style = style == "paper" or (style == "auto" and isinstance(model, BidirectionalEpsilonMachine))
242
+ epsilon_paper = style == "paper"
243
+ is_msp = isinstance(model, MixedStatePresentation)
244
+ stationary_hmm = isinstance(model, (EpsilonMachine, MealyHMM, MooreHMM)) and not is_msp
245
+ annotate_stationary_mass = not (paper_style or epsilon_paper or stationary_hmm)
246
+ show_start_node = not (paper_style or stationary_hmm or is_msp)
247
+ highlight_initial_states = (not (paper_style or stationary_hmm)) or is_msp
248
+ graph_engine = "circo" if paper_style else None
249
+ rankdir = None if paper_style else "LR"
250
+
251
+ title = model.__class__.__name__
252
+ initial_states: frozenset[Hashable] = frozenset()
253
+ accepting_states: frozenset[Hashable] = frozenset()
254
+ state_labels: dict[Hashable, str] = {}
255
+
256
+ def edge_label(transition: Transition) -> str:
257
+ return _dot_edge_label(edge_spec(model, transition))
258
+
259
+ def _dyck_color(transition: Transition) -> str | None:
260
+ return _dyck_edge_color(transition.data.get(ATTR_KIND))
261
+
262
+ specialized_color: Callable[[Transition], str | None] = lambda _t: None
263
+ edge_style: Callable[[Transition], str | None] = lambda _t: None
264
+ emission_colors = _emission_color_map(model) if color_by_emission else {}
265
+
266
+ if isinstance(model, LabeledAutomaton):
267
+ initial_states = model.initial_states
268
+ accepting_states = model.accepting_states
269
+ elif isinstance(model, Transducer):
270
+ initial_states = model.initial_states
271
+ elif isinstance(model, VisiblyPushdownAutomaton):
272
+ if model.initial_state is not None:
273
+ initial_states = frozenset({model.initial_state})
274
+ accepting_states = model.accepting_states
275
+ specialized_color = _dyck_color
276
+ elif isinstance(model, MixedStatePresentation):
277
+ initial_states = frozenset({model.initial_mixed_state})
278
+ elif isinstance(model, BidirectionalEpsilonMachine):
279
+ initial_states = frozenset()
280
+ elif isinstance(model, (NMachine, MooreHMM, MealyHMM, StochasticModel, QuasiStochasticModel)):
281
+ initial_states = _stochastic_initials(model)
282
+ elif isinstance(model, SoficDyckShift):
283
+ specialized_color = _dyck_color
284
+
285
+ for state in model.states():
286
+ attrs = model.graph.state_attrs(state)
287
+ extras: list[str] = []
288
+ if isinstance(model, MixedStatePresentation) and isinstance(state, MixedState):
289
+ index = pure_state_index(state)
290
+ if index is not None:
291
+ state_labels[state] = format_state(model.basis_states[index])
292
+ continue
293
+ state_labels[state] = format_belief(state.belief)
294
+ continue
295
+ if annotate_stationary_mass and isinstance(model, StochasticModel):
296
+ dist = getattr(model, "initial_distribution", {})
297
+ if state in dist:
298
+ extras.append(f"π={format_prob_label(dist[state])}")
299
+ elif annotate_stationary_mass and isinstance(model, QuasiStochasticModel):
300
+ quasidist = getattr(model, "initial_quasidistribution", {})
301
+ if state in quasidist:
302
+ extras.append(f"π={format_prob_label(quasidist[state])}")
303
+ state_labels[state] = _edge_state_label_with_attrs(attrs) or _state_label_with_attrs(state, attrs, extras)
304
+
305
+ transient_fill, recurrent_fill = _recurrence_fill_sets(model, initial_states=initial_states)
306
+
307
+ def state_tooltip(state: Hashable, attrs: Mapping[str, Any]) -> str | None:
308
+ if isinstance(model, MixedStatePresentation) and isinstance(state, MixedState):
309
+ return format_belief(state.belief)
310
+ emission_dist = attrs.get(ATTR_EMISSION_DIST)
311
+ if emission_dist:
312
+ return format_distribution(emission_dist)
313
+ return None
314
+
315
+ def node_fillcolor(state: Hashable) -> str | None:
316
+ if state in transient_fill:
317
+ return _TRANSIENT_FILL
318
+ if state in recurrent_fill:
319
+ return _RECURRENT_FILL
320
+ return None
321
+
322
+ def edge_color(transition: Transition) -> str | None:
323
+ color = specialized_color(transition)
324
+ if color:
325
+ return color
326
+ key = _emission_color_key(model, transition)
327
+ if key is None:
328
+ return None
329
+ return emission_colors.get(key)
330
+
331
+ return VizContext(
332
+ title=title,
333
+ initial_states=initial_states,
334
+ accepting_states=accepting_states,
335
+ state_labels=state_labels,
336
+ edge_label=edge_label,
337
+ edge_color=edge_color,
338
+ edge_style=edge_style,
339
+ state_tooltip=state_tooltip,
340
+ node_fillcolor=node_fillcolor,
341
+ show_start_node=show_start_node,
342
+ highlight_initial_states=highlight_initial_states,
343
+ graph_engine=graph_engine,
344
+ rankdir=rankdir,
345
+ )
sofic/viz/_edge.py ADDED
@@ -0,0 +1,216 @@
1
+ """Backend-agnostic edge-label decomposition for viz.
2
+
3
+ A single dispatch (:func:`edge_spec`) maps each model type to a structured
4
+ :class:`EdgeSpec`: an ordered list of semantic :class:`EdgePart` pieces plus a
5
+ ``style`` tag selecting the TikZ rendering family. The Graphviz backend renders
6
+ parts uniformly (join with ``" | "``); the TikZ backend switches on ``style``.
7
+
8
+ Adding a new model type means adding one branch here, not editing two parallel
9
+ per-backend ladders.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from sofic.base import StateMachine
18
+ from sofic.graph import (
19
+ ATTR_EMISSION,
20
+ ATTR_KIND,
21
+ ATTR_MULTIPLICITY,
22
+ ATTR_OUTPUT,
23
+ ATTR_PROB,
24
+ ATTR_QUASIPROB,
25
+ ATTR_STACK_SYMBOL,
26
+ ATTR_SYMBOL,
27
+ EPSILON,
28
+ Transition,
29
+ )
30
+
31
+ # Semantic part kinds.
32
+ PART_SYMBOL = "symbol"
33
+ PART_EMISSION = "emission"
34
+ PART_PROB = "prob"
35
+ PART_QUASIPROB = "quasiprob"
36
+ PART_OUTPUT = "output"
37
+ PART_KIND = "kind"
38
+ PART_STACK = "stack"
39
+ PART_MULTIPLICITY = "multiplicity"
40
+ PART_MATCH_TAG = "match_tag"
41
+
42
+ # TikZ rendering families.
43
+ STYLE_SYMBOL_ONLY = "symbol_only"
44
+ STYLE_TRANSDUCER = "transducer"
45
+ STYLE_EDGE = "edge"
46
+ STYLE_PROB_ONLY = "prob_only"
47
+ STYLE_VPA = "vpa"
48
+ STYLE_DYCK = "dyck"
49
+ STYLE_TMC = "tmc"
50
+ STYLE_FALLBACK = "fallback"
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class EdgePart:
55
+ """One semantic piece of an edge label."""
56
+
57
+ kind: str
58
+ value: Any
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class EdgeSpec:
63
+ """Structured edge label: ordered parts (Graphviz) + TikZ ``style``."""
64
+
65
+ parts: tuple[EdgePart, ...]
66
+ style: str
67
+
68
+
69
+ def part_value(spec: EdgeSpec, *kinds: str) -> Any:
70
+ """Return the value of the first part whose kind is in ``kinds`` (or None)."""
71
+ for part in spec.parts:
72
+ if part.kind in kinds:
73
+ return part.value
74
+ return None
75
+
76
+
77
+ def _primary_symbol_part(emission: Any, symbol: Any, *, symbol_fallback: bool) -> EdgePart | None:
78
+ """Emission-or-symbol primary label part (emission preferred)."""
79
+ if emission is not None:
80
+ return EdgePart(PART_EMISSION, emission)
81
+ if symbol_fallback and symbol is not None:
82
+ return EdgePart(PART_SYMBOL, symbol)
83
+ return None
84
+
85
+
86
+ def _prob_part(prob: Any, quasiprob: Any) -> EdgePart | None:
87
+ if prob is not None:
88
+ return EdgePart(PART_PROB, prob)
89
+ if quasiprob is not None:
90
+ return EdgePart(PART_QUASIPROB, quasiprob)
91
+ return None
92
+
93
+
94
+ def edge_spec(model: StateMachine, transition: Transition) -> EdgeSpec:
95
+ """Decompose ``transition`` for ``model`` into a backend-agnostic spec."""
96
+ from sofic.automata.base import LabeledAutomaton
97
+ from sofic.automata.transducers import MooreMachine, Transducer
98
+ from sofic.automata.vpa import VisiblyPushdownAutomaton
99
+ from sofic.generators.base import QuasiStochasticModel, StochasticModel
100
+ from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
101
+ from sofic.generators.mealy import MealyHMM
102
+ from sofic.generators.mixed_state import MixedStatePresentation
103
+ from sofic.generators.moore import MooreHMM
104
+ from sofic.generators.nmachine import NMachine
105
+ from sofic.shifts.base import SymbolicModel
106
+ from sofic.shifts.sofic_dyck import SoficDyckShift, transition_ref
107
+ from sofic.shifts.tmc import TopologicalMarkovChain
108
+
109
+ data = transition.data
110
+ symbol = data.get(ATTR_SYMBOL)
111
+ emission = data.get(ATTR_EMISSION)
112
+ prob = data.get(ATTR_PROB)
113
+ quasiprob = data.get(ATTR_QUASIPROB)
114
+
115
+ if isinstance(model, LabeledAutomaton):
116
+ sym = symbol if symbol is not None else EPSILON
117
+ return EdgeSpec((EdgePart(PART_SYMBOL, sym),), STYLE_SYMBOL_ONLY)
118
+
119
+ if isinstance(model, Transducer):
120
+ if isinstance(model, MooreMachine):
121
+ parts = (EdgePart(PART_SYMBOL, symbol),) if symbol is not None else ()
122
+ return EdgeSpec(parts, STYLE_SYMBOL_ONLY)
123
+ parts_list: list[EdgePart] = []
124
+ if symbol is not None:
125
+ parts_list.append(EdgePart(PART_SYMBOL, symbol))
126
+ output = data.get(ATTR_OUTPUT)
127
+ if output is not None:
128
+ parts_list.append(EdgePart(PART_OUTPUT, output))
129
+ return EdgeSpec(tuple(parts_list), STYLE_TRANSDUCER)
130
+
131
+ if isinstance(model, VisiblyPushdownAutomaton):
132
+ parts_list = []
133
+ if symbol is not None:
134
+ parts_list.append(EdgePart(PART_SYMBOL, symbol))
135
+ kind = data.get(ATTR_KIND)
136
+ if kind is not None:
137
+ parts_list.append(EdgePart(PART_KIND, kind))
138
+ stack = data.get(ATTR_STACK_SYMBOL)
139
+ if stack is not None:
140
+ parts_list.append(EdgePart(PART_STACK, stack))
141
+ return EdgeSpec(tuple(parts_list), STYLE_VPA)
142
+
143
+ if isinstance(model, NMachine):
144
+ parts_list = []
145
+ if emission is not None:
146
+ parts_list.append(EdgePart(PART_EMISSION, emission))
147
+ if quasiprob is not None:
148
+ parts_list.append(EdgePart(PART_QUASIPROB, quasiprob))
149
+ return EdgeSpec(tuple(parts_list), STYLE_EDGE)
150
+
151
+ if isinstance(model, MooreHMM):
152
+ parts = (EdgePart(PART_PROB, prob),) if prob is not None else ()
153
+ return EdgeSpec(parts, STYLE_PROB_ONLY)
154
+
155
+ if isinstance(model, (BidirectionalEpsilonMachine, MixedStatePresentation, MealyHMM, StochasticModel)):
156
+ parts_list = []
157
+ primary = _primary_symbol_part(emission, symbol, symbol_fallback=True)
158
+ if primary is not None:
159
+ parts_list.append(primary)
160
+ prob_part = _prob_part(prob, None)
161
+ if prob_part is not None:
162
+ parts_list.append(prob_part)
163
+ return EdgeSpec(tuple(parts_list), STYLE_EDGE)
164
+
165
+ if isinstance(model, QuasiStochasticModel):
166
+ parts_list = []
167
+ if emission is not None:
168
+ parts_list.append(EdgePart(PART_EMISSION, emission))
169
+ if quasiprob is not None:
170
+ parts_list.append(EdgePart(PART_QUASIPROB, quasiprob))
171
+ return EdgeSpec(tuple(parts_list), STYLE_EDGE)
172
+
173
+ if isinstance(model, SoficDyckShift):
174
+ parts_list = []
175
+ if symbol is not None:
176
+ parts_list.append(EdgePart(PART_SYMBOL, symbol))
177
+ kind = data.get(ATTR_KIND)
178
+ if kind is not None:
179
+ parts_list.append(EdgePart(PART_KIND, kind))
180
+ match_tags = _dyck_match_tags(model.matched_edges)
181
+ for tag in match_tags.get(transition_ref(transition), ()):
182
+ parts_list.append(EdgePart(PART_MATCH_TAG, tag))
183
+ return EdgeSpec(tuple(parts_list), STYLE_DYCK)
184
+
185
+ if isinstance(model, TopologicalMarkovChain):
186
+ parts_list = []
187
+ if symbol is not None:
188
+ parts_list.append(EdgePart(PART_SYMBOL, symbol))
189
+ mult = data.get(ATTR_MULTIPLICITY)
190
+ if mult is not None and mult != 1:
191
+ parts_list.append(EdgePart(PART_MULTIPLICITY, mult))
192
+ return EdgeSpec(tuple(parts_list), STYLE_TMC)
193
+
194
+ if isinstance(model, SymbolicModel):
195
+ parts = (EdgePart(PART_SYMBOL, symbol),) if symbol is not None else ()
196
+ return EdgeSpec(parts, STYLE_SYMBOL_ONLY)
197
+
198
+ parts_list = []
199
+ if symbol is not None:
200
+ parts_list.append(EdgePart(PART_SYMBOL, symbol))
201
+ if emission is not None:
202
+ parts_list.append(EdgePart(PART_EMISSION, emission))
203
+ if prob is not None:
204
+ parts_list.append(EdgePart(PART_PROB, prob))
205
+ if quasiprob is not None:
206
+ parts_list.append(EdgePart(PART_QUASIPROB, quasiprob))
207
+ return EdgeSpec(tuple(parts_list), STYLE_FALLBACK)
208
+
209
+
210
+ def _dyck_match_tags(matched_edges: Any) -> dict[Any, tuple[str, ...]]:
211
+ tags: dict[Any, list[str]] = {}
212
+ for index, (call_ref, return_ref) in enumerate(sorted(matched_edges, key=repr), start=1):
213
+ tag = f"m{index}"
214
+ tags.setdefault(call_ref, []).append(tag)
215
+ tags.setdefault(return_ref, []).append(tag)
216
+ return {ref: tuple(ref_tags) for ref, ref_tags in tags.items()}
sofic/viz/_format.py ADDED
@@ -0,0 +1,89 @@
1
+ """Label formatting helpers for Graphviz output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from sofic.viz import _labels
9
+ from sofic.viz._rational import two_digit_rational
10
+
11
+
12
+ def dot_escape(text: str) -> str:
13
+ """Escape a string for use inside a Graphviz double-quoted label."""
14
+ return (
15
+ text.replace("\\", "\\\\")
16
+ .replace('"', '\\"')
17
+ .replace("\n", "\\n")
18
+ .replace("<", "&lt;")
19
+ .replace(">", "&gt;")
20
+ .replace("{", "\\{")
21
+ .replace("}", "\\}")
22
+ )
23
+
24
+
25
+ def format_state(state: Any) -> str:
26
+ return _labels.format_state(state, escape=dot_escape, epsilon="ε")
27
+
28
+
29
+ def format_symbol(symbol: Any) -> str:
30
+ return _labels.format_symbol(symbol, escape=dot_escape, epsilon="ε")
31
+
32
+
33
+ def format_prob(value: float, *, precision: int = 3) -> str:
34
+ return dot_escape(f"{value:.{precision}g}")
35
+
36
+
37
+ def format_prob_rational(value: float, *, precision: int = 3) -> str:
38
+ """Format a probability as p/q when exact with two-digit numerator and denominator."""
39
+ if value <= 0.0:
40
+ return "0"
41
+ if value >= 1.0:
42
+ return "1"
43
+ frac = two_digit_rational(value)
44
+ if frac is not None:
45
+ if frac.numerator == frac.denominator:
46
+ return "1"
47
+ return dot_escape(f"{frac.numerator}/{frac.denominator}")
48
+ return format_prob(value, precision=precision)
49
+
50
+
51
+ def format_prob_label(value: Any, *, precision: int = 3) -> str:
52
+ """Format a probability for Graphviz edge/π labels (float or sympy Expr)."""
53
+ try:
54
+ from sofic.generators.prob import is_symbolic, simplify_prob
55
+ except ImportError: # pragma: no cover
56
+
57
+ def is_symbolic(_v: Any) -> bool:
58
+ return False
59
+
60
+ def simplify_prob(v: Any) -> Any:
61
+ return v
62
+
63
+ if is_symbolic(value):
64
+ simplified = simplify_prob(value)
65
+ try:
66
+ import sympy as sp
67
+
68
+ text = sp.sstr(simplified)
69
+ except Exception:
70
+ text = str(simplified)
71
+ return dot_escape(text)
72
+ return format_prob_rational(float(value), precision=precision)
73
+
74
+
75
+ def format_distribution(dist: Mapping[Any, Any], *, precision: int = 3) -> str:
76
+ parts = [
77
+ f"{format_symbol(symbol)}:{format_prob_label(prob, precision=precision)}"
78
+ for symbol, prob in sorted(dist.items(), key=str)
79
+ ]
80
+ return ", ".join(parts)
81
+
82
+
83
+ def format_belief(belief: Sequence[Any], *, rational: bool = True) -> str:
84
+ """Compact simplex label for a mixed-state belief vector."""
85
+ if rational:
86
+ parts = [format_prob_label(value) for value in belief]
87
+ else:
88
+ parts = [format_prob(float(value)) for value in belief]
89
+ return f"μ=({', '.join(parts)})"
sofic/viz/_labels.py ADDED
@@ -0,0 +1,34 @@
1
+ """Backend-agnostic skeletons for state/symbol label formatting.
2
+
3
+ The graphviz and LaTeX backends share the same structural recursion for
4
+ formatting states (tuples recurse, frozensets render as sorted ``{...}``,
5
+ ``EPSILON`` becomes a glyph) and symbols; they differ only in the leaf escape
6
+ function and the epsilon glyph. These helpers capture that shared shape.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from sofic.graph import EPSILON
15
+
16
+
17
+ def format_state(state: Any, *, escape: Callable[[str], str], epsilon: str) -> str:
18
+ """Format a (possibly nested) state using ``escape`` for leaves."""
19
+ if isinstance(state, tuple):
20
+ inner = ", ".join(format_state(part, escape=escape, epsilon=epsilon) for part in state)
21
+ return f"({inner})"
22
+ if isinstance(state, frozenset):
23
+ inner = ", ".join(sorted(format_state(part, escape=escape, epsilon=epsilon) for part in state))
24
+ return rf"\{{{inner}\}}"
25
+ if state is EPSILON:
26
+ return epsilon
27
+ return escape(str(state))
28
+
29
+
30
+ def format_symbol(symbol: Any, *, escape: Callable[[str], str], epsilon: str) -> str:
31
+ """Format a symbol using ``escape`` for the leaf, ``epsilon`` for EPSILON."""
32
+ if symbol is EPSILON:
33
+ return epsilon
34
+ return escape(str(symbol))
sofic/viz/_names.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shared node-identifier helper for viz backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Hashable
7
+
8
+
9
+ def node_name(state: Hashable) -> str:
10
+ """Return a Graphviz-compatible node identifier for ``state``."""
11
+ text = repr(state)
12
+ ident = re.sub(r"[^A-Za-z0-9_]+", "_", text).strip("_")
13
+ if not ident:
14
+ ident = "state"
15
+ if ident[0].isdigit():
16
+ ident = f"s_{ident}"
17
+ return ident