scistackplot 0.1.36__tar.gz → 0.1.37__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.37}/PKG-INFO +2 -2
  2. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/aliases.py +21 -2
  3. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/capability.py +22 -8
  4. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/codegen.py +14 -1
  5. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/reduce.py +1 -1
  6. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/render/mpl.py +146 -22
  7. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/render/plotly_.py +32 -10
  8. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/spec.py +29 -1
  9. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/variants.py +280 -18
  10. {scistackplot-0.1.36 → scistackplot-0.1.37}/.gitignore +0 -0
  11. {scistackplot-0.1.36 → scistackplot-0.1.37}/README.md +0 -0
  12. {scistackplot-0.1.36 → scistackplot-0.1.37}/pyproject.toml +0 -0
  13. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/__init__.py +0 -0
  14. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/autosize.py +0 -0
  15. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/cell.py +0 -0
  16. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/colors.py +0 -0
  17. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/compare.py +0 -0
  18. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/custom.py +0 -0
  19. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/dedup.py +0 -0
  20. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/diffbars.py +0 -0
  21. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/export.py +0 -0
  22. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/figsize.py +0 -0
  23. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/figure_file.py +0 -0
  24. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/framesize.py +0 -0
  25. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/groups.py +0 -0
  26. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/numeric.py +0 -0
  27. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/panels.py +0 -0
  28. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/paper.py +0 -0
  29. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/presets.py +0 -0
  30. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/reducer.py +0 -0
  31. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/render/__init__.py +0 -0
  32. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/render/base.py +0 -0
  33. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/resolved.py +0 -0
  34. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/restore.py +0 -0
  35. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/roles.py +0 -0
  36. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/series_stats.py +0 -0
  37. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/shape.py +0 -0
  38. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/sources/__init__.py +0 -0
  39. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/sources/base.py +0 -0
  40. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/sources/csv.py +0 -0
  41. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/sources/frame.py +0 -0
  42. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/spaghetti.py +0 -0
  43. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/table.py +0 -0
  44. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/textsize.py +0 -0
  45. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/ticklabels.py +0 -0
  46. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/weights.py +0 -0
  47. {scistackplot-0.1.36 → scistackplot-0.1.37}/src/scistackplot/xaxis.py +0 -0
  48. {scistackplot-0.1.36 → scistackplot-0.1.37}/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.37
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.37
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,209 @@ 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
+ stacked multi-variable table, a single-generation variable — exports the
394
+ row as written, as the preview draws it. A freeze comes back as the frozen
395
+ ordinal(s) on their ``Code:`` columns.
396
+
397
+ Known limit: a freeze of SEVERAL ordinals is "per location, the highest of
398
+ these" in the preview (:func:`frozen_mask`); a ``Variant`` list is "any
399
+ of". The WARN below says so in the log whenever that happens.
400
+ """
401
+ frozen = (
402
+ {}
403
+ if variant.follow_latest
404
+ else {column: list(levels) for column, levels in variant.frozen.items() if levels}
405
+ )
406
+ selection, _source = _effective(
407
+ variant.selection,
408
+ frozen,
409
+ latest_column=latest_column,
410
+ default_pin=default_pin,
411
+ )
412
+ for column, levels in frozen.items():
413
+ selection[column] = levels[0] if len(levels) == 1 else list(levels)
414
+ if len(levels) > 1:
415
+ Log.warn(
416
+ "export: %s is frozen to %s — the preview keeps the highest of "
417
+ "these per schema location, the exported Variant(...) keeps any "
418
+ "of them; re-save the plot on one version for an exact export",
419
+ column,
420
+ levels,
421
+ layer=LAYER,
422
+ )
423
+ return selection
424
+
425
+
426
+ def effective_selection(variant: VariantSet, table: LongTable) -> dict[str, Any]:
427
+ """The selection half of :func:`effective_selection_with_source`."""
428
+ return effective_selection_with_source(variant, table)[0]
429
+
430
+
431
+ def _ordinal_rank(value: str) -> int:
432
+ match = _ORDINAL.match(value)
433
+ return int(match.group(1)) if match else 0
434
+
435
+
436
+ def frozen_mask(
293
437
  frame: pd.DataFrame,
438
+ frozen: dict[str, list[str]],
439
+ table: LongTable,
440
+ *,
441
+ within: pd.Series | None = None,
442
+ ) -> pd.Series:
443
+ """Rows a freeze keeps: per frozen column, only the frozen ordinals, and
444
+ within each schema location only the HIGHEST of them present.
445
+
446
+ That is "latest as of the save" reproduced from ordinals alone: a save
447
+ made while subject 01 was on v2 and subject 02 still on v1 froze
448
+ ``[v1, v2]``; reopened after a v3 run, 01 draws v2 and 02 draws v1 — not
449
+ both at 01, which a plain "any of" would pool.
450
+
451
+ ``within`` restricts which rows compete (the row's selection and variable
452
+ have already narrowed them), so the per-location best is picked among
453
+ rows the row could draw at all. Locations are the table's schema levels
454
+ plus the stacked ``Variable`` column, so two variables never compete.
455
+ """
456
+ within = within if within is not None else pd.Series(True, index=frame.index)
457
+ mask = pd.Series(True, index=frame.index)
458
+ keys = [key for key in table.schema_levels if key in frame.columns]
459
+ if VARIABLE_COLUMN in frame.columns:
460
+ keys.append(VARIABLE_COLUMN)
461
+ for column, ordinals in frozen.items():
462
+ if column not in frame.columns:
463
+ continue
464
+ values = frame[column].astype(str)
465
+ allowed = values.isin({str(level) for level in ordinals})
466
+ rank = values.map(_ordinal_rank).where(allowed & within & mask, -1)
467
+ if keys:
468
+ best = rank.groupby(
469
+ [frame[key].astype(str) for key in keys], sort=False
470
+ ).transform("max")
471
+ else:
472
+ best = pd.Series(rank.max(), index=frame.index)
473
+ mask &= allowed & (rank == best)
474
+ return mask
475
+
476
+
477
+ def row_mask(
478
+ table: LongTable,
294
479
  spec: PlotSpec,
295
480
  variant: VariantSet,
296
- *,
297
- latest_column: str | None = None,
298
481
  ) -> pd.Series:
299
- """Rows one variant ROW claims: its selection, within its own variable.
482
+ """Rows one variant ROW claims: its effective selection, its freeze, within
483
+ its own variable.
300
484
 
301
485
  The single definition, called by :func:`apply_variant_sets` (which builds
302
486
  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.
487
+ rows each variant matched, and whether it spans versions). Those two
488
+ disagreeing put a "mixes v1 | v2" banner above a figure drawing v2 only
489
+ (2026-10-15) — the same reason :func:`variant_set_mask` is public.
306
490
  """
307
- mask = variant_set_mask(frame, variant.selection, latest_column=latest_column)
491
+ frame = table.frame
492
+ mask = variant_set_mask(
493
+ frame, effective_selection(variant, table), latest_column=table.latest_column
494
+ )
308
495
  if VARIABLE_COLUMN in frame.columns:
309
496
  # A row claims only its own variable's rows. Without this, a row
310
497
  # pinning `Code:filterEMG=v1` names nothing about a second variable —
@@ -314,6 +501,9 @@ def row_mask(
314
501
  mask &= frame[VARIABLE_COLUMN].astype(str) == (
315
502
  variant.variable or spec.y_measure
316
503
  )
504
+ frozen = active_frozen(variant, table)
505
+ if frozen:
506
+ mask &= frozen_mask(frame, frozen, table, within=mask)
317
507
  return mask
318
508
 
319
509
 
@@ -323,6 +513,7 @@ def auto_label(
323
513
  index: int = 0,
324
514
  variable: str | None = None,
325
515
  latest_column: str | None = None,
516
+ frozen: dict[str, list[str]] | None = None,
326
517
  ) -> str:
327
518
  """The name a variant gets until the user types over it.
328
519
 
@@ -342,9 +533,17 @@ def auto_label(
342
533
  An empty selection with no variable is ``(not set)``, not "all variants":
343
534
  an unfilled row is inert (:func:`defined_sets`), so a label promising every
344
535
  variant would describe a row that contributes nothing.
536
+
537
+ ``frozen`` (a reopened saved plot's versions) is spelled ``load_cbrace v1
538
+ (saved)``, and replaces the latest flag and the frozen columns' own
539
+ selection keys, exactly as :func:`effective_selection_with_source` does —
540
+ "current · load_cbrace v1" would describe something the row does not draw.
345
541
  """
346
542
  parts: list[str] = []
543
+ frozen = {column: levels for column, levels in (frozen or {}).items() if levels}
347
544
  for column, value in selection.items():
545
+ if frozen and (column in frozen or (latest_column and column == latest_column)):
546
+ continue
348
547
  text = _level_text(value)
349
548
  if latest_column and column == latest_column:
350
549
  # A False flag is "not the current code", which is a real thing to
@@ -357,6 +556,8 @@ def auto_label(
357
556
  # function is usually obvious from context in a legend, the
358
557
  # parameter never is.
359
558
  parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
559
+ for column, levels in frozen.items():
560
+ parts.append(f"{function_of_axis(column)} {_level_text(levels)} (saved)")
360
561
 
361
562
  if variable:
362
563
  return f"{variable} · {' · '.join(parts)}" if parts else variable
@@ -392,11 +593,18 @@ def label_variable(
392
593
  """
393
594
  if variant_set.variable:
394
595
  return variant_set.variable
395
- if variant_set.selection or index == 0:
596
+ if variant_set.selection or variant_set.frozen or index == 0:
396
597
  return primary
397
598
  return None
398
599
 
399
600
 
601
+ def says_something(variant_set: VariantSet) -> bool:
602
+ """A selection, a variable, or a frozen pin — any of them is a statement
603
+ about the data. A freeze especially: a reopened saved plot's empty row 0
604
+ with ``frozen={"Code:f": ["v1"]}`` must not fall back to "current"."""
605
+ return bool(variant_set.selection or variant_set.variable or variant_set.frozen)
606
+
607
+
400
608
  def is_stated(variant_set: VariantSet, index: int) -> bool:
401
609
  """Whether this row says anything — what the panel's "not set" tag reads.
402
610
 
@@ -407,7 +615,7 @@ def is_stated(variant_set: VariantSet, index: int) -> bool:
407
615
  empty row 0 states "the primary measure" and needs no ``Variant`` factor to
408
616
  express, so it is stated but not a defined set.
409
617
  """
410
- return index == 0 or bool(variant_set.selection) or bool(variant_set.variable)
618
+ return index == 0 or says_something(variant_set)
411
619
 
412
620
 
413
621
  def set_name(
@@ -429,6 +637,7 @@ def set_name(
429
637
  index=index,
430
638
  variable=label_variable(variant_set, index, primary),
431
639
  latest_column=latest_column,
640
+ frozen=None if variant_set.follow_latest else variant_set.frozen,
432
641
  )
433
642
 
434
643
 
@@ -462,7 +671,7 @@ def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
462
671
  With no concrete row anywhere there is nothing to fold: one unnarrowed
463
672
  series needs no ``Variant`` factor to express.
464
673
  """
465
- if not any(variant.selection or variant.variable for variant in sets):
674
+ if not any(says_something(variant) for variant in sets):
466
675
  return []
467
676
  return [
468
677
  variant for index, variant in enumerate(sets) if is_stated(variant, index)
@@ -529,12 +738,17 @@ def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
529
738
  # WITHIN a variable and never across.
530
739
  per_variant: list[set[str]] = []
531
740
  for variant in sets:
741
+ # The EFFECTIVE selection (and the freeze), not the raw one: what a row
742
+ # answers is what it draws, by the same owner `row_mask` uses.
532
743
  per_variant.append(
533
744
  set(
534
745
  resolve_selection(
535
- frame, variant.selection, latest_column=table.latest_column
746
+ frame,
747
+ effective_selection(variant, table),
748
+ latest_column=table.latest_column,
536
749
  )
537
750
  )
751
+ | set(active_frozen(variant, table))
538
752
  )
539
753
  return answered | set.intersection(*per_variant)
540
754
 
@@ -744,10 +958,20 @@ def _apply_default_pin(table: LongTable) -> LongTable:
744
958
  record and its superseded whole-table record together, 17 rows where there
745
959
  should be one. An unnamed plot now reads what an unnamed load reads.
746
960
 
961
+ The selection comes from :func:`effective_selection_with_source` for an
962
+ empty row — the SAME owner a stated row goes through, so the figure and
963
+ the row counts/banner (``capability.variant_summary``) cannot disagree
964
+ about what an empty row draws again (2026-10-15).
965
+
966
+ The function axes the pin answered **leave the factor list**, as they do
967
+ for any defined row (:func:`_answered`). Left in, a ``Code:fn`` column
968
+ whose rows were already filtered to one version got no role, defaulted to
969
+ ITERATE, and titled the figure ``Code:fn=v2``.
970
+
747
971
  No pin (one generation, a CSV source) leaves the table untouched, so a
748
972
  project with nothing superseded behaves exactly as before.
749
973
  """
750
- pin = dict(table.default_pin or {})
974
+ pin, _source = effective_selection_with_source(VariantSet(), table)
751
975
  frame = table.frame
752
976
  if not pin:
753
977
  Log.info(
@@ -758,18 +982,23 @@ def _apply_default_pin(table: LongTable) -> LongTable:
758
982
  return table
759
983
  mask = variant_set_mask(frame, pin, latest_column=table.latest_column)
760
984
  kept = int(mask.sum())
985
+ answered = [
986
+ factor.name for factor in table.factors if is_function_axis(factor.name)
987
+ ]
761
988
  Log.info(
762
989
  "variant selection: no variant rows — the source default %s keeps %d of "
763
- "%d row(s)%s",
990
+ "%d row(s)%s; answers %s",
764
991
  pin,
765
992
  kept,
766
993
  len(frame),
767
994
  "" if kept == len(frame) else f" ({len(frame) - kept} superseded row(s) dropped)",
995
+ answered or "no function axis",
768
996
  layer=LAYER,
769
997
  )
770
- if kept == len(frame):
998
+ factors = [f for f in table.factors if f.name not in answered]
999
+ if kept == len(frame) and not answered:
771
1000
  return table
772
- return replace(table, frame=frame[mask].copy())
1001
+ return replace(table, frame=frame[mask].copy(), factors=factors)
773
1002
 
774
1003
 
775
1004
  def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
@@ -805,7 +1034,7 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
805
1034
  counts: list[int] = []
806
1035
 
807
1036
  for name, variant in zip(names, sets, strict=True):
808
- mask = row_mask(frame, spec, variant, latest_column=table.latest_column)
1037
+ mask = row_mask(table, spec, variant)
809
1038
  fresh = mask & assigned.isna()
810
1039
  counts.append(int(fresh.sum()))
811
1040
  assigned[fresh] = name
@@ -813,7 +1042,9 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
813
1042
  # Code axes leave the factor list unconditionally, so a variant that
814
1043
  # quietly straddles two versions has to say so here — this is the log
815
1044
  # half of what the GUI shows on the row.
816
- spans = spanned_code_axes(frame[fresh], variant.selection, table)
1045
+ spans = spanned_code_axes(
1046
+ frame[fresh], effective_selection(variant, table), table
1047
+ )
817
1048
  if spans:
818
1049
  Log.warn(
819
1050
  "variant %r was built by MORE THAN ONE version of the code — "
@@ -858,5 +1089,36 @@ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
858
1089
  dict(zip(names, counts, strict=True)),
859
1090
  layer=LAYER,
860
1091
  )
1092
+ log_effective_selections(names, sets, counts, table)
861
1093
 
862
1094
  return replace(table, frame=kept, factors=factors)
1095
+
1096
+
1097
+ def log_effective_selections(
1098
+ names: list[str],
1099
+ sets: list[VariantSet],
1100
+ counts: list[int],
1101
+ table: LongTable,
1102
+ ) -> None:
1103
+ """One INFO naming, per row, what it REALLY selected and why.
1104
+
1105
+ ``name: stated|source default|frozen <selection> [frozen <cols>] -> N
1106
+ row(s)``. The diagnostic for the 2026-10-15 class of bug: if a banner or
1107
+ a count ever disagrees with the figure again, this line shows which
1108
+ selection the figure applied, beside whatever the other reader claims.
1109
+ """
1110
+ parts = []
1111
+ for name, variant, count in zip(names, sets, counts, strict=True):
1112
+ selection, source = effective_selection_with_source(variant, table)
1113
+ frozen = active_frozen(variant, table)
1114
+ parts.append(
1115
+ f"{name!r}: {source} {selection}"
1116
+ + (f" frozen {frozen}" if frozen else "")
1117
+ + (" (follow latest)" if variant.follow_latest else "")
1118
+ + f" -> {count} row(s)"
1119
+ )
1120
+ Log.info(
1121
+ "variant rows' effective selections: %s",
1122
+ "; ".join(parts),
1123
+ layer=LAYER,
1124
+ )
File without changes
File without changes