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