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/_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}