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.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- 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("<", "<")
|
|
19
|
+
.replace(">", ">")
|
|
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
|