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/graphviz.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Graphviz rendering for sofic state-machine models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from sofic.base import StateMachine
|
|
8
|
+
from sofic.viz._context import VizContext, viz_context
|
|
9
|
+
from sofic.viz._format import format_state
|
|
10
|
+
from sofic.viz._names import node_name as _node_name
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import graphviz
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require_graphviz() -> Any:
|
|
17
|
+
try:
|
|
18
|
+
import graphviz
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"Graphviz rendering requires the optional sofic[viz] extra "
|
|
22
|
+
"(pip install 'sofic[viz]') and the Graphviz system binaries."
|
|
23
|
+
) from exc
|
|
24
|
+
return graphviz
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _model_for_viz(model: StateMachine) -> StateMachine:
|
|
28
|
+
from sofic.generators.quasi_realization import QuasiRealization
|
|
29
|
+
|
|
30
|
+
if isinstance(model, QuasiRealization) and not any(model.states()):
|
|
31
|
+
return model.to_nmachine()
|
|
32
|
+
return model
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def model_to_graphviz(
|
|
36
|
+
model: StateMachine,
|
|
37
|
+
*,
|
|
38
|
+
rankdir: str | None = None,
|
|
39
|
+
style: str = "auto",
|
|
40
|
+
color_by_emission: bool = True,
|
|
41
|
+
graph_attr: dict[str, str] | None = None,
|
|
42
|
+
node_attr: dict[str, str] | None = None,
|
|
43
|
+
edge_attr: dict[str, str] | None = None,
|
|
44
|
+
) -> graphviz.Digraph:
|
|
45
|
+
"""Return a :class:`graphviz.Digraph` for ``model``.
|
|
46
|
+
|
|
47
|
+
Edges sharing an emission (or input/label symbol when the model has no
|
|
48
|
+
emissions) get a common colour from a categorical palette. Pass
|
|
49
|
+
``color_by_emission=False`` for uncoloured edges. Visibly pushdown / Dyck
|
|
50
|
+
kind colours (call / return / internal) take precedence.
|
|
51
|
+
"""
|
|
52
|
+
graphviz = _require_graphviz()
|
|
53
|
+
model = _model_for_viz(model)
|
|
54
|
+
context = viz_context(model, style=style, color_by_emission=color_by_emission)
|
|
55
|
+
|
|
56
|
+
resolved_rankdir = rankdir if rankdir is not None else (context.rankdir or "LR")
|
|
57
|
+
attrs = {
|
|
58
|
+
"rankdir": resolved_rankdir,
|
|
59
|
+
"fontsize": "12",
|
|
60
|
+
"fontname": "Helvetica",
|
|
61
|
+
"splines": "true",
|
|
62
|
+
"overlap": "false",
|
|
63
|
+
}
|
|
64
|
+
if context.graph_engine == "circo":
|
|
65
|
+
attrs["margin"] = "0.08"
|
|
66
|
+
if graph_attr:
|
|
67
|
+
attrs.update(graph_attr)
|
|
68
|
+
|
|
69
|
+
dot = graphviz.Digraph(
|
|
70
|
+
name=context.title,
|
|
71
|
+
graph_attr=attrs,
|
|
72
|
+
node_attr={
|
|
73
|
+
"fontname": "Helvetica",
|
|
74
|
+
"fontsize": "11",
|
|
75
|
+
"shape": "circle",
|
|
76
|
+
"style": "filled",
|
|
77
|
+
"fillcolor": "white",
|
|
78
|
+
**(node_attr or {}),
|
|
79
|
+
},
|
|
80
|
+
edge_attr={
|
|
81
|
+
"fontname": "Helvetica",
|
|
82
|
+
"fontsize": "10",
|
|
83
|
+
"arrowsize": "0.8",
|
|
84
|
+
**(edge_attr or {}),
|
|
85
|
+
},
|
|
86
|
+
engine=context.graph_engine,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
_add_states(dot, model, context)
|
|
90
|
+
_add_transitions(dot, model, context)
|
|
91
|
+
return dot
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _add_states(dot: graphviz.Digraph, model: StateMachine, context: VizContext) -> None:
|
|
95
|
+
for state in model.states():
|
|
96
|
+
dot.node(
|
|
97
|
+
_node_name(state),
|
|
98
|
+
label=context.state_labels.get(state, format_state(state)),
|
|
99
|
+
shape="doublecircle" if state in context.accepting_states else "circle",
|
|
100
|
+
peripheries="2" if state in context.accepting_states else "1",
|
|
101
|
+
penwidth="2.5" if context.highlight_initial_states and state in context.initial_states else "1.0",
|
|
102
|
+
fillcolor=context.node_fillcolor(state) or "white",
|
|
103
|
+
tooltip=context.state_tooltip(state, model.graph.state_attrs(state)),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if context.show_start_node and context.initial_states:
|
|
107
|
+
dot.node("__start__", label="", shape="point", width="0.12", height="0.12")
|
|
108
|
+
for state in sorted(context.initial_states, key=str):
|
|
109
|
+
dot.edge("__start__", _node_name(state))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _add_transitions(dot: graphviz.Digraph, model: StateMachine, context: VizContext) -> None:
|
|
113
|
+
for transition in model.transitions():
|
|
114
|
+
label = context.edge_label(transition)
|
|
115
|
+
attrs: dict[str, str] = {}
|
|
116
|
+
if label:
|
|
117
|
+
attrs["label"] = label
|
|
118
|
+
color = context.edge_color(transition)
|
|
119
|
+
if color:
|
|
120
|
+
attrs["color"] = color
|
|
121
|
+
style = context.edge_style(transition)
|
|
122
|
+
if style:
|
|
123
|
+
attrs["style"] = style
|
|
124
|
+
dot.edge(_node_name(transition.source), _node_name(transition.target), **attrs)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def model_to_svg(model: StateMachine, **kwargs: Any) -> str:
|
|
128
|
+
"""Render ``model`` to an SVG string."""
|
|
129
|
+
dot = model_to_graphviz(model, **kwargs)
|
|
130
|
+
return dot.pipe(format="svg").decode("utf-8")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def model_to_png(model: StateMachine, **kwargs: Any) -> bytes:
|
|
134
|
+
"""Render ``model`` to PNG bytes."""
|
|
135
|
+
dot = model_to_graphviz(model, **kwargs)
|
|
136
|
+
return dot.pipe(format="png")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def draw(
|
|
140
|
+
model: StateMachine,
|
|
141
|
+
filename: str | None = None,
|
|
142
|
+
*,
|
|
143
|
+
format: str = "svg",
|
|
144
|
+
view: bool = False,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> str | None:
|
|
147
|
+
"""Render ``model`` to a file or open it in a viewer.
|
|
148
|
+
|
|
149
|
+
Returns the output path when ``filename`` is given.
|
|
150
|
+
"""
|
|
151
|
+
dot = model_to_graphviz(model, **kwargs)
|
|
152
|
+
if filename is None:
|
|
153
|
+
dot.view(format=format, cleanup=True)
|
|
154
|
+
return None
|
|
155
|
+
path = dot.render(filename=filename, format=format, cleanup=True)
|
|
156
|
+
if view:
|
|
157
|
+
dot.view(filename=path, cleanup=True)
|
|
158
|
+
return path
|
sofic/viz/idiagram.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""UpSet-style plot of the five-variable information-anatomy I-diagram.
|
|
2
|
+
|
|
3
|
+
Renders the atoms of :func:`sofic.generators.information_diagram.information_diagram`
|
|
4
|
+
as an UpSet plot (:cite:`lex2014upset`): a signed bar per atom (so negative
|
|
5
|
+
co-information atoms dip below zero), a width-scaled anatomy-aggregate legend
|
|
6
|
+
above the bars, a dot-matrix below showing which of the five random variables
|
|
7
|
+
``(S⁺₀, S⁻₀, X₀, S⁺₁, S⁻₁)`` are inside each atom, and a colour per anatomy
|
|
8
|
+
aggregate (``r_μ``, ``b⁺_μ``, ``b⁻_μ``, ``q_μ``, ``σ_μ``, ``χ⁺``, ``χ⁻``).
|
|
9
|
+
Atoms are laid out in the fixed
|
|
10
|
+
:data:`~sofic.generators.information_diagram.ROLE_ORDER`, never sorted by value,
|
|
11
|
+
and labeled with the Jurgens taxonomy name when one exists
|
|
12
|
+
(:cite:`jurgens2026taxonomy` Table II).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
18
|
+
|
|
19
|
+
from sofic.generators.information_diagram import (
|
|
20
|
+
COLOR_GROUP,
|
|
21
|
+
ROLE_ORDER,
|
|
22
|
+
ROLE_TOTAL_KEY,
|
|
23
|
+
InformationDiagram,
|
|
24
|
+
information_diagram,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from matplotlib.figure import Figure
|
|
29
|
+
|
|
30
|
+
#: Default colour per fine role. Roles that contribute to the same aggregate
|
|
31
|
+
#: share a colour (all ``r_*`` red, both ``b_plus`` atoms dark green, …).
|
|
32
|
+
DEFAULT_ROLE_COLORS: dict[str, str] = {
|
|
33
|
+
# rμ — red
|
|
34
|
+
"r_gauge": "#c1121f",
|
|
35
|
+
"r_fwd": "#c1121f",
|
|
36
|
+
"r_rev": "#c1121f",
|
|
37
|
+
"r_joint": "#c1121f",
|
|
38
|
+
# b⁺μ — darkish green
|
|
39
|
+
"b_plus": "#1b4332",
|
|
40
|
+
# b⁻μ — lighter green
|
|
41
|
+
"b_minus": "#74c69d",
|
|
42
|
+
# qμ — purple
|
|
43
|
+
"q_mu": "#7b2cbf",
|
|
44
|
+
# σμ — blue
|
|
45
|
+
"sigma_mu": "#1d4e89",
|
|
46
|
+
# χ⁺ / χ⁻ — contrasting warm accents (orange / amber)
|
|
47
|
+
"chi_plus": "#e85d04",
|
|
48
|
+
"chi_minus": "#ffba08",
|
|
49
|
+
# leftover structure (always zero under unifilarity)
|
|
50
|
+
"structure": "#adb5bd",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Legend symbols keyed by colour group (one legend entry per aggregate).
|
|
54
|
+
GROUP_LABELS: dict[str, str] = {
|
|
55
|
+
"r_mu": "rμ",
|
|
56
|
+
"b_plus": "b⁺μ",
|
|
57
|
+
"b_minus": "b⁻μ",
|
|
58
|
+
"q_mu": "qμ",
|
|
59
|
+
"sigma_mu": "σμ",
|
|
60
|
+
"chi_plus": "χ⁺",
|
|
61
|
+
"chi_minus": "χ⁻",
|
|
62
|
+
"structure": "struct",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#: Totals key for each colour-group legend entry.
|
|
66
|
+
GROUP_TOTAL_KEY: dict[str, str] = {
|
|
67
|
+
"r_mu": "r_mu",
|
|
68
|
+
"b_plus": "b_plus",
|
|
69
|
+
"b_minus": "b_minus",
|
|
70
|
+
"q_mu": "q_mu",
|
|
71
|
+
"sigma_mu": "sigma_mu",
|
|
72
|
+
"chi_plus": "chi_plus",
|
|
73
|
+
"chi_minus": "chi_minus",
|
|
74
|
+
"structure": "structure",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#: Ordered colour groups for the legend.
|
|
78
|
+
GROUP_ORDER: tuple[str, ...] = (
|
|
79
|
+
"r_mu",
|
|
80
|
+
"b_plus",
|
|
81
|
+
"b_minus",
|
|
82
|
+
"q_mu",
|
|
83
|
+
"sigma_mu",
|
|
84
|
+
"chi_plus",
|
|
85
|
+
"chi_minus",
|
|
86
|
+
"structure",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Back-compat aliases used by older docs / callers.
|
|
90
|
+
ROLE_LABELS: dict[str, str] = {role: GROUP_LABELS[COLOR_GROUP[role]] for role in ROLE_ORDER}
|
|
91
|
+
ROLE_TOTAL_KEY_LEGACY = ROLE_TOTAL_KEY
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_matplotlib() -> Any:
|
|
95
|
+
try:
|
|
96
|
+
import matplotlib.pyplot as plt
|
|
97
|
+
except ImportError as exc:
|
|
98
|
+
raise ImportError(
|
|
99
|
+
"Information-diagram plotting requires the optional sofic[viz] extra (pip install 'sofic[viz]')."
|
|
100
|
+
) from exc
|
|
101
|
+
return plt
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: Approximate horizontal inches per compact ``symbol = value`` legend entry.
|
|
105
|
+
_LEGEND_ENTRY_WIDTH_IN = 1.1
|
|
106
|
+
#: Vertical gridspec share for the legend title row + each entry row.
|
|
107
|
+
_LEGEND_TITLE_RATIO = 0.28
|
|
108
|
+
_LEGEND_ROW_RATIO = 0.22
|
|
109
|
+
|
|
110
|
+
#: Semantic columns used when the legend is laid out in four columns:
|
|
111
|
+
#: rμ | (b⁺μ, b⁻μ) | (qμ, σμ) | (χ⁺, χ⁻). Rare once entries are compact
|
|
112
|
+
#: enough to fit one aggregate per column on a typical figure width.
|
|
113
|
+
_LEGEND_COLUMN_GROUPS_4: tuple[tuple[str, ...], ...] = (
|
|
114
|
+
("r_mu",),
|
|
115
|
+
("b_plus", "b_minus"),
|
|
116
|
+
("q_mu", "sigma_mu"),
|
|
117
|
+
("chi_plus", "chi_minus"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _legend_ncols(fig_width: float, n_items: int) -> int:
|
|
122
|
+
"""How many legend columns fit in ``fig_width`` without crowding.
|
|
123
|
+
|
|
124
|
+
Driven by figure width (itself a function of the number of bars), so machines
|
|
125
|
+
with similar atom counts share the same legend geometry.
|
|
126
|
+
"""
|
|
127
|
+
if n_items <= 0:
|
|
128
|
+
return 1
|
|
129
|
+
usable = max(fig_width - 0.9, _LEGEND_ENTRY_WIDTH_IN)
|
|
130
|
+
fit = max(1, int(usable // _LEGEND_ENTRY_WIDTH_IN))
|
|
131
|
+
return max(1, min(n_items, fit))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pack_legend_handles(
|
|
135
|
+
handles_by_group: dict[str, Any],
|
|
136
|
+
ncol: int,
|
|
137
|
+
*,
|
|
138
|
+
empty_patch: Any,
|
|
139
|
+
) -> tuple[list[Any], int]:
|
|
140
|
+
"""Order legend handles for matplotlib's column-major ``ncol`` packing.
|
|
141
|
+
|
|
142
|
+
When ``ncol == 4``, pack into the semantic columns of
|
|
143
|
+
:data:`_LEGEND_COLUMN_GROUPS_4` (padding shorter columns so ``rμ`` sits alone
|
|
144
|
+
in the first column). Otherwise keep :data:`GROUP_ORDER`.
|
|
145
|
+
"""
|
|
146
|
+
if ncol == 4:
|
|
147
|
+
columns: list[list[Any]] = []
|
|
148
|
+
for keys in _LEGEND_COLUMN_GROUPS_4:
|
|
149
|
+
col = [handles_by_group[g] for g in keys if g in handles_by_group]
|
|
150
|
+
if col:
|
|
151
|
+
columns.append(col)
|
|
152
|
+
# Leftover groups (e.g. structure) append to the last column.
|
|
153
|
+
placed = {g for keys in _LEGEND_COLUMN_GROUPS_4 for g in keys}
|
|
154
|
+
leftovers = [handles_by_group[g] for g in GROUP_ORDER if g in handles_by_group and g not in placed]
|
|
155
|
+
if leftovers:
|
|
156
|
+
if columns:
|
|
157
|
+
columns[-1].extend(leftovers)
|
|
158
|
+
else:
|
|
159
|
+
columns.append(leftovers)
|
|
160
|
+
if not columns:
|
|
161
|
+
return [], 1
|
|
162
|
+
ncol_eff = len(columns)
|
|
163
|
+
height = max(len(col) for col in columns)
|
|
164
|
+
packed: list[Any] = []
|
|
165
|
+
for col in columns:
|
|
166
|
+
packed.extend(col)
|
|
167
|
+
packed.extend([empty_patch] * (height - len(col)))
|
|
168
|
+
return packed, ncol_eff
|
|
169
|
+
|
|
170
|
+
ordered = [handles_by_group[g] for g in GROUP_ORDER if g in handles_by_group]
|
|
171
|
+
return ordered, max(1, min(ncol, len(ordered) or 1))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def plot_information_diagram(
|
|
175
|
+
source: Any,
|
|
176
|
+
*,
|
|
177
|
+
show_zero: bool = False,
|
|
178
|
+
atoms: Literal["process", "generic", "all"] | None = None,
|
|
179
|
+
role_colors: dict[str, str] | None = None,
|
|
180
|
+
annotate: bool = True,
|
|
181
|
+
title: str | None = None,
|
|
182
|
+
figsize: tuple[float, float] | None = None,
|
|
183
|
+
) -> Figure:
|
|
184
|
+
"""Draw the five-variable information anatomy as a colour-coded UpSet plot.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
source: A bidirectional/forward ε-machine or a pre-computed
|
|
188
|
+
:class:`~sofic.generators.information_diagram.InformationDiagram`.
|
|
189
|
+
show_zero: Deprecated; prefer ``atoms``. If ``True`` and ``atoms`` is
|
|
190
|
+
omitted, keeps every atom (``atoms="all"``).
|
|
191
|
+
atoms: ``"process"`` (default) — nonzero for this process;
|
|
192
|
+
``"generic"`` — the 21 generically nonzero membership sets;
|
|
193
|
+
``"all"`` — all 31 Yeung atoms. Ignored when ``source`` is already
|
|
194
|
+
an :class:`InformationDiagram`.
|
|
195
|
+
role_colors: Overrides for :data:`DEFAULT_ROLE_COLORS` (per-role).
|
|
196
|
+
annotate: Print each atom's value above/below its bar.
|
|
197
|
+
title: Plot title; a default anatomy title is used when ``None``.
|
|
198
|
+
figsize: Figure size; auto-sized from the atom count when ``None``.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The :class:`matplotlib.figure.Figure`.
|
|
202
|
+
"""
|
|
203
|
+
plt = _require_matplotlib()
|
|
204
|
+
import matplotlib.patches as mpatches
|
|
205
|
+
|
|
206
|
+
if isinstance(source, InformationDiagram):
|
|
207
|
+
diagram = source
|
|
208
|
+
else:
|
|
209
|
+
diagram = information_diagram(source, show_zero=show_zero, atoms=atoms)
|
|
210
|
+
|
|
211
|
+
colors = {**DEFAULT_ROLE_COLORS, **(role_colors or {})}
|
|
212
|
+
plotted = diagram.atoms
|
|
213
|
+
if not plotted:
|
|
214
|
+
raise ValueError("information diagram has no atoms to plot")
|
|
215
|
+
|
|
216
|
+
var_names = diagram.variable_names
|
|
217
|
+
n_vars = len(var_names)
|
|
218
|
+
n = len(plotted)
|
|
219
|
+
xs = list(range(n))
|
|
220
|
+
values = [atom.value_float() for atom in plotted]
|
|
221
|
+
bar_colors = [colors[atom.role] for atom in plotted]
|
|
222
|
+
|
|
223
|
+
vmax = max(values + [0.0])
|
|
224
|
+
vmin = min(values + [0.0])
|
|
225
|
+
span = (vmax - vmin) or 1.0
|
|
226
|
+
|
|
227
|
+
summary = diagram.totals
|
|
228
|
+
present_groups = [group for group in GROUP_ORDER if any(COLOR_GROUP[a.role] == group for a in plotted)]
|
|
229
|
+
handles_by_group: dict[str, Any] = {}
|
|
230
|
+
for group in present_groups:
|
|
231
|
+
role_for_color = next(role for role, g in COLOR_GROUP.items() if g == group)
|
|
232
|
+
total = summary.get(GROUP_TOTAL_KEY[group])
|
|
233
|
+
label = GROUP_LABELS[group]
|
|
234
|
+
if total is not None:
|
|
235
|
+
label = f"{label} = {float(total):+.3f}"
|
|
236
|
+
handles_by_group[group] = mpatches.Patch(
|
|
237
|
+
facecolor=colors[role_for_color],
|
|
238
|
+
edgecolor="0.25",
|
|
239
|
+
lw=0.5,
|
|
240
|
+
label=label,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if figsize is None:
|
|
244
|
+
figsize = (max(9.0, 0.55 * n + 1.2), 5.6)
|
|
245
|
+
n_legend = len(handles_by_group)
|
|
246
|
+
ncol = _legend_ncols(figsize[0], n_legend)
|
|
247
|
+
empty_patch = mpatches.Patch(facecolor="none", edgecolor="none", label=" ")
|
|
248
|
+
handles, ncol = _pack_legend_handles(handles_by_group, ncol, empty_patch=empty_patch)
|
|
249
|
+
# Column-major packing: rows = max column height (including rμ's spacer).
|
|
250
|
+
n_rows = (len(handles) // ncol) if ncol and handles else 1
|
|
251
|
+
legend_ratio = _LEGEND_TITLE_RATIO + _LEGEND_ROW_RATIO * max(n_rows, 1)
|
|
252
|
+
|
|
253
|
+
fig = plt.figure(figsize=figsize)
|
|
254
|
+
# Outer split keeps a little air under the legend; the bar + UpSet matrix
|
|
255
|
+
# share a nested gridspec with almost no gap so the dots sit under the bars.
|
|
256
|
+
gs = fig.add_gridspec(
|
|
257
|
+
2,
|
|
258
|
+
1,
|
|
259
|
+
height_ratios=[legend_ratio, 4.3],
|
|
260
|
+
hspace=0.08,
|
|
261
|
+
)
|
|
262
|
+
gs_plot = gs[1].subgridspec(2, 1, height_ratios=[3.0, 1.3], hspace=0.0)
|
|
263
|
+
ax_leg = fig.add_subplot(gs[0])
|
|
264
|
+
ax_bar = fig.add_subplot(gs_plot[0])
|
|
265
|
+
ax_mat = fig.add_subplot(gs_plot[1], sharex=ax_bar)
|
|
266
|
+
ax_leg.set_axis_off()
|
|
267
|
+
|
|
268
|
+
ax_bar.axhline(0.0, color="0.4", lw=0.8, zorder=1)
|
|
269
|
+
ax_bar.bar(xs, values, width=0.72, color=bar_colors, edgecolor="0.25", lw=0.5, zorder=2)
|
|
270
|
+
if annotate:
|
|
271
|
+
pad = 0.02 * span
|
|
272
|
+
for x, v in zip(xs, values, strict=True):
|
|
273
|
+
ax_bar.text(
|
|
274
|
+
x,
|
|
275
|
+
v + (pad if v >= 0 else -pad),
|
|
276
|
+
f"{v:.3f}",
|
|
277
|
+
ha="center",
|
|
278
|
+
va="bottom" if v >= 0 else "top",
|
|
279
|
+
fontsize=7,
|
|
280
|
+
)
|
|
281
|
+
ax_bar.set_ylabel("bits")
|
|
282
|
+
ax_bar.set_ylim(vmin - 0.14 * span, vmax + 0.16 * span)
|
|
283
|
+
ax_bar.set_xlim(-1.4, n - 0.5)
|
|
284
|
+
ax_bar.set_xticks([])
|
|
285
|
+
ax_bar.tick_params(axis="x", bottom=False, labelbottom=False)
|
|
286
|
+
for side in ("top", "right", "bottom"):
|
|
287
|
+
ax_bar.spines[side].set_visible(False)
|
|
288
|
+
|
|
289
|
+
fig.suptitle(title or "Five-variable information anatomy I-diagram", fontsize=11, y=0.995)
|
|
290
|
+
|
|
291
|
+
# Top row is a gray strip (var_index 0); match the axes face so the bar
|
|
292
|
+
# x-axis borders gray rather than a white fringe at the join.
|
|
293
|
+
ax_mat.set_facecolor("0.95")
|
|
294
|
+
for var_index in range(n_vars):
|
|
295
|
+
y = n_vars - 1 - var_index
|
|
296
|
+
if var_index % 2 == 1:
|
|
297
|
+
ax_mat.axhspan(y - 0.5, y + 0.5, color="1.0", zorder=0)
|
|
298
|
+
ax_mat.text(-1.2, y, var_names[var_index], ha="right", va="center", fontsize=9)
|
|
299
|
+
|
|
300
|
+
for x, atom in zip(xs, plotted, strict=True):
|
|
301
|
+
inside = set(atom.indices)
|
|
302
|
+
color = colors[atom.role]
|
|
303
|
+
ys_inside = []
|
|
304
|
+
for var_index in range(n_vars):
|
|
305
|
+
y = n_vars - 1 - var_index
|
|
306
|
+
if var_index in inside:
|
|
307
|
+
ax_mat.plot(x, y, "o", color=color, ms=8, zorder=3)
|
|
308
|
+
ys_inside.append(y)
|
|
309
|
+
else:
|
|
310
|
+
ax_mat.plot(x, y, "o", color="0.86", ms=8, zorder=2)
|
|
311
|
+
if len(ys_inside) > 1:
|
|
312
|
+
ax_mat.plot([x, x], [min(ys_inside), max(ys_inside)], color=color, lw=2.0, zorder=2)
|
|
313
|
+
|
|
314
|
+
ax_mat.set_xlim(-1.4, n - 0.5)
|
|
315
|
+
# First gray strip is the top row at y = n_vars - 1, spanning
|
|
316
|
+
# [n_vars - 1.5, n_vars - 0.5]. Flush the axes top to that edge and draw
|
|
317
|
+
# the bar x-axis there so the black line borders the gray strip.
|
|
318
|
+
top = n_vars - 0.5
|
|
319
|
+
ax_mat.set_ylim(-0.5, top)
|
|
320
|
+
ax_mat.axhline(top, color="0.15", lw=1.0, solid_capstyle="butt", zorder=10)
|
|
321
|
+
ax_mat.set_yticks([])
|
|
322
|
+
ax_mat.set_xticks([])
|
|
323
|
+
ax_mat.tick_params(axis="x", bottom=False, labelbottom=False)
|
|
324
|
+
for spine in ax_mat.spines.values():
|
|
325
|
+
spine.set_visible(False)
|
|
326
|
+
|
|
327
|
+
# Width-scaled columns; at ncol==4 use semantic grouping
|
|
328
|
+
# rμ | (b⁺, b⁻) | (q, σ) | (χ⁺, χ⁻).
|
|
329
|
+
ax_leg.legend(
|
|
330
|
+
handles=handles,
|
|
331
|
+
loc="center",
|
|
332
|
+
ncol=ncol,
|
|
333
|
+
fontsize=7,
|
|
334
|
+
framealpha=0.92,
|
|
335
|
+
borderaxespad=0.0,
|
|
336
|
+
columnspacing=1.2,
|
|
337
|
+
handletextpad=0.5,
|
|
338
|
+
title="anatomy aggregate",
|
|
339
|
+
title_fontsize=8,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
fig.subplots_adjust(left=0.075, right=0.98, top=0.93, bottom=0.05)
|
|
343
|
+
# After layout, seat the matrix flush under the bar so the bar x-axis
|
|
344
|
+
# (bottom spine) borders the top of the first gray strip.
|
|
345
|
+
bar_pos = ax_bar.get_position()
|
|
346
|
+
mat_pos = ax_mat.get_position()
|
|
347
|
+
lift = bar_pos.y0 - mat_pos.y1
|
|
348
|
+
if abs(lift) > 1e-6:
|
|
349
|
+
ax_mat.set_position([mat_pos.x0, mat_pos.y0 + lift, mat_pos.width, mat_pos.height])
|
|
350
|
+
return fig
|