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/_rational.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared rational-number helper for viz label formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fractions import Fraction
|
|
6
|
+
|
|
7
|
+
_TWO_DIGIT_RATIONAL_ATOL = 1e-9
|
|
8
|
+
_MAX_TWO_DIGIT_RATIONAL = 99
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def two_digit_rational(value: float, *, atol: float = _TWO_DIGIT_RATIONAL_ATOL) -> Fraction | None:
|
|
12
|
+
"""Return a reduced rational with 1 <= p, q <= 99 when ``value`` matches exactly."""
|
|
13
|
+
if value <= 0.0 or value >= 1.0:
|
|
14
|
+
return None
|
|
15
|
+
frac = Fraction(value).limit_denominator(_MAX_TWO_DIGIT_RATIONAL)
|
|
16
|
+
if abs(float(frac) - value) >= atol:
|
|
17
|
+
return None
|
|
18
|
+
if not (1 <= frac.numerator <= _MAX_TWO_DIGIT_RATIONAL and 1 <= frac.denominator <= _MAX_TWO_DIGIT_RATIONAL):
|
|
19
|
+
return None
|
|
20
|
+
return frac
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Compile Vaucanson TikZ fragments to PDF/PNG/SVG via ``pdflatex``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_VAUCANSON_ASSET = Path(__file__).resolve().parent / "assets" / "vaucanson.tikz"
|
|
12
|
+
|
|
13
|
+
# GUI-launched notebooks often omit MacTeX / Homebrew from PATH.
|
|
14
|
+
_TOOL_SEARCH_DIRS = (
|
|
15
|
+
Path("/Library/TeX/texbin"),
|
|
16
|
+
Path("/usr/local/texlive/2026/bin/universal-darwin"),
|
|
17
|
+
Path("/usr/local/texlive/2025/bin/universal-darwin"),
|
|
18
|
+
Path("/usr/local/texlive/2024/bin/universal-darwin"),
|
|
19
|
+
Path("/opt/homebrew/bin"),
|
|
20
|
+
Path("/usr/local/bin"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TikzCompileError(RuntimeError):
|
|
25
|
+
"""Raised when LaTeX compilation or image conversion fails."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_executable(name: str) -> str | None:
|
|
29
|
+
"""Locate a CLI tool on PATH or in common TeX/Homebrew install dirs."""
|
|
30
|
+
found = shutil.which(name)
|
|
31
|
+
if found is not None:
|
|
32
|
+
return found
|
|
33
|
+
for directory in _TOOL_SEARCH_DIRS:
|
|
34
|
+
candidate = directory / name
|
|
35
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
36
|
+
return str(candidate)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_pdflatex() -> str:
|
|
41
|
+
path = find_executable("pdflatex")
|
|
42
|
+
if path is None:
|
|
43
|
+
raise TikzCompileError(
|
|
44
|
+
"TikZ image export requires pdflatex (e.g. MacTeX or TeX Live). "
|
|
45
|
+
"Install TeX and ensure pdflatex is on PATH, typically "
|
|
46
|
+
"/Library/TeX/texbin on macOS."
|
|
47
|
+
)
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compilation_document(body: str) -> str:
|
|
52
|
+
"""Wrap a ``tikzpicture`` fragment in a standalone LaTeX document."""
|
|
53
|
+
return (
|
|
54
|
+
r"\documentclass[tikz,border=2pt]{standalone}"
|
|
55
|
+
"\n"
|
|
56
|
+
r"\usepackage{amsmath}"
|
|
57
|
+
"\n"
|
|
58
|
+
r"\usepackage{xcolor}"
|
|
59
|
+
"\n"
|
|
60
|
+
r"\pagecolor{white}"
|
|
61
|
+
"\n"
|
|
62
|
+
r"\definecolor{honeydew}{RGB}{240,255,240}"
|
|
63
|
+
"\n"
|
|
64
|
+
r"\definecolor{mistyrose}{RGB}{255,228,225}"
|
|
65
|
+
"\n"
|
|
66
|
+
r"\usepackage{nicefrac}"
|
|
67
|
+
"\n"
|
|
68
|
+
r"\providecommand{\half}{\nicefrac{1}{2}}"
|
|
69
|
+
"\n"
|
|
70
|
+
r"\usetikzlibrary{automata,positioning,arrows.meta}"
|
|
71
|
+
"\n"
|
|
72
|
+
r"\input{vaucanson.tikz}"
|
|
73
|
+
"\n"
|
|
74
|
+
r"\begin{document}"
|
|
75
|
+
"\n"
|
|
76
|
+
f"{body}\n"
|
|
77
|
+
r"\end{document}"
|
|
78
|
+
"\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compile_tikz_fragment(fragment: str, *, format: str = "png") -> bytes:
|
|
83
|
+
"""Compile a ``tikzpicture`` fragment and return raster/vector bytes."""
|
|
84
|
+
normalized = format.lower().lstrip(".")
|
|
85
|
+
if normalized not in {"pdf", "png", "svg"}:
|
|
86
|
+
raise ValueError(f"unsupported compile format {format!r}; expected pdf, png, or svg")
|
|
87
|
+
|
|
88
|
+
pdflatex = _require_pdflatex()
|
|
89
|
+
with tempfile.TemporaryDirectory(prefix="sofic-tikz-") as tmp:
|
|
90
|
+
workdir = Path(tmp)
|
|
91
|
+
shutil.copy2(_VAUCANSON_ASSET, workdir / "vaucanson.tikz")
|
|
92
|
+
tex_path = workdir / "sofic_tikz.tex"
|
|
93
|
+
tex_path.write_text(compilation_document(fragment), encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
[pdflatex, "-halt-on-error", "-interaction=nonstopmode", tex_path.name],
|
|
97
|
+
cwd=workdir,
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
check=False,
|
|
101
|
+
)
|
|
102
|
+
pdf_path = workdir / "sofic_tikz.pdf"
|
|
103
|
+
if result.returncode != 0 or not pdf_path.is_file():
|
|
104
|
+
log_tail = (workdir / "sofic_tikz.log").read_text(encoding="utf-8", errors="replace")
|
|
105
|
+
raise TikzCompileError("pdflatex failed to compile TikZ figure.\n" + log_tail[-4000:])
|
|
106
|
+
|
|
107
|
+
if normalized == "pdf":
|
|
108
|
+
return pdf_path.read_bytes()
|
|
109
|
+
if normalized == "png":
|
|
110
|
+
return _pdf_to_png(pdf_path, workdir / "sofic_tikz.png")
|
|
111
|
+
return _pdf_to_svg(pdf_path, workdir / "sofic_tikz.svg")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _pdf_to_png(pdf_path: Path, png_path: Path) -> bytes:
|
|
115
|
+
pdftocairo = find_executable("pdftocairo")
|
|
116
|
+
if pdftocairo is not None:
|
|
117
|
+
subprocess.run(
|
|
118
|
+
[pdftocairo, "-png", "-singlefile", str(pdf_path), str(png_path.with_suffix(""))],
|
|
119
|
+
check=True,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
)
|
|
122
|
+
if png_path.is_file():
|
|
123
|
+
return png_path.read_bytes()
|
|
124
|
+
|
|
125
|
+
gs = find_executable("gs")
|
|
126
|
+
if gs is not None:
|
|
127
|
+
subprocess.run(
|
|
128
|
+
[
|
|
129
|
+
gs,
|
|
130
|
+
"-dNOPAUSE",
|
|
131
|
+
"-dBATCH",
|
|
132
|
+
# Opaque RGB (not pngalpha) so the page background is white.
|
|
133
|
+
"-sDEVICE=png16m",
|
|
134
|
+
"-dGraphicsAlphaBits=4",
|
|
135
|
+
"-dTextAlphaBits=4",
|
|
136
|
+
"-r200",
|
|
137
|
+
"-dFirstPage=1",
|
|
138
|
+
"-dLastPage=1",
|
|
139
|
+
f"-sOutputFile={png_path}",
|
|
140
|
+
str(pdf_path),
|
|
141
|
+
],
|
|
142
|
+
check=True,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
)
|
|
145
|
+
if png_path.is_file():
|
|
146
|
+
return png_path.read_bytes()
|
|
147
|
+
|
|
148
|
+
if os.uname().sysname == "Darwin":
|
|
149
|
+
sips = find_executable("sips")
|
|
150
|
+
if sips is not None:
|
|
151
|
+
subprocess.run([sips, "-s", "format", "png", str(pdf_path), "--out", str(png_path)], check=True)
|
|
152
|
+
if png_path.is_file():
|
|
153
|
+
return png_path.read_bytes()
|
|
154
|
+
|
|
155
|
+
raise TikzCompileError(
|
|
156
|
+
"compiled PDF but could not convert to PNG; install poppler (pdftocairo) or ghostscript (gs)"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pdf_to_svg(pdf_path: Path, svg_path: Path) -> bytes:
|
|
161
|
+
pdftocairo = find_executable("pdftocairo")
|
|
162
|
+
if pdftocairo is not None:
|
|
163
|
+
subprocess.run(
|
|
164
|
+
[pdftocairo, "-svg", str(pdf_path), str(svg_path)],
|
|
165
|
+
check=True,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
)
|
|
168
|
+
if svg_path.is_file():
|
|
169
|
+
return svg_path.read_bytes()
|
|
170
|
+
|
|
171
|
+
pdf2svg = find_executable("pdf2svg")
|
|
172
|
+
if pdf2svg is not None:
|
|
173
|
+
subprocess.run([pdf2svg, str(pdf_path), str(svg_path)], check=True, capture_output=True)
|
|
174
|
+
if svg_path.is_file():
|
|
175
|
+
return svg_path.read_bytes()
|
|
176
|
+
|
|
177
|
+
raise TikzCompileError("compiled PDF but could not convert to SVG; install poppler (pdftocairo) or pdf2svg")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""LaTeX / TikZ label formatting for Vaucanson-style machine figures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sofic.viz import _labels
|
|
9
|
+
from sofic.viz._rational import _TWO_DIGIT_RATIONAL_ATOL, two_digit_rational
|
|
10
|
+
|
|
11
|
+
_LATEX_SPECIAL = {
|
|
12
|
+
"\\": r"\textbackslash{}",
|
|
13
|
+
"{": r"\{",
|
|
14
|
+
"}": r"\}",
|
|
15
|
+
"#": r"\#",
|
|
16
|
+
"$": r"\$",
|
|
17
|
+
"%": r"\%",
|
|
18
|
+
"&": r"\&",
|
|
19
|
+
"_": r"\_",
|
|
20
|
+
"^": r"\^{}",
|
|
21
|
+
"~": r"\textasciitilde{}",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def latex_escape(text: str) -> str:
|
|
26
|
+
"""Escape plain text for use outside math mode in TikZ node labels."""
|
|
27
|
+
escaped = []
|
|
28
|
+
for char in text:
|
|
29
|
+
escaped.append(_LATEX_SPECIAL.get(char, char))
|
|
30
|
+
return "".join(escaped)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _latex_arg(text: str) -> str:
|
|
34
|
+
"""Escape content for a LaTeX macro argument (no math mode)."""
|
|
35
|
+
if text == "":
|
|
36
|
+
return "{}"
|
|
37
|
+
if any(char in text for char in "{}\\#%&_^~$"):
|
|
38
|
+
return f"{{{latex_escape(text)}}}"
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_state_latex(state: Any) -> str:
|
|
43
|
+
return _labels.format_state(state, escape=latex_escape, epsilon=r"\varepsilon")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_state_tikz_node(state: Any) -> str:
|
|
47
|
+
"""TikZ-safe state label (commas in joint states must not parse as options)."""
|
|
48
|
+
label = format_state_latex(state)
|
|
49
|
+
return label.replace(",", "{,}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_belief_tikz_node(belief: Sequence[Any]) -> str:
|
|
53
|
+
"""LaTeX-safe belief simplex label for TikZ node text (μ implied)."""
|
|
54
|
+
parts = [format_prob_latex(value) for value in belief]
|
|
55
|
+
return rf"$\scriptstyle({', '.join(parts)})$"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_symbol_latex(symbol: Any) -> str:
|
|
59
|
+
return _labels.format_symbol(symbol, escape=_latex_arg, epsilon=r"\varepsilon")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_prob_latex(value: Any, *, precision: int = 3) -> str:
|
|
63
|
+
"""Format a probability for Vaucanson edge labels (float or sympy Expr)."""
|
|
64
|
+
try:
|
|
65
|
+
from sofic.generators.prob import is_symbolic, simplify_prob
|
|
66
|
+
except ImportError: # pragma: no cover
|
|
67
|
+
is_symbolic = lambda _v: False # noqa: E731
|
|
68
|
+
simplify_prob = lambda v: v # noqa: E731
|
|
69
|
+
|
|
70
|
+
if is_symbolic(value):
|
|
71
|
+
simplified = simplify_prob(value)
|
|
72
|
+
try:
|
|
73
|
+
import sympy as sp
|
|
74
|
+
|
|
75
|
+
return latex_escape(sp.latex(simplified))
|
|
76
|
+
except Exception:
|
|
77
|
+
return latex_escape(str(simplified))
|
|
78
|
+
|
|
79
|
+
numeric = float(value)
|
|
80
|
+
if numeric <= 0.0:
|
|
81
|
+
return "0"
|
|
82
|
+
if numeric >= 1.0:
|
|
83
|
+
return "1"
|
|
84
|
+
if abs(numeric - 0.5) < _TWO_DIGIT_RATIONAL_ATOL:
|
|
85
|
+
return r"\half"
|
|
86
|
+
frac = two_digit_rational(numeric)
|
|
87
|
+
if frac is not None:
|
|
88
|
+
if frac.numerator == frac.denominator:
|
|
89
|
+
return "1"
|
|
90
|
+
return rf"\nicefrac{{{frac.numerator}}}{{{frac.denominator}}}"
|
|
91
|
+
return latex_escape(f"{numeric:.{precision}g}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_symbol_macro(symbol: Any) -> str:
|
|
95
|
+
return rf"\Symbol{{{format_symbol_latex(symbol)}}}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_edge_latex(symbol: Any, prob: Any) -> str:
|
|
99
|
+
"""Return ``$\\Edge{sym}{prob}$`` math content."""
|
|
100
|
+
return rf"$\Edge{{{format_symbol_latex(symbol)}}}{{{format_prob_latex(prob)}}}$"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_tedge_latex(forward: Any, reverse: Any, prob: Any) -> str:
|
|
104
|
+
"""Return ``$\\TEdge{f}{r}{prob}$`` math content."""
|
|
105
|
+
return (
|
|
106
|
+
rf"$\TEdge{{{format_symbol_latex(forward)}}}"
|
|
107
|
+
rf"{{{format_symbol_latex(reverse)}}}"
|
|
108
|
+
rf"{{{format_prob_latex(prob)}}}$"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def format_symbol_only_latex(symbol: Any) -> str:
|
|
113
|
+
return rf"$\Symbol{{{format_symbol_latex(symbol)}}}$"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_transducer_edge_latex(input_symbol: Any, output_symbol: Any | None = None) -> str:
|
|
117
|
+
if output_symbol is None:
|
|
118
|
+
return format_symbol_only_latex(input_symbol)
|
|
119
|
+
return (
|
|
120
|
+
rf"$\Symbol{{{format_symbol_latex(input_symbol)}}}"
|
|
121
|
+
rf"\mid\Symbol{{{format_symbol_latex(output_symbol)}}}$"
|
|
122
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Layout helpers for TikZ machine figures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Hashable, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sofic.base import StateMachine
|
|
12
|
+
from sofic.viz._names import node_name
|
|
13
|
+
|
|
14
|
+
_LOOP_STYLES = ("loop above", "loop right", "loop below", "loop left")
|
|
15
|
+
_BEND_STYLES = ("bend left", "bend right")
|
|
16
|
+
_LOOP_STYLE_ANGLES = {
|
|
17
|
+
"loop above": 90.0,
|
|
18
|
+
"loop right": 0.0,
|
|
19
|
+
"loop below": -90.0,
|
|
20
|
+
"loop left": 180.0,
|
|
21
|
+
}
|
|
22
|
+
_LOOP_CONFLICT_RADIUS = 55.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def layout_circle(
|
|
26
|
+
model: StateMachine,
|
|
27
|
+
*,
|
|
28
|
+
radius: str = "2cm",
|
|
29
|
+
positions: Mapping[Hashable, tuple[float, float]] | None = None,
|
|
30
|
+
angles: Mapping[Hashable, float] | None = None,
|
|
31
|
+
) -> dict[Hashable, str]:
|
|
32
|
+
"""Return TikZ ``at (...)`` clauses for each state on a circle."""
|
|
33
|
+
states = sorted(model.states(), key=repr)
|
|
34
|
+
if not states:
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
radius_value, radius_unit = _split_dim(radius)
|
|
38
|
+
coords: dict[Hashable, str] = {}
|
|
39
|
+
|
|
40
|
+
if positions is not None:
|
|
41
|
+
for state in states:
|
|
42
|
+
if state not in positions:
|
|
43
|
+
raise ValueError(f"missing position override for state {state!r}")
|
|
44
|
+
x, y = positions[state]
|
|
45
|
+
coords[state] = f"at ({x}{radius_unit}, {y}{radius_unit})"
|
|
46
|
+
return coords
|
|
47
|
+
|
|
48
|
+
count = len(states)
|
|
49
|
+
for index, state in enumerate(states):
|
|
50
|
+
angle = angles[state] if angles is not None and state in angles else 90.0 - index * (360.0 / count)
|
|
51
|
+
coords[state] = f"at ({angle:.4g}:{radius_value}{radius_unit})"
|
|
52
|
+
return coords
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _split_dim(value: str) -> tuple[str, str]:
|
|
56
|
+
match = re.fullmatch(r"([0-9.]+)([a-zA-Z]+)", value.strip())
|
|
57
|
+
if match is None:
|
|
58
|
+
raise ValueError(f"expected dimension like '2cm', got {value!r}")
|
|
59
|
+
return match.group(1), match.group(2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def layout_graphviz(
|
|
63
|
+
model: StateMachine,
|
|
64
|
+
*,
|
|
65
|
+
style: str = "auto",
|
|
66
|
+
rankdir: str | None = None,
|
|
67
|
+
) -> dict[Hashable, str]:
|
|
68
|
+
"""Return TikZ ``at (...)`` clauses using Graphviz node positions."""
|
|
69
|
+
from sofic.viz.graphviz import model_to_graphviz
|
|
70
|
+
|
|
71
|
+
dot = model_to_graphviz(model, style=style, rankdir=rankdir)
|
|
72
|
+
plain = dot.pipe(format="plain").decode("utf-8")
|
|
73
|
+
positions = _parse_plain_positions(plain)
|
|
74
|
+
coords: dict[Hashable, str] = {}
|
|
75
|
+
for state in model.states():
|
|
76
|
+
node = node_name(state)
|
|
77
|
+
if node not in positions:
|
|
78
|
+
raise RuntimeError(f"graphviz layout missing position for node {node!r}")
|
|
79
|
+
x_cm, y_cm = positions[node]
|
|
80
|
+
coords[state] = f"at ({x_cm:.4f}cm, {y_cm:.4f}cm)"
|
|
81
|
+
return coords
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_plain_positions(plain: str) -> dict[str, tuple[float, float]]:
|
|
85
|
+
"""Parse Graphviz plain format node lines into cm coordinates.
|
|
86
|
+
|
|
87
|
+
Graphviz ``plain`` node positions are in **inches** (same unit as the graph
|
|
88
|
+
width/height on the ``graph`` header line), not PostScript points.
|
|
89
|
+
"""
|
|
90
|
+
inch_to_cm = 2.54
|
|
91
|
+
positions: dict[str, tuple[float, float]] = {}
|
|
92
|
+
for line in plain.splitlines():
|
|
93
|
+
if not line.startswith("node "):
|
|
94
|
+
continue
|
|
95
|
+
parts = line.split()
|
|
96
|
+
if len(parts) < 4:
|
|
97
|
+
continue
|
|
98
|
+
name = parts[1].strip('"')
|
|
99
|
+
x_cm = float(parts[2]) * inch_to_cm
|
|
100
|
+
y_cm = float(parts[3]) * inch_to_cm
|
|
101
|
+
positions[name] = (x_cm, y_cm)
|
|
102
|
+
return positions
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def placement_to_xy(placement: str) -> tuple[float, float]:
|
|
106
|
+
"""Convert a TikZ ``at (...)`` clause to ``(x, y)`` in cm (0° = east)."""
|
|
107
|
+
inner = placement.removeprefix("at (").removesuffix(")")
|
|
108
|
+
polar = re.fullmatch(r"([-\d.]+):([-\d.]+)(cm)", inner)
|
|
109
|
+
if polar is not None:
|
|
110
|
+
angle_deg = float(polar.group(1))
|
|
111
|
+
radius = float(polar.group(2))
|
|
112
|
+
rad = math.radians(angle_deg)
|
|
113
|
+
return radius * math.cos(rad), radius * math.sin(rad)
|
|
114
|
+
cart = re.fullmatch(r"([-\d.]+)cm, ([-\d.]+)cm", inner)
|
|
115
|
+
if cart is not None:
|
|
116
|
+
return float(cart.group(1)), float(cart.group(2))
|
|
117
|
+
raise ValueError(f"unsupported placement {placement!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _angle_diff_deg(a: float, b: float) -> float:
|
|
121
|
+
return abs((a - b + 180.0) % 360.0 - 180.0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _outgoing_bearings(
|
|
125
|
+
source: Hashable,
|
|
126
|
+
positions: Mapping[Hashable, tuple[float, float]],
|
|
127
|
+
grouped: Mapping[tuple[Hashable, Hashable], list[Any]],
|
|
128
|
+
) -> list[float]:
|
|
129
|
+
"""Bearings at ``source`` that outgoing edges already occupy."""
|
|
130
|
+
bearings: list[float] = []
|
|
131
|
+
sx, sy = positions[source]
|
|
132
|
+
for (src, target), _group in grouped.items():
|
|
133
|
+
if src != source or target == source:
|
|
134
|
+
continue
|
|
135
|
+
if target not in positions:
|
|
136
|
+
continue
|
|
137
|
+
tx, ty = positions[target]
|
|
138
|
+
angle = math.degrees(math.atan2(ty - sy, tx - sx))
|
|
139
|
+
bearings.append(angle)
|
|
140
|
+
if (target, source) in grouped:
|
|
141
|
+
bearings.append(angle + 90.0)
|
|
142
|
+
return bearings
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pick_loop_style(
|
|
146
|
+
bearings: list[float],
|
|
147
|
+
used: list[str],
|
|
148
|
+
*,
|
|
149
|
+
preferred_order: tuple[str, ...] = _LOOP_STYLES,
|
|
150
|
+
) -> str:
|
|
151
|
+
def score(style: str) -> float:
|
|
152
|
+
anchor = _LOOP_STYLE_ANGLES[style]
|
|
153
|
+
penalty = 1000.0 if style in used else 0.0
|
|
154
|
+
for bearing in bearings:
|
|
155
|
+
diff = _angle_diff_deg(anchor, bearing)
|
|
156
|
+
if diff < _LOOP_CONFLICT_RADIUS:
|
|
157
|
+
penalty += (_LOOP_CONFLICT_RADIUS - diff) ** 2
|
|
158
|
+
return penalty
|
|
159
|
+
|
|
160
|
+
return min(preferred_order, key=score)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def plan_loop_styles(
|
|
164
|
+
positions: Mapping[Hashable, tuple[float, float]],
|
|
165
|
+
grouped: Mapping[tuple[Hashable, Hashable], list[Any]],
|
|
166
|
+
) -> dict[tuple[Hashable, Hashable, int], str]:
|
|
167
|
+
"""Assign self-loop styles that avoid outgoing-edge corridors."""
|
|
168
|
+
styles: dict[tuple[Hashable, Hashable, int], str] = {}
|
|
169
|
+
loops_by_state: dict[Hashable, list[tuple[Hashable, Hashable, int]]] = defaultdict(list)
|
|
170
|
+
for key, group in grouped.items():
|
|
171
|
+
source, target = key
|
|
172
|
+
if source != target:
|
|
173
|
+
continue
|
|
174
|
+
for index in range(len(group)):
|
|
175
|
+
loops_by_state[source].append((source, target, index))
|
|
176
|
+
|
|
177
|
+
for source, loops in loops_by_state.items():
|
|
178
|
+
bearings = _outgoing_bearings(source, positions, grouped)
|
|
179
|
+
used: list[str] = []
|
|
180
|
+
for key in loops:
|
|
181
|
+
style = _pick_loop_style(bearings, used)
|
|
182
|
+
styles[key] = style
|
|
183
|
+
used.append(style)
|
|
184
|
+
return styles
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def edge_style(
|
|
188
|
+
source: Hashable,
|
|
189
|
+
target: Hashable,
|
|
190
|
+
*,
|
|
191
|
+
parallel_index: int,
|
|
192
|
+
total_parallel: int,
|
|
193
|
+
has_reverse: bool = False,
|
|
194
|
+
loop_style: str | None = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Choose bend/loop options for one transition."""
|
|
197
|
+
if source == target:
|
|
198
|
+
return loop_style or _LOOP_STYLES[parallel_index % len(_LOOP_STYLES)]
|
|
199
|
+
if has_reverse:
|
|
200
|
+
return _reciprocal_bend(parallel_index, total_parallel)
|
|
201
|
+
if total_parallel == 1:
|
|
202
|
+
return ""
|
|
203
|
+
if total_parallel == 2:
|
|
204
|
+
return _BEND_STYLES[parallel_index % 2]
|
|
205
|
+
base = 30 + 25 * parallel_index
|
|
206
|
+
return f"bend left, out={base}, in={180 - base}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _reciprocal_bend(parallel_index: int, total_parallel: int) -> str:
|
|
210
|
+
"""Bend both directions of a reciprocal pair the same way (cpfci convention)."""
|
|
211
|
+
if total_parallel == 1:
|
|
212
|
+
return "bend left"
|
|
213
|
+
if total_parallel == 2 and parallel_index == 0:
|
|
214
|
+
return "bend left, out=45, in=135"
|
|
215
|
+
if total_parallel == 2:
|
|
216
|
+
return "bend left"
|
|
217
|
+
base = 30 + 25 * parallel_index
|
|
218
|
+
return f"bend left, out={base}, in={180 - base}"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
\tikzstyle{vaucanson}=[
|
|
2
|
+
%node distance=1.25cm,
|
|
3
|
+
node distance=3cm,
|
|
4
|
+
bend angle=15,
|
|
5
|
+
auto,
|
|
6
|
+
% For some reason, the loop direction cannot be overwritten if we set the
|
|
7
|
+
% style for "every loop" to be "->". Curiously, this doesn't seem to be
|
|
8
|
+
% an issue for "every edge".
|
|
9
|
+
every loop/.style={},
|
|
10
|
+
every edge/.style={->,draw=black,line width=1.2,>=latex,shorten <=1pt, shorten >=1pt},
|
|
11
|
+
every state/.style={draw=black,line width=2,font=\large},
|
|
12
|
+
loop right/.style={right,out=22,in=-22,loop},
|
|
13
|
+
loop above/.style={above,out=112,in=68,loop},
|
|
14
|
+
loop left/.style={left,out=202,in=158,loop},
|
|
15
|
+
loop below/.style={below,out=292,in=248,loop},
|
|
16
|
+
loop above right/.style={right,out=67,in=23,loop},
|
|
17
|
+
loop above left/.style={left,out=157,in=113,loop},
|
|
18
|
+
loop below left/.style={left,out=247,in=203,loop},
|
|
19
|
+
loop below right/.style={right,out=337,in=293,loop},
|
|
20
|
+
binode/.style={minimum size=1cm,inner sep=0pt},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
\tikzset{
|
|
24
|
+
mystate/.style={circle,draw,fill=black}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
%%% Command for bicausal nodes. This only supports relative positioning.
|
|
28
|
+
%%% Use as: \binode[state][red,blue] (AC) [left=of someNode] {$\A:\:C$};
|
|
29
|
+
\makeatletter
|
|
30
|
+
\newcommand{\binode}[1][state]{%
|
|
31
|
+
\@ifnextchar[{\binode@i[{#1}]}{\binode@i[{#1}][{yellow},{gray!70}]}%
|
|
32
|
+
}
|
|
33
|
+
\def\binode@i[#1][#2,#3]{%
|
|
34
|
+
\@ifnextchar({\binode@ii[{#1}][{#2},{#3}]}{\binode@ii[{#1}][{#2},{#3}]({})}%
|
|
35
|
+
}
|
|
36
|
+
\def\binode@ii[#1][#2,#3](#4){%
|
|
37
|
+
\@ifnextchar[{\binode@iii[{#1}][{#2},{#3}]({#4})}{\binode@iii[{#1}][{#2},{#3}]({#4})[{}]}%
|
|
38
|
+
}
|
|
39
|
+
\def\binode@iii[#1][#2,#3](#4)[#5]#6{%
|
|
40
|
+
|
|
41
|
+
\node[#1,binode] (#4) [#5] {#6};
|
|
42
|
+
\node[yshift=-10pt] (#4SS) at (#4.270) {};
|
|
43
|
+
\node[xshift=-10pt] (#4WW) at (#4.180) {};
|
|
44
|
+
\node[xshift=-10pt] (#4NN) at (#4.180) {};
|
|
45
|
+
\node[xshift=-10pt,yshift=10pt] (#4NW) at (#4.135) {};
|
|
46
|
+
% forward shading
|
|
47
|
+
\begin{scope}
|
|
48
|
+
\path[clip] (#4.255) -- +(-.8cm,0cm) -- +(-.8cm,1cm) -- (#4.75) -- cycle;
|
|
49
|
+
\node[#1,fill=#2,binode] (#4f) [#5] {#6};
|
|
50
|
+
\end{scope}
|
|
51
|
+
% reverse shading
|
|
52
|
+
\begin{scope}
|
|
53
|
+
\path[clip] (#4.75) -- +(.8cm,0cm) -- +(.8cm,-1cm) -- (#4.255) -- cycle;
|
|
54
|
+
% ++(0cm,-.2cm) -- ++(.8cm,0cm) -- ++(0cm,1cm) -- (#4.75) -- cycle;
|
|
55
|
+
\node[#1,fill=#3,binode] (#4r) [#5] {#6};
|
|
56
|
+
\end{scope}
|
|
57
|
+
\node {} % semicolon purposefully omitted. this is a dummy call
|
|
58
|
+
}
|
|
59
|
+
\makeatother
|
|
60
|
+
|
|
61
|
+
\providecommand{\Symbol}[1]{\textcolor{blue}{#1}}
|
|
62
|
+
\providecommand{\Edge}[2]{\Symbol{#1}\!:\!#2}
|
|
63
|
+
\providecommand{\TEdge}[3]{\Symbol{#1}|\Symbol{#2}\!:\!#3}
|
|
64
|
+
|
|
65
|
+
\definecolor{honeydew}{RGB}{240,255,240}
|
|
66
|
+
\definecolor{mistyrose}{RGB}{255,228,225}
|
|
67
|
+
\definecolor{FCSA}{RGB}{141,211,199}
|
|
68
|
+
\definecolor{FCSB}{RGB}{255,255,179}
|
|
69
|
+
\definecolor{RCSC}{RGB}{185,138,196}
|
|
70
|
+
\definecolor{RCSD}{RGB}{231,143,111}
|
|
71
|
+
\definecolor{RCSE}{RGB}{128,177,211}
|