scistackplot 0.1.36__tar.gz → 0.1.38__tar.gz

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 (48) hide show
  1. {scistackplot-0.1.36 → scistackplot-0.1.38}/PKG-INFO +2 -2
  2. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/aliases.py +21 -2
  3. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/capability.py +22 -8
  4. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/codegen.py +14 -1
  5. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/reduce.py +1 -1
  6. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/mpl.py +146 -22
  7. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/plotly_.py +32 -10
  8. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/spec.py +29 -1
  9. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/variants.py +279 -18
  10. {scistackplot-0.1.36 → scistackplot-0.1.38}/.gitignore +0 -0
  11. {scistackplot-0.1.36 → scistackplot-0.1.38}/README.md +0 -0
  12. {scistackplot-0.1.36 → scistackplot-0.1.38}/pyproject.toml +0 -0
  13. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/__init__.py +0 -0
  14. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/autosize.py +0 -0
  15. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/cell.py +0 -0
  16. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/colors.py +0 -0
  17. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/compare.py +0 -0
  18. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/custom.py +0 -0
  19. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/dedup.py +0 -0
  20. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/diffbars.py +0 -0
  21. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/export.py +0 -0
  22. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/figsize.py +0 -0
  23. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/figure_file.py +0 -0
  24. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/framesize.py +0 -0
  25. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/groups.py +0 -0
  26. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/numeric.py +0 -0
  27. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/panels.py +0 -0
  28. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/paper.py +0 -0
  29. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/presets.py +0 -0
  30. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/reducer.py +0 -0
  31. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/__init__.py +0 -0
  32. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/render/base.py +0 -0
  33. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/resolved.py +0 -0
  34. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/restore.py +0 -0
  35. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/roles.py +0 -0
  36. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/series_stats.py +0 -0
  37. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/shape.py +0 -0
  38. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/__init__.py +0 -0
  39. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/base.py +0 -0
  40. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/csv.py +0 -0
  41. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/sources/frame.py +0 -0
  42. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/spaghetti.py +0 -0
  43. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/table.py +0 -0
  44. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/textsize.py +0 -0
  45. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/ticklabels.py +0 -0
  46. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/weights.py +0 -0
  47. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/xaxis.py +0 -0
  48. {scistackplot-0.1.36 → scistackplot-0.1.38}/src/scistackplot/ylimits.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.36
3
+ Version: 0.1.38
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: numpy>=1.21
21
21
  Requires-Dist: pandas>=1.5
22
- Requires-Dist: scistacklog==0.1.36
22
+ Requires-Dist: scistacklog==0.1.38
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
25
25
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -156,11 +156,21 @@ class DisplayText:
156
156
  """A panel's facet values, the way ``Panel.title`` joins them."""
157
157
  return " · ".join(self.level(factor, value) for factor, value in key.items())
158
158
 
159
- def figure_title(self, key: dict[str, Any]) -> str:
160
- """An ITERATE figure's ``name=value`` title."""
159
+ def figure_title(self, key: dict[str, Any], table: "LongTable | None" = None) -> str:
160
+ """An ITERATE figure's ``name=value`` title.
161
+
162
+ **A variant factor with ONE level is left out** (user, 2026-10-15):
163
+ one series needs no name in the title, and the legend/Variants row
164
+ already says which variant it is — ``Variant=CosmedData · load_cbrace
165
+ v2`` over a figure that could not be anything else is noise. Only the
166
+ TITLE: ``figure_key`` keeps the entry, because save-all file names and
167
+ the endpoint's ``for_each`` keys are built from it. A single-level
168
+ schema key (``subject=03``) stays — it says which figure this is.
169
+ """
161
170
  return ", ".join(
162
171
  f"{self.name(factor, factor)}={self.level(factor, value)}"
163
172
  for factor, value in key.items()
173
+ if not _lone_variant(table, factor)
164
174
  )
165
175
 
166
176
  def x_plan(self, plan):
@@ -208,6 +218,15 @@ def _raw(value: Any) -> str:
208
218
  # ---------------------------------------------------------------------------
209
219
 
210
220
 
221
+ def _lone_variant(table: LongTable | None, factor: str) -> bool:
222
+ """A variant factor (``Variant``, ``Code:*``) holding a single level —
223
+ the rule :meth:`DisplayText.figure_title` leaves out of a title."""
224
+ if table is None or not table.has_factor(factor):
225
+ return False
226
+ info = table.factor(factor)
227
+ return bool(info.is_variant) and len(info.levels) == 1
228
+
229
+
211
230
  def display_text(spec: PlotSpec, table: LongTable) -> DisplayText:
212
231
  """The plot's aliases over the project's, for ``table``'s factors.
213
232
 
@@ -717,8 +717,10 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
717
717
  import pandas as pd
718
718
 
719
719
  from .variants import (
720
+ VariantSet,
720
721
  auto_label,
721
722
  defined_sets,
723
+ effective_selection,
722
724
  is_stated,
723
725
  label_variable,
724
726
  resolve_selection,
@@ -731,9 +733,14 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
731
733
  names = [f.name for f in table.variant_factors if f.name in frame.columns]
732
734
 
733
735
  sets = []
734
- # With nothing selected — no rows, or none filled in yet — every row is on
735
- # screen, which is what the figure is showing too.
736
- kept_mask = pd.Series(not defined_sets(spec.variant_sets), index=frame.index)
736
+ # With nothing selected — no rows, or none filled in yet — the figure draws
737
+ # what an EMPTY row selects (the source default: current code), so that is
738
+ # what is "on screen" here too. Measured by the same owner, `row_mask`.
739
+ kept_mask = (
740
+ row_mask(table, spec, VariantSet())
741
+ if not defined_sets(spec.variant_sets)
742
+ else pd.Series(False, index=frame.index)
743
+ )
737
744
  claimed = pd.Series(False, index=frame.index)
738
745
  for index, variant in enumerate(spec.variant_sets):
739
746
  # "Says something", which row 0 always does — it is the figure's
@@ -743,9 +750,7 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
743
750
  # and folds to nothing, because one series needs no factor.
744
751
  defined = is_stated(variant, index)
745
752
  if defined:
746
- mask = row_mask(
747
- frame, spec, variant, latest_column=table.latest_column
748
- )
753
+ mask = row_mask(table, spec, variant)
749
754
  # First-match-wins, mirroring apply_variant_sets: the count shown
750
755
  # must be the number of rows this variant contributes to the figure,
751
756
  # not the number it would match on its own.
@@ -772,8 +777,13 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
772
777
  index=index,
773
778
  variable=label_variable(variant, index, spec.y_measure),
774
779
  latest_column=table.latest_column,
780
+ frozen=None if variant.follow_latest else variant.frozen,
775
781
  ),
776
782
  "explicit_name": variant.name,
783
+ # A reopened saved plot's versions, and whether the row follows
784
+ # latest instead (docs/claude/variant-row-selection.md).
785
+ "frozen": {c: list(v) for c, v in variant.frozen.items()},
786
+ "follow_latest": variant.follow_latest,
777
787
  "selection": dict(variant.selection),
778
788
  # None means the primary measure; the GUI shows that as the
779
789
  # dropdown's default rather than inventing a name for it.
@@ -784,7 +794,9 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
784
794
  # per row because that is where the fix is (pin a version, or
785
795
  # split the row), and because the column itself is no longer
786
796
  # offered as a factor.
787
- "spans": spanned_code_axes(frame[fresh], variant.selection, table)
797
+ "spans": spanned_code_axes(
798
+ frame[fresh], effective_selection(variant, table), table
799
+ )
788
800
  if defined
789
801
  else {},
790
802
  # What this selection actually resolved to, and — when it
@@ -803,7 +815,9 @@ def variant_summary(spec: PlotSpec, table: LongTable) -> dict:
803
815
  # I get" is nothing.
804
816
  "resolved": _jsonable_selection(
805
817
  resolve_selection(
806
- frame, variant.selection, latest_column=table.latest_column
818
+ frame,
819
+ effective_selection(variant, table),
820
+ latest_column=table.latest_column,
807
821
  )
808
822
  )
809
823
  if defined
@@ -2855,7 +2855,20 @@ def _script_inputs(spec: PlotSpec) -> tuple[str, list[str]]:
2855
2855
  )
2856
2856
  else:
2857
2857
  lines.append(f"{param} = df")
2858
- for column, value in variant.selection.items():
2858
+ selection = dict(variant.selection)
2859
+ if variant.frozen and not variant.follow_latest:
2860
+ # A reopened saved plot's versions (variants.frozen_mask). The
2861
+ # frozen columns replace the row's own keys for them; several
2862
+ # ordinals export as "any of" — the per-location "highest of"
2863
+ # needs provenance a flat table does not carry.
2864
+ selection.update(
2865
+ {
2866
+ column: levels[0] if len(levels) == 1 else list(levels)
2867
+ for column, levels in variant.frozen.items()
2868
+ if levels
2869
+ }
2870
+ )
2871
+ for column, value in selection.items():
2859
2872
  if isinstance(value, str) and value == LATEST:
2860
2873
  lines.append(
2861
2874
  f"# NOTE: {column!r} asked for the latest version, which "
@@ -2776,7 +2776,7 @@ def _labels_for(
2776
2776
 
2777
2777
  title = style.title
2778
2778
  if title is None and figure_key:
2779
- title = text.figure_title(figure_key)
2779
+ title = text.figure_title(figure_key, table)
2780
2780
 
2781
2781
  def named(factor: str) -> str:
2782
2782
  return text.name(factor, table.factor(factor).display)
@@ -9,6 +9,9 @@ expected to reproduce.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import threading
13
+ import time
14
+ from contextlib import contextmanager
12
15
  from dataclasses import dataclass, field, replace
13
16
  from typing import Any
14
17
 
@@ -72,9 +75,48 @@ from .base import (
72
75
 
73
76
  LAYER = "scistackplot"
74
77
 
78
+ #: One matplotlib figure at a time, process-wide. The GUI answers every RPC
79
+ #: on its own thread, and a render sets text sizes through ``rc_context`` —
80
+ #: GLOBAL rcParams, saved on entry and restored on exit — so two renders
81
+ #: overlapping (a pane resize while the previous resolve still lays out;
82
+ #: scidb.log 2026-10-15 12:23:50, three at once) read each other's sizes and
83
+ #: the last to exit can leave the wrong ones behind. Re-entrant: a layout
84
+ #: decision settles the auto text size, whose trials render.
85
+ _MPL_LOCK = threading.RLock()
86
+
87
+ #: A wait for the lock longer than this is logged (INFO): a slow figure is
88
+ #: then told apart from a figure queued behind another one.
89
+ _LOCK_WAIT_LOG_S = 0.25
90
+
91
+
92
+ @contextmanager
93
+ def _one_figure_at_a_time(what: str):
94
+ t0 = time.perf_counter()
95
+ with _MPL_LOCK:
96
+ waited = time.perf_counter() - t0
97
+ if waited > _LOCK_WAIT_LOG_S:
98
+ Log.info(
99
+ "%s waited %.2fs for another figure's matplotlib layout to finish "
100
+ "(matplotlib lays out one figure at a time)",
101
+ what,
102
+ waited,
103
+ layer=LAYER,
104
+ )
105
+ yield
106
+
107
+
108
+ def _artist_count(fig) -> int:
109
+ """Every artist a layout pass walks: each axes' children, the figure's own."""
110
+ return len(fig.get_children()) + sum(len(ax.get_children()) for ax in fig.axes)
111
+
75
112
 
76
113
  def render(resolved: ResolvedPlot):
77
114
  """Draw ``resolved`` and return the Figure (caller owns closing it)."""
115
+ with _one_figure_at_a_time(f"render_mpl({resolved.kind})"):
116
+ return _render(resolved)
117
+
118
+
119
+ def _render(resolved: ResolvedPlot):
78
120
  import matplotlib
79
121
 
80
122
  if matplotlib.get_backend().lower() not in ("agg", "template"):
@@ -88,7 +130,18 @@ def render(resolved: ResolvedPlot):
88
130
  # An auto text size is chosen before anything is drawn (autosize owns
89
131
  # it; a trial layout arrives here already settled, so this never recurses).
90
132
  resolved = settle(resolved)
91
- with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)):
133
+ with Log.timer("render_mpl", layer=LAYER, extra=str(resolved.kind)) as timer:
134
+ # Consecutive laps rather than nested phases: the body is one long
135
+ # sequence, and which pass of it is slow is the question a slow
136
+ # figure asks (scidb.log 2026-10-15: 6.6 s with no breakdown).
137
+ mark = time.perf_counter()
138
+
139
+ def lap(name: str) -> None:
140
+ nonlocal mark
141
+ now = time.perf_counter()
142
+ timer.phases.append((name, now - mark))
143
+ mark = now
144
+
92
145
  style = resolved.spec.style
93
146
  # rc_context, not rcParams: text reads the size at CREATION, so this
94
147
  # has to be open while every label, tick and legend entry is made —
@@ -129,7 +182,12 @@ def render(resolved: ResolvedPlot):
129
182
  # panel was drawn last.
130
183
  sharey=shares_y_axis(resolved),
131
184
  )
185
+ lap("subplots")
132
186
 
187
+ # Marks and overlay alternate per panel: summed apart, so the
188
+ # overlay's share is its own phase.
189
+ marks_s = sample_s = 0.0
190
+ sample_runs_drawn = sample_artists = 0
133
191
  used: set[tuple[int, int]] = set()
134
192
  # (row, col) -> panel, so the cosmetics pass can ask a CELL for its
135
193
  # panel's limits. It walks the grid rather than the panel list (blank
@@ -160,16 +218,25 @@ def render(resolved: ResolvedPlot):
160
218
  used.add((row, col))
161
219
  at_cell[(row, col)] = panel
162
220
  panel_axes[index] = ax
221
+ t0 = time.perf_counter()
163
222
  if resolved.custom is not None:
164
223
  # The user's plot_ function draws this panel's marks; the
165
224
  # layout around them is this renderer's (custom.py).
166
225
  resolved.custom.draw_panel(ax, panel, resolved)
167
226
  else:
168
227
  _draw_panel(ax, panel.frame, resolved)
169
- _draw_sample(ax, panel, resolved)
228
+ t1 = time.perf_counter()
229
+ runs, drawn = _draw_sample(ax, panel, resolved)
230
+ sample_runs_drawn += runs
231
+ sample_artists += drawn
232
+ marks_s += t1 - t0
233
+ sample_s += time.perf_counter() - t1
170
234
  # No subplot caption: a faceted panel is named by its y-axis title
171
235
  # instead (base.panel_y_title), which buys back the row of vertical
172
236
  # space a title costs in every row of the grid.
237
+ timer.phases.append(("marks", marks_s))
238
+ timer.phases.append(("sample_overlay", sample_s))
239
+ mark = time.perf_counter()
173
240
 
174
241
  # Blank out grid cells no panel landed in (a wrapped grid's remainder).
175
242
  for row in range(n_rows):
@@ -178,6 +245,21 @@ def render(resolved: ResolvedPlot):
178
245
  axes[row][col].set_visible(False)
179
246
 
180
247
  _apply_axes_cosmetics(fig, axes, resolved, n_rows, n_cols, at_cell)
248
+ lap("cosmetics")
249
+ # What every layout pass below walks. Thousands here is the cause
250
+ # of a slow figure, whatever the row count says (the overlay drew
251
+ # one artist per run until 2026-10-15).
252
+ Log.info(
253
+ "drew %d artist(s) over %d panel(s)%s",
254
+ _artist_count(fig),
255
+ len(resolved.panels),
256
+ (
257
+ f"; sample overlay {sample_runs_drawn} run(s) as {sample_artists} artist(s)"
258
+ if sample_runs_drawn
259
+ else ""
260
+ ),
261
+ layer=LAYER,
262
+ )
181
263
 
182
264
  if resolved.labels.title:
183
265
  fig.suptitle(resolved.labels.title)
@@ -204,13 +286,16 @@ def render(resolved: ResolvedPlot):
204
286
  rect = _arrange(fig, labelled, resolved, legend)
205
287
  fig.tight_layout(rect=rect)
206
288
  setattr(fig, LEGEND_ATTR, legend.describe() if legend is not None else None)
289
+ lap("legend_and_layout")
207
290
  # After a layout pass: the room each label has is the laid-out
208
291
  # panel width (spec/images/graph1.png, graph2.png).
209
292
  _fit_x_labels(fig, [ax for _, _, ax in labelled], resolved, rect)
293
+ lap("x_labels")
210
294
  # Difference bars need the laid-out panel heights (their sizes are
211
295
  # points), so they come after every other layout pass, and before
212
296
  # the reach is measured (a raised top can widen the tick numbers).
213
297
  _draw_difference_bars(fig, panel_axes, resolved, rect)
298
+ lap("difference_bars")
214
299
  # Last, on the final layout: what the preview sizes its margins
215
300
  # and cell gaps from. Hidden y titles are named next to it, so a
216
301
  # gap that did not shrink can be explained from scidb.log (the
@@ -223,7 +308,9 @@ def render(resolved: ResolvedPlot):
223
308
  if hidden:
224
309
  Log.debug("y titles hidden: %s", "; ".join(hidden), layer=LAYER)
225
310
  setattr(fig, GRID_REACH_ATTR, _grid_reach(fig, axes, n_rows, n_cols))
311
+ lap("grid_reach")
226
312
  setattr(fig, AXIS_TEXT_ATTR, _axis_text(fig, axes, n_rows, n_cols, resolved))
313
+ lap("axis_text")
227
314
  return fig
228
315
 
229
316
 
@@ -491,7 +578,7 @@ def _draw_distribution(ax, frame, resolved, *, violin: bool) -> None:
491
578
  ax.set_xticklabels([resolved.text.x_tick(v) for v in order])
492
579
 
493
580
 
494
- def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
581
+ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
495
582
  """The "Show sample" overlay: one point per row of ``panel.sample`` inside
496
583
  the mark it belongs to, joined into a line per identity when
497
584
  ``ResolvedPlot.sample_join`` says so.
@@ -503,18 +590,36 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
503
590
  (``ResolvedPlot.sample_color``, the legend lists the levels —
504
591
  ``_sample_legend_handles``), or each point its mark's, with a line that
505
592
  crosses mark colours drawn neutral under points painted one by one.
506
- Drawn after the marks, on top.
593
+ Drawn after the marks, on top. Joined runs are one line each; lone
594
+ points are one scatter per colour per panel. Returns ``(runs, artists)``
595
+ for the render's log line.
507
596
  """
508
597
  sample = getattr(panel, "sample", None)
509
598
  if sample is None or sample.empty or sample_dropped_reason(panel, resolved):
510
- return
599
+ return 0, 0
511
600
  weight = sample_weight(resolved.spec.style)
512
- for run in sample_runs(sample, resolved):
601
+ runs = sample_runs(sample, resolved)
602
+ # Lone points (a points-only overlay, or a one-point run of a joined
603
+ # one) gathered per colour and drawn as ONE scatter each, after the
604
+ # lines: one artist per run made a figure whose innermost tick is a
605
+ # between-subject factor (group) draw ~1400 scatters, and every layout
606
+ # pass walks every artist — 6.6 s per render, 46 s with the auto text
607
+ # size's trials (scidb.log 2026-10-15 12:21). Same points, same paint.
608
+ lone: dict[str, tuple[list[float], list[float]]] = {}
609
+ artists = 0
610
+ for run in runs:
513
611
  positions = sample_positions(run.rows, resolved, run.identity)
514
612
  order = np.argsort(positions, kind="stable")
515
613
  values = run.rows[resolved.encoding.y].to_numpy(dtype=float)[order]
516
614
  joined = resolved.sample_join and len(run.rows) > 1
517
- if joined and run.uniform:
615
+ if not joined:
616
+ for x, y, i in zip(positions[order], values, order):
617
+ xs, ys = lone.setdefault(run.point_colors[i], ([], []))
618
+ xs.append(float(x))
619
+ ys.append(float(y))
620
+ continue
621
+ artists += 2 if not run.uniform else 1
622
+ if run.uniform:
518
623
  ax.plot(
519
624
  positions[order],
520
625
  values,
@@ -528,18 +633,17 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
528
633
  zorder=3,
529
634
  )
530
635
  continue
531
- if joined:
532
- # The points sit on marks of different colours: the line (the
533
- # run, zorder 3) in the neutral colour, the points on top of it
534
- # each in its own mark's colour (gid: part of the line's run).
535
- ax.plot(
536
- positions[order],
537
- values,
538
- color=run.line_color,
539
- alpha=SAMPLE_ALPHA,
540
- linewidth=weight.line_pt,
541
- zorder=3,
542
- )
636
+ # The points sit on marks of different colours: the line (the
637
+ # run, zorder 3) in the neutral colour, the points on top of it
638
+ # each in its own mark's colour (gid: part of the line's run).
639
+ ax.plot(
640
+ positions[order],
641
+ values,
642
+ color=run.line_color,
643
+ alpha=SAMPLE_ALPHA,
644
+ linewidth=weight.line_pt,
645
+ zorder=3,
646
+ )
543
647
  ax.scatter(
544
648
  positions[order],
545
649
  values,
@@ -548,10 +652,23 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> None:
548
652
  alpha=SAMPLE_ALPHA,
549
653
  edgecolors=SAMPLE_EDGE_COLOR,
550
654
  linewidths=0.5,
551
- zorder=3.1 if joined else 3,
552
- gid=SAMPLE_LINE_POINTS_GID if joined else None,
655
+ zorder=3.1,
656
+ gid=SAMPLE_LINE_POINTS_GID,
553
657
  )
658
+ for color, (xs, ys) in lone.items():
659
+ ax.scatter(
660
+ xs,
661
+ ys,
662
+ s=weight.marker_area,
663
+ color=color,
664
+ alpha=SAMPLE_ALPHA,
665
+ edgecolors=SAMPLE_EDGE_COLOR,
666
+ linewidths=0.5,
667
+ zorder=3,
668
+ )
669
+ artists += len(lone)
554
670
  _sample_legend_handles(ax, resolved)
671
+ return len(runs), artists
555
672
 
556
673
 
557
674
  def _sample_legend_handles(ax, resolved: ResolvedPlot) -> None:
@@ -1791,7 +1908,9 @@ def layout_decisions(
1791
1908
  style = resolved.spec.style
1792
1909
  width = float(width_in or style.width)
1793
1910
  height = float(height_in or style.height)
1794
- with Log.timer("layout_decisions", layer=LAYER, extra=f"{width:.2f}x{height:.2f}in"):
1911
+ with _one_figure_at_a_time("layout_decisions"), Log.timer(
1912
+ "layout_decisions", layer=LAYER, extra=f"{width:.2f}x{height:.2f}in"
1913
+ ):
1795
1914
  # Auto text is chosen at the size being decided (the pane's, in Fit
1796
1915
  # pane), keyed on the caller's object so the preview's later
1797
1916
  # render_plotly and a Save of the same resolve reuse it.
@@ -1830,6 +1949,11 @@ def layout_report(resolved: ResolvedPlot):
1830
1949
  measurements the auto text size judges a reference by. Renders, reads
1831
1950
  what the export already records on the figure, measures the drawn tick
1832
1951
  labels and y titles, closes it."""
1952
+ with _one_figure_at_a_time("layout_report"):
1953
+ return _layout_report(resolved)
1954
+
1955
+
1956
+ def _layout_report(resolved: ResolvedPlot):
1833
1957
  import matplotlib.pyplot as plt
1834
1958
 
1835
1959
  from ..autosize import LayoutReport
@@ -698,7 +698,14 @@ def _sample_traces(
698
698
  listed = overlay_in_legend(resolved.spec, resolved.sample_color)
699
699
  weight = sample_weight(resolved.spec.style)
700
700
  traces: list[dict] = []
701
- for run in sample_runs(sample, resolved):
701
+ # Lone points (not joined: a points-only overlay, or a one-point run)
702
+ # of one legend group and colour share ONE markers trace — one trace per
703
+ # run was ~1400 traces when the innermost tick is a between-subject
704
+ # factor (scidb.log 2026-10-15), each one an SVG group in the browser.
705
+ # Same points, same legend group (hiding a level still hides them).
706
+ lone: dict[tuple[str, str], dict] = {}
707
+ runs = sample_runs(sample, resolved)
708
+ for run in runs:
702
709
  level, rows, identity = run.level, run.rows, run.identity
703
710
  # The overlay's own colour key when it has one, else the mark's
704
711
  # colour — none when the run crosses the marks' colours.
@@ -714,22 +721,36 @@ def _sample_traces(
714
721
  levels = [
715
722
  str(v).replace(LEAF_SEPARATOR, " · ") for v in rows[resolved.encoding.x].to_numpy()
716
723
  ]
724
+ xs = [None if np.isnan(v) else float(v) for v in positions[order]]
725
+ ys = _values(rows[resolved.encoding.y].iloc[order])
726
+ custom = [f"{levels[i]}<br>{hover[i]}" if hover[i] else levels[i] for i in order]
727
+ name = label if own_color else (str(identity) if identity is not None else label)
728
+ joined_run = resolved.sample_join and len(rows) > 1
729
+ batch = lone.get((legend_group, run.line_color)) if not joined_run and run.uniform else None
730
+ if batch is not None:
731
+ batch["x"].extend(xs)
732
+ batch["y"].extend(ys)
733
+ batch["customdata"].extend(custom)
734
+ if batch["name"] != name:
735
+ # Several identities in one trace: name the trace by its level.
736
+ batch["name"] = label
737
+ continue
717
738
  show_legend = listed and legend_on and legend_group not in seen
718
739
  if show_legend:
719
740
  seen.add(legend_group)
720
741
  traces.append(
721
742
  {
722
743
  "type": "scatter",
723
- "mode": "lines+markers" if resolved.sample_join and len(rows) > 1 else "markers",
724
- "name": label if own_color else (str(identity) if identity is not None else label),
744
+ "mode": "lines+markers" if joined_run else "markers",
745
+ "name": name,
725
746
  "legendgroup": legend_group,
726
747
  "showlegend": show_legend,
727
748
  # After every mark entry whatever panel first drew it.
728
749
  "legendrank": 2000,
729
750
  "xaxis": x_axis,
730
751
  "yaxis": y_axis,
731
- "x": [None if np.isnan(v) else float(v) for v in positions[order]],
732
- "y": _values(rows[resolved.encoding.y].iloc[order]),
752
+ "x": xs,
753
+ "y": ys,
733
754
  "marker": {
734
755
  # One colour, or each point its own mark's when the
735
756
  # run crosses the marks' colours (base.sample_runs).
@@ -742,23 +763,24 @@ def _sample_traces(
742
763
  "line": {"color": run.line_color, "width": weight.line_pt},
743
764
  "opacity": SAMPLE_ALPHA,
744
765
  # Hover-only (see `_level_hover` on why not `text`).
745
- "customdata": [
746
- f"{levels[i]}<br>{hover[i]}" if hover[i] else levels[i] for i in order
747
- ],
766
+ "customdata": custom,
748
767
  "hovertemplate": "%{customdata}<br>%{y}<extra>" + label + "</extra>",
749
768
  }
750
769
  )
770
+ if not joined_run and run.uniform:
771
+ lone[(legend_group, run.line_color)] = traces[-1]
751
772
  # The one line that tells "no lines" apart: joined runs vs one-point runs
752
773
  # (a run is one identity inside one bracket — base.sample_runs — never
753
774
  # split by the marks' colour), and what decided the join.
754
775
  joined = sum(1 for t in traces if t["mode"] == "lines+markers")
755
776
  Log.debug(
756
- "sample overlay panel %s: %d run(s), %d joined, %d single-point; "
777
+ "sample overlay panel %s: %d run(s) as %d trace(s), %d joined, %d single-point; "
757
778
  "join=%s (%s), own colour=%r, mark colour=%r",
758
779
  panel.title or "unfaceted",
780
+ len(runs),
759
781
  len(traces),
760
782
  joined,
761
- sum(1 for t in traces if len(t["x"]) == 1),
783
+ sum(1 for run in runs if len(run.rows) == 1),
762
784
  resolved.sample_join,
763
785
  resolved.sample_join_reason,
764
786
  resolved.sample_color,
@@ -776,13 +776,36 @@ class VariantSet:
776
776
  #: would claim every ``Force`` row too, drawing it once per variant. Naming
777
777
  #: the variable makes "not applicable here" and "stale" distinguishable.
778
778
  variable: str | None = None
779
+ #: Code versions this row is FROZEN to: ``{"Code:fn": ["v1", ...]}``. Per
780
+ #: schema location the row keeps the highest of these ordinals, so a save
781
+ #: spanning v1/v2 across locations reopens exactly as drawn.
782
+ #:
783
+ #: Filled in by ``scistackplotdb.saved`` when a saved plot is reopened,
784
+ #: translated from the function HASHES stored at save time (ordinals
785
+ #: renumber after a real delete; hashes do not). Plain data so this
786
+ #: package stays scidb-free. A frozen column overrides the row's own
787
+ #: selection key for that column and its latest flag — "current" AND
788
+ #: "v1" would select nothing. See docs/claude/variant-row-selection.md.
789
+ frozen: dict[str, list[str]] = field(default_factory=dict)
790
+ #: True: this row always draws the current code and a saved plot never
791
+ #: freezes it. Set by answering "switch to latest" when a saved plot finds
792
+ #: newer data (``scistackplotdb.saved.follow_latest_saved_plot``).
793
+ follow_latest: bool = False
779
794
 
780
795
  def to_dict(self) -> dict:
781
- return {
796
+ out = {
782
797
  "name": self.name,
783
798
  "selection": dict(self.selection),
784
799
  "variable": self.variable,
785
800
  }
801
+ # Written only when set, so the common row (and every generated
802
+ # docstring, plan-cache diff and stored spec that holds one) reads
803
+ # exactly as it did before these existed.
804
+ if self.frozen:
805
+ out["frozen"] = {column: list(levels) for column, levels in self.frozen.items()}
806
+ if self.follow_latest:
807
+ out["follow_latest"] = True
808
+ return out
786
809
 
787
810
  @classmethod
788
811
  def from_dict(cls, raw: dict) -> "VariantSet":
@@ -790,6 +813,11 @@ class VariantSet:
790
813
  name=raw.get("name"),
791
814
  selection=dict(raw.get("selection") or {}),
792
815
  variable=raw.get("variable"),
816
+ frozen={
817
+ str(column): [str(level) for level in (levels or [])]
818
+ for column, levels in (raw.get("frozen") or {}).items()
819
+ },
820
+ follow_latest=bool(raw.get("follow_latest", False)),
793
821
  )
794
822
 
795
823
 
@@ -289,22 +289,208 @@ def variant_set_mask(
289
289
  return mask
290
290
 
291
291
 
292
- def row_mask(
292
+ #: Where a row's effective selection came from — the third field of every
293
+ #: ``log_effective_selections`` entry, so a log reader can tell "the user said
294
+ #: this" from "nothing was said, so the source's current records".
295
+ SOURCE_STATED = "stated"
296
+ SOURCE_DEFAULT = "source default"
297
+ SOURCE_FROZEN = "frozen"
298
+
299
+
300
+ def _names_function_choice(selection: dict[str, Any], latest_column: str | None) -> bool:
301
+ """Whether a selection already says which CODE to draw: a code/run axis,
302
+ or the source's latest flag."""
303
+ return any(
304
+ is_function_axis(key) or (latest_column is not None and key == latest_column)
305
+ for key in selection
306
+ )
307
+
308
+
309
+ def active_frozen(variant: VariantSet, table: LongTable) -> dict[str, list[str]]:
310
+ """The row's frozen columns that this table can apply.
311
+
312
+ ``follow_latest`` wins over a freeze: the user asked for current code.
313
+ A frozen column the frame does not carry (the function is back to one
314
+ version, so there is no axis) is ignored — there is nothing to choose
315
+ between, and ``scistackplotdb.saved`` only writes columns that exist.
316
+ """
317
+ if variant.follow_latest or not variant.frozen:
318
+ return {}
319
+ columns = table.frame.columns
320
+ return {
321
+ column: list(levels)
322
+ for column, levels in variant.frozen.items()
323
+ if column in columns and levels
324
+ }
325
+
326
+
327
+ def effective_selection_with_source(
328
+ variant: VariantSet, table: LongTable
329
+ ) -> tuple[dict[str, Any], str]:
330
+ """``(selection, source)`` — what this row's selection really is.
331
+
332
+ The single answer to "which code does a row draw?", consumed by
333
+ :func:`row_mask` (and so by the figure AND the row counts/banner),
334
+ :func:`_answered`, and the no-rows path. Until 2026-10-15 an empty row 0
335
+ meant "the source default" to the figure and "everything" to the banner,
336
+ which reported pooling the figure never did. See
337
+ ``docs/claude/variant-row-selection.md``.
338
+
339
+ * **frozen** — a reopened saved plot's code versions. The frozen columns
340
+ and the latest flag leave the selection (``frozen_mask`` answers them;
341
+ "current" AND "v1" would select nothing).
342
+ * **stated** — the selection already names a code/run axis or the latest
343
+ flag, or the source has no default: used as written. Pooling versions
344
+ is reachable only this way, by naming them.
345
+ * **source default** — the selection says nothing about code, so the
346
+ source's ``default_pin`` (the per-location latest flag) is added. A row
347
+ that only says ``low_hz=20``, or nothing at all, draws current code —
348
+ never every version at once.
349
+ """
350
+ return _effective(
351
+ variant.selection,
352
+ active_frozen(variant, table),
353
+ latest_column=table.latest_column,
354
+ default_pin=table.default_pin,
355
+ )
356
+
357
+
358
+ def _effective(
359
+ raw: dict[str, Any],
360
+ frozen: dict[str, list[str]],
361
+ *,
362
+ latest_column: str | None,
363
+ default_pin: dict[str, Any] | None,
364
+ ) -> tuple[dict[str, Any], str]:
365
+ """The rule itself, with the table's facts passed in — shared by the
366
+ table-bound reader above and by :func:`export_selection`, which has no
367
+ table, so the preview and the exported code cannot apply two rules."""
368
+ selection = dict(raw)
369
+ if frozen:
370
+ for column in frozen:
371
+ selection.pop(column, None)
372
+ if latest_column:
373
+ selection.pop(latest_column, None)
374
+ return selection, SOURCE_FROZEN
375
+ pin = dict(default_pin or {})
376
+ if not pin or _names_function_choice(selection, latest_column):
377
+ return selection, SOURCE_STATED
378
+ return {**pin, **selection}, SOURCE_DEFAULT
379
+
380
+
381
+ def export_selection(
382
+ variant: VariantSet,
383
+ *,
384
+ latest_column: str | None,
385
+ default_pin: dict[str, Any] | None,
386
+ ) -> dict[str, Any]:
387
+ """The selection exported code loads one row through — what the preview
388
+ applies, spelled for a ``Variant(...)`` wrapper.
389
+
390
+ ``latest_column`` and ``default_pin`` are the TABLE's (the same facts
391
+ :func:`effective_selection_with_source` reads), so the export adds the
392
+ latest flag exactly when the preview does. A table with no default — a
393
+ single-generation variable, a CSV — exports the row as written, as the preview draws it. A freeze comes back as the frozen
394
+ ordinal(s) on their ``Code:`` columns.
395
+
396
+ Known limit: a freeze of SEVERAL ordinals is "per location, the highest of
397
+ these" in the preview (:func:`frozen_mask`); a ``Variant`` list is "any
398
+ of". The WARN below says so in the log whenever that happens.
399
+ """
400
+ frozen = (
401
+ {}
402
+ if variant.follow_latest
403
+ else {column: list(levels) for column, levels in variant.frozen.items() if levels}
404
+ )
405
+ selection, _source = _effective(
406
+ variant.selection,
407
+ frozen,
408
+ latest_column=latest_column,
409
+ default_pin=default_pin,
410
+ )
411
+ for column, levels in frozen.items():
412
+ selection[column] = levels[0] if len(levels) == 1 else list(levels)
413
+ if len(levels) > 1:
414
+ Log.warn(
415
+ "export: %s is frozen to %s — the preview keeps the highest of "
416
+ "these per schema location, the exported Variant(...) keeps any "
417
+ "of them; re-save the plot on one version for an exact export",
418
+ column,
419
+ levels,
420
+ layer=LAYER,
421
+ )
422
+ return selection
423
+
424
+
425
+ def effective_selection(variant: VariantSet, table: LongTable) -> dict[str, Any]:
426
+ """The selection half of :func:`effective_selection_with_source`."""
427
+ return effective_selection_with_source(variant, table)[0]
428
+
429
+
430
+ def _ordinal_rank(value: str) -> int:
431
+ match = _ORDINAL.match(value)
432
+ return int(match.group(1)) if match else 0
433
+
434
+
435
+ def frozen_mask(
293
436
  frame: pd.DataFrame,
437
+ frozen: dict[str, list[str]],
438
+ table: LongTable,
439
+ *,
440
+ within: pd.Series | None = None,
441
+ ) -> pd.Series:
442
+ """Rows a freeze keeps: per frozen column, only the frozen ordinals, and
443
+ within each schema location only the HIGHEST of them present.
444
+
445
+ That is "latest as of the save" reproduced from ordinals alone: a save
446
+ made while subject 01 was on v2 and subject 02 still on v1 froze
447
+ ``[v1, v2]``; reopened after a v3 run, 01 draws v2 and 02 draws v1 — not
448
+ both at 01, which a plain "any of" would pool.
449
+
450
+ ``within`` restricts which rows compete (the row's selection and variable
451
+ have already narrowed them), so the per-location best is picked among
452
+ rows the row could draw at all. Locations are the table's schema levels
453
+ plus the stacked ``Variable`` column, so two variables never compete.
454
+ """
455
+ within = within if within is not None else pd.Series(True, index=frame.index)
456
+ mask = pd.Series(True, index=frame.index)
457
+ keys = [key for key in table.schema_levels if key in frame.columns]
458
+ if VARIABLE_COLUMN in frame.columns:
459
+ keys.append(VARIABLE_COLUMN)
460
+ for column, ordinals in frozen.items():
461
+ if column not in frame.columns:
462
+ continue
463
+ values = frame[column].astype(str)
464
+ allowed = values.isin({str(level) for level in ordinals})
465
+ rank = values.map(_ordinal_rank).where(allowed & within & mask, -1)
466
+ if keys:
467
+ best = rank.groupby(
468
+ [frame[key].astype(str) for key in keys], sort=False
469
+ ).transform("max")
470
+ else:
471
+ best = pd.Series(rank.max(), index=frame.index)
472
+ mask &= allowed & (rank == best)
473
+ return mask
474
+
475
+
476
+ def row_mask(
477
+ table: LongTable,
294
478
  spec: PlotSpec,
295
479
  variant: VariantSet,
296
- *,
297
- latest_column: str | None = None,
298
480
  ) -> pd.Series:
299
- """Rows one variant ROW claims: its selection, within its own variable.
481
+ """Rows one variant ROW claims: its effective selection, its freeze, within
482
+ its own variable.
300
483
 
301
484
  The single definition, called by :func:`apply_variant_sets` (which builds
302
485
  the figure) and by ``capability.variant_summary`` (which reports how many
303
- rows each variant matched). Those two disagreeing would put a number on
304
- screen the figure contradicts — the same reason
305
- :func:`variant_set_mask` is public.
486
+ rows each variant matched, and whether it spans versions). Those two
487
+ disagreeing put a "mixes v1 | v2" banner above a figure drawing v2 only
488
+ (2026-10-15) — the same reason :func:`variant_set_mask` is public.
306
489
  """
307
- mask = variant_set_mask(frame, variant.selection, latest_column=latest_column)
490
+ frame = table.frame
491
+ mask = variant_set_mask(
492
+ frame, effective_selection(variant, table), latest_column=table.latest_column
493
+ )
308
494
  if VARIABLE_COLUMN in frame.columns:
309
495
  # A row claims only its own variable's rows. Without this, a row
310
496
  # pinning `Code:filterEMG=v1` names nothing about a second variable —
@@ -314,6 +500,9 @@ def row_mask(
314
500
  mask &= frame[VARIABLE_COLUMN].astype(str) == (
315
501
  variant.variable or spec.y_measure
316
502
  )
503
+ frozen = active_frozen(variant, table)
504
+ if frozen:
505
+ mask &= frozen_mask(frame, frozen, table, within=mask)
317
506
  return mask
318
507
 
319
508
 
@@ -323,6 +512,7 @@ def auto_label(
323
512
  index: int = 0,
324
513
  variable: str | None = None,
325
514
  latest_column: str | None = None,
515
+ frozen: dict[str, list[str]] | None = None,
326
516
  ) -> str:
327
517
  """The name a variant gets until the user types over it.
328
518
 
@@ -342,9 +532,17 @@ def auto_label(
342
532
  An empty selection with no variable is ``(not set)``, not "all variants":
343
533
  an unfilled row is inert (:func:`defined_sets`), so a label promising every
344
534
  variant would describe a row that contributes nothing.
535
+
536
+ ``frozen`` (a reopened saved plot's versions) is spelled ``load_cbrace v1
537
+ (saved)``, and replaces the latest flag and the frozen columns' own
538
+ selection keys, exactly as :func:`effective_selection_with_source` does —
539
+ "current · load_cbrace v1" would describe something the row does not draw.
345
540
  """
346
541
  parts: list[str] = []
542
+ frozen = {column: levels for column, levels in (frozen or {}).items() if levels}
347
543
  for column, value in selection.items():
544
+ if frozen and (column in frozen or (latest_column and column == latest_column)):
545
+ continue
348
546
  text = _level_text(value)
349
547
  if latest_column and column == latest_column:
350
548
  # A False flag is "not the current code", which is a real thing to
@@ -357,6 +555,8 @@ def auto_label(
357
555
  # function is usually obvious from context in a legend, the
358
556
  # parameter never is.
359
557
  parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
558
+ for column, levels in frozen.items():
559
+ parts.append(f"{function_of_axis(column)} {_level_text(levels)} (saved)")
360
560
 
361
561
  if variable:
362
562
  return f"{variable} · {' · '.join(parts)}" if parts else variable
@@ -392,11 +592,18 @@ def label_variable(
392
592
  """
393
593
  if variant_set.variable:
394
594
  return variant_set.variable
395
- if variant_set.selection or index == 0:
595
+ if variant_set.selection or variant_set.frozen or index == 0:
396
596
  return primary
397
597
  return None
398
598
 
399
599
 
600
+ def says_something(variant_set: VariantSet) -> bool:
601
+ """A selection, a variable, or a frozen pin — any of them is a statement
602
+ about the data. A freeze especially: a reopened saved plot's empty row 0
603
+ with ``frozen={"Code:f": ["v1"]}`` must not fall back to "current"."""
604
+ return bool(variant_set.selection or variant_set.variable or variant_set.frozen)
605
+
606
+
400
607
  def is_stated(variant_set: VariantSet, index: int) -> bool:
401
608
  """Whether this row says anything — what the panel's "not set" tag reads.
402
609
 
@@ -407,7 +614,7 @@ def is_stated(variant_set: VariantSet, index: int) -> bool:
407
614
  empty row 0 states "the primary measure" and needs no ``Variant`` factor to
408
615
  express, so it is stated but not a defined set.
409
616
  """
410
- return index == 0 or bool(variant_set.selection) or bool(variant_set.variable)
617
+ return index == 0 or says_something(variant_set)
411
618
 
412
619
 
413
620
  def set_name(
@@ -429,6 +636,7 @@ def set_name(
429
636
  index=index,
430
637
  variable=label_variable(variant_set, index, primary),
431
638
  latest_column=latest_column,
639
+ frozen=None if variant_set.follow_latest else variant_set.frozen,
432
640
  )
433
641
 
434
642
 
@@ -462,7 +670,7 @@ def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
462
670
  With no concrete row anywhere there is nothing to fold: one unnarrowed
463
671
  series needs no ``Variant`` factor to express.
464
672
  """
465
- if not any(variant.selection or variant.variable for variant in sets):
673
+ if not any(says_something(variant) for variant in sets):
466
674
  return []
467
675
  return [
468
676
  variant for index, variant in enumerate(sets) if is_stated(variant, index)
@@ -529,12 +737,17 @@ def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
529
737
  # WITHIN a variable and never across.
530
738
  per_variant: list[set[str]] = []
531
739
  for variant in sets:
740
+ # The EFFECTIVE selection (and the freeze), not the raw one: what a row
741
+ # answers is what it draws, by the same owner `row_mask` uses.
532
742
  per_variant.append(
533
743
  set(
534
744
  resolve_selection(
535
- frame, variant.selection, latest_column=table.latest_column
745
+ frame,
746
+ effective_selection(variant, table),
747
+ latest_column=table.latest_column,
536
748
  )
537
749
  )
750
+ | set(active_frozen(variant, table))
538
751
  )
539
752
  return answered | set.intersection(*per_variant)
540
753
 
@@ -744,10 +957,20 @@ def _apply_default_pin(table: LongTable) -> LongTable:
744
957
  record and its superseded whole-table record together, 17 rows where there
745
958
  should be one. An unnamed plot now reads what an unnamed load reads.
746
959
 
960
+ The selection comes from :func:`effective_selection_with_source` for an
961
+ empty row — the SAME owner a stated row goes through, so the figure and
962
+ the row counts/banner (``capability.variant_summary``) cannot disagree
963
+ about what an empty row draws again (2026-10-15).
964
+
965
+ The function axes the pin answered **leave the factor list**, as they do
966
+ for any defined row (:func:`_answered`). Left in, a ``Code:fn`` column
967
+ whose rows were already filtered to one version got no role, defaulted to
968
+ ITERATE, and titled the figure ``Code:fn=v2``.
969
+
747
970
  No pin (one generation, a CSV source) leaves the table untouched, so a
748
971
  project with nothing superseded behaves exactly as before.
749
972
  """
750
- pin = dict(table.default_pin or {})
973
+ pin, _source = effective_selection_with_source(VariantSet(), table)
751
974
  frame = table.frame
752
975
  if not pin:
753
976
  Log.info(
@@ -758,18 +981,23 @@ def _apply_default_pin(table: LongTable) -> LongTable:
758
981
  return table
759
982
  mask = variant_set_mask(frame, pin, latest_column=table.latest_column)
760
983
  kept = int(mask.sum())
984
+ answered = [
985
+ factor.name for factor in table.factors if is_function_axis(factor.name)
986
+ ]
761
987
  Log.info(
762
988
  "variant selection: no variant rows — the source default %s keeps %d of "
763
- "%d row(s)%s",
989
+ "%d row(s)%s; answers %s",
764
990
  pin,
765
991
  kept,
766
992
  len(frame),
767
993
  "" if kept == len(frame) else f" ({len(frame) - kept} superseded row(s) dropped)",
994
+ answered or "no function axis",
768
995
  layer=LAYER,
769
996
  )
770
- if kept == len(frame):
997
+ factors = [f for f in table.factors if f.name not in answered]
998
+ if kept == len(frame) and not answered:
771
999
  return table
772
- return replace(table, frame=frame[mask].copy())
1000
+ return replace(table, frame=frame[mask].copy(), factors=factors)
773
1001
 
774
1002
 
775
1003
  def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
@@ -805,7 +1033,7 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
805
1033
  counts: list[int] = []
806
1034
 
807
1035
  for name, variant in zip(names, sets, strict=True):
808
- mask = row_mask(frame, spec, variant, latest_column=table.latest_column)
1036
+ mask = row_mask(table, spec, variant)
809
1037
  fresh = mask & assigned.isna()
810
1038
  counts.append(int(fresh.sum()))
811
1039
  assigned[fresh] = name
@@ -813,7 +1041,9 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
813
1041
  # Code axes leave the factor list unconditionally, so a variant that
814
1042
  # quietly straddles two versions has to say so here — this is the log
815
1043
  # half of what the GUI shows on the row.
816
- spans = spanned_code_axes(frame[fresh], variant.selection, table)
1044
+ spans = spanned_code_axes(
1045
+ frame[fresh], effective_selection(variant, table), table
1046
+ )
817
1047
  if spans:
818
1048
  Log.warn(
819
1049
  "variant %r was built by MORE THAN ONE version of the code — "
@@ -858,5 +1088,36 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
858
1088
  dict(zip(names, counts, strict=True)),
859
1089
  layer=LAYER,
860
1090
  )
1091
+ log_effective_selections(names, sets, counts, table)
861
1092
 
862
1093
  return replace(table, frame=kept, factors=factors)
1094
+
1095
+
1096
+ def log_effective_selections(
1097
+ names: list[str],
1098
+ sets: list[VariantSet],
1099
+ counts: list[int],
1100
+ table: LongTable,
1101
+ ) -> None:
1102
+ """One INFO naming, per row, what it REALLY selected and why.
1103
+
1104
+ ``name: stated|source default|frozen <selection> [frozen <cols>] -> N
1105
+ row(s)``. The diagnostic for the 2026-10-15 class of bug: if a banner or
1106
+ a count ever disagrees with the figure again, this line shows which
1107
+ selection the figure applied, beside whatever the other reader claims.
1108
+ """
1109
+ parts = []
1110
+ for name, variant, count in zip(names, sets, counts, strict=True):
1111
+ selection, source = effective_selection_with_source(variant, table)
1112
+ frozen = active_frozen(variant, table)
1113
+ parts.append(
1114
+ f"{name!r}: {source} {selection}"
1115
+ + (f" frozen {frozen}" if frozen else "")
1116
+ + (" (follow latest)" if variant.follow_latest else "")
1117
+ + f" -> {count} row(s)"
1118
+ )
1119
+ Log.info(
1120
+ "variant rows' effective selections: %s",
1121
+ "; ".join(parts),
1122
+ layer=LAYER,
1123
+ )
File without changes
File without changes