scistackplot 0.1.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,743 @@
1
+ """
2
+ Named variants: turning a list of selections into a factor you can plot by.
3
+
4
+ A :class:`~scistackplot.spec.VariantSet` names a region of variant space —
5
+ "baseline" is ``Code:bandpass == v1``, "new filter" is ``low_hz in (20, 50)``.
6
+ :func:`apply_variant_sets` collapses however many of those a spec carries into
7
+ **one ordinary factor**, ``Variant``, whose levels are the names. From that
8
+ point on nothing downstream needs to know variants exist: the factor takes a
9
+ role, gets a colour or a facet row, and appears in a legend, exactly like
10
+ ``session``.
11
+
12
+ Two decisions here are load-bearing.
13
+
14
+ **Answered columns leave the factor list.** Once "baseline" *means*
15
+ ``Code:bandpass == v1``, keeping ``Code:bandpass`` as its own factor states the
16
+ same thing twice — and worse, with two sets it is a two-level variant factor
17
+ nobody assigned, so ``roles.validate`` would refuse the figure the user just
18
+ asked for. The information is not lost; it moved into the name. Which columns
19
+ count as answered is :func:`_answered`, and both of its rules matter.
20
+
21
+ **"latest" resolves against the data, never to a hard-coded ordinal.** See
22
+ :func:`resolve_selection` — this is the difference between a figure that keeps
23
+ every subject and one that silently drops the subjects nobody re-ran.
24
+
25
+ See ``docs/claude/variant-selection.md`` and ``.claude/plan-plot-variant-rows.md``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import replace
32
+ from typing import Any
33
+
34
+ import pandas as pd
35
+ from scistacklog import Log
36
+
37
+ from .spec import PlotSpec, VariantSet
38
+ from .table import CODE_FACTOR_PREFIX, FactorInfo, LongTable
39
+
40
+ LAYER = "scistackplot"
41
+
42
+ #: Column (and factor) the named variants collapse into. Named for what it is
43
+ #: to a reader of the figure — the legend says "Variant: baseline", not
44
+ #: "Code:bandpass: v1", which is the whole point of letting the user name it.
45
+ VARIANT_FACTOR = "Variant"
46
+
47
+ #: Bookkeeping column naming which variable each stacked row came from.
48
+ #:
49
+ #: Present only when a spec's rows span more than one variable, and never a
50
+ #: user-facing factor: it is consumed by :func:`apply_variant_sets` exactly like
51
+ #: an answered code axis, because "which variable" is already what the row's
52
+ #: name says. Its whole job is to let a row's mask claim only its own
53
+ #: variable's rows.
54
+ VARIABLE_COLUMN = "Variable"
55
+
56
+ #: A selection value asking for "whatever is current", rather than a named
57
+ #: ordinal. Matches ``scidb.variant.LATEST_VERSION``; duplicated as a literal
58
+ #: rather than imported because this package must work with no scidb installed.
59
+ LATEST = "latest"
60
+
61
+ _ORDINAL = re.compile(r"^v(\d+)$")
62
+
63
+
64
+ def is_code_axis(column: str) -> bool:
65
+ return column.startswith(CODE_FACTOR_PREFIX)
66
+
67
+
68
+ def resolve_selection(
69
+ frame: pd.DataFrame,
70
+ selection: dict[str, Any],
71
+ *,
72
+ latest_column: str | None = None,
73
+ ) -> dict[str, Any]:
74
+ """
75
+ Replace every ``"latest"`` in ``selection`` with something the frame can test.
76
+
77
+ Two resolutions, and which one applies depends on the rest of the set:
78
+
79
+ * **Nothing else pinned** — every code axis in the selection says ``latest``.
80
+ The set filters on ``latest_column``, the per-schema-location flag, and the
81
+ code axes drop out of the selection entirely. This is the important case
82
+ and the reason ``latest`` is not simply "the highest ordinal": a subject
83
+ never re-run under the newest code keeps contributing its own newest
84
+ record instead of vanishing from the figure.
85
+ * **Something else is pinned** to a named ordinal. The location-wise flag is
86
+ no longer usable — a record pinned to ``v1`` is by definition not the
87
+ latest — so the remaining ``latest`` axes resolve to the **highest ordinal
88
+ present in the data**. That does drop locations which never ran it, but
89
+ the user already asked for that by naming a version; the honest thing is
90
+ to say which ordinal it became (the GUI shows ``latest (v3)``), not to
91
+ quietly switch semantics.
92
+
93
+ Keys naming a column the frame does not have are dropped, not treated as
94
+ matching nothing: a spec outlives the table it was written against, and a
95
+ stale key must never silently empty a figure.
96
+ """
97
+ present = {k: v for k, v in selection.items() if k in frame.columns}
98
+ dropped = [k for k in selection if k not in frame.columns]
99
+ if dropped:
100
+ Log.debug(
101
+ "variant selection ignores %s — not column(s) of this table",
102
+ dropped,
103
+ layer=LAYER,
104
+ )
105
+
106
+ latest_axes = [k for k, v in present.items() if _is_latest(v)]
107
+ if not latest_axes:
108
+ return present
109
+
110
+ pinned_elsewhere = any(k not in latest_axes for k in present)
111
+ resolved = {k: v for k, v in present.items() if k not in latest_axes}
112
+
113
+ if not pinned_elsewhere and latest_column and latest_column in frame.columns:
114
+ resolved[latest_column] = True
115
+ return resolved
116
+
117
+ for axis in latest_axes:
118
+ highest = _highest_ordinal(frame[axis])
119
+ if highest is not None:
120
+ resolved[axis] = highest
121
+ return resolved
122
+
123
+
124
+ def _is_latest(value: Any) -> bool:
125
+ return isinstance(value, str) and value == LATEST
126
+
127
+
128
+ def _highest_ordinal(column: pd.Series) -> Any:
129
+ """The largest ``vN`` in a code column, or None when it holds none.
130
+
131
+ Sorted numerically on the ordinal rather than lexically, so ``v10`` beats
132
+ ``v9``. Non-ordinal levels (scidb's ``(n/a)`` for a record whose chain never
133
+ ran this function) are ignored rather than compared against.
134
+ """
135
+ ordinals = []
136
+ for value in column.dropna().astype(str).unique():
137
+ match = _ORDINAL.match(value)
138
+ if match:
139
+ ordinals.append((int(match.group(1)), value))
140
+ if not ordinals:
141
+ return None
142
+ return max(ordinals)[1]
143
+
144
+
145
+ def default_selection(table: LongTable) -> dict[str, Any]:
146
+ """The selection a figure opens on: **exactly one variant**.
147
+
148
+ Plotting one thing is the common case; comparing is what you opt into by
149
+ adding a row. Opening on several series is how a figure that nobody asked
150
+ for gets drawn — and worse, how two pipeline variants get read as replicates
151
+ of one condition.
152
+
153
+ The rule is deterministic and per axis, so the same table always opens the
154
+ same way:
155
+
156
+ * **code axes** -> the latest function body;
157
+ * **branch-param axes** -> the first level, in the table's own declared level
158
+ order (``source._ordered``: natural sort, so ``Parameter1`` opens on ``1``
159
+ and zero-padded IDs go ``01, 02, ... 10`` rather than ``1, 10, 2``).
160
+
161
+ Which value a parameter opens on genuinely does not matter — the user
162
+ changes it if they want another — but *stability* does, so it is the level
163
+ order rather than whatever order the rows arrived in.
164
+
165
+ **The pin is applied blindly.** Real data is ragged, so (latest code) x
166
+ (first value) may be a combination nobody ever ran, and this will happily
167
+ select nothing. That is deliberate: a rule that quietly picks a different
168
+ value to avoid an empty figure is no longer a rule anyone can predict. The
169
+ empty figure explains itself instead (``capability.variant_summary`` reports
170
+ ``row_count == 0`` and what combinations do exist).
171
+
172
+ **Why the latest FLAG and not** ``Code:<fn> = "latest"``. Both spell "latest"
173
+ but they resolve differently, and only one of them is right here.
174
+ :func:`resolve_selection` treats a selection as "nothing else pinned" only
175
+ when every key in it is a ``"latest"`` axis (``pinned_elsewhere``, line ~116)
176
+ — so the moment a branch param is pinned alongside, a ``"latest"`` code axis
177
+ stops resolving through the per-location flag and becomes the **global
178
+ highest ordinal**. That would silently drop every schema location never
179
+ re-run under the newest code. Pinning the boolean flag keeps the
180
+ per-location meaning no matter what else is in the selection, which is the
181
+ behaviour ``test_pin_keeps_locations_never_rerun_under_the_newest_code``
182
+ exists to protect.
183
+
184
+ The ordinal fallback below therefore only runs for a source that has code
185
+ axes but no flag to go with them — impossible from scidb, which writes the
186
+ two together, but a CSV or a hand-built table may declare variant factors
187
+ without one.
188
+ """
189
+ # The source's own recommendation comes first and is never overwritten:
190
+ # ``default_pin`` is where a source says which rows are CURRENT, and only
191
+ # scidb can know that. This function's job is to finish the job — pin the
192
+ # axes the source had no opinion about — not to second-guess it.
193
+ #
194
+ # One caveat for a source that pins a code axis to the string ``"latest"``
195
+ # rather than to the flag: adding a branch-param pin beside it flips it to
196
+ # the global highest ordinal (see above). Left alone rather than rewritten,
197
+ # because silently changing what a source asked for is worse than honouring
198
+ # a request it can express and `resolve_selection` documents.
199
+ selection: dict[str, Any] = dict(table.default_pin or {})
200
+ frame = table.frame
201
+ latest = table.latest_column if table.latest_column in frame.columns else None
202
+ if latest:
203
+ selection.setdefault(latest, True)
204
+
205
+ for factor in table.variant_factors:
206
+ if factor.name in selection or factor.name not in frame.columns:
207
+ continue
208
+ if is_code_axis(factor.name):
209
+ if latest:
210
+ continue # already answered, per-location, by the flag
211
+ highest = _highest_ordinal(frame[factor.name])
212
+ if highest is not None:
213
+ selection[factor.name] = highest
214
+ elif factor.levels:
215
+ selection[factor.name] = _level_text(factor.levels[-1])
216
+ continue
217
+ if factor.levels:
218
+ selection[factor.name] = _level_text(factor.levels[0])
219
+
220
+ Log.debug(
221
+ "default selection for %r: %s", table.name, selection, layer=LAYER
222
+ )
223
+ return selection
224
+
225
+
226
+ def variant_set_mask(
227
+ frame: pd.DataFrame,
228
+ selection: dict[str, Any],
229
+ *,
230
+ latest_column: str | None = None,
231
+ ) -> pd.Series:
232
+ """Rows this selection keeps — the single definition of what a variant selects.
233
+
234
+ Public because the GUI reports *"4 of 24 combinations"* and a per-row count
235
+ per variant, and both have to be measured with exactly the rule the renderer
236
+ will apply. A second implementation that counted differently would put a
237
+ number on screen the figure disagrees with, which is worse than no number.
238
+
239
+ A list/tuple/set value means "any of these" — the subcube rule the popup's
240
+ checkboxes produce, and the same membership semantics
241
+ ``scidb.database._match_branch_param`` already gives a list-valued
242
+ ``Variant(...)`` kwarg.
243
+ """
244
+ mask = pd.Series(True, index=frame.index)
245
+ resolved = resolve_selection(frame, selection, latest_column=latest_column)
246
+ for key, value in resolved.items():
247
+ column = frame[key]
248
+ if isinstance(value, bool):
249
+ # The latest flag is a real bool column; comparing it as text would
250
+ # depend on how pandas spells True.
251
+ mask &= column.fillna(False).astype(bool) == value
252
+ continue
253
+ as_text = column.astype(str)
254
+ if isinstance(value, (list, tuple, set, frozenset)):
255
+ mask &= as_text.isin({str(v) for v in value})
256
+ else:
257
+ mask &= as_text == str(value)
258
+ return mask
259
+
260
+
261
+ def row_mask(
262
+ frame: pd.DataFrame,
263
+ spec: PlotSpec,
264
+ variant: VariantSet,
265
+ *,
266
+ latest_column: str | None = None,
267
+ ) -> pd.Series:
268
+ """Rows one variant ROW claims: its selection, within its own variable.
269
+
270
+ The single definition, called by :func:`apply_variant_sets` (which builds
271
+ the figure) and by ``capability.variant_summary`` (which reports how many
272
+ rows each variant matched). Those two disagreeing would put a number on
273
+ screen the figure contradicts — the same reason
274
+ :func:`variant_set_mask` is public.
275
+ """
276
+ mask = variant_set_mask(frame, variant.selection, latest_column=latest_column)
277
+ if VARIABLE_COLUMN in frame.columns:
278
+ # A row claims only its own variable's rows. Without this, a row
279
+ # pinning `Code:filterEMG=v1` names nothing about a second variable —
280
+ # `resolve_selection` drops keys the frame lacks, deliberately — so it
281
+ # would match every row of that variable too and draw it once per
282
+ # variant, identically.
283
+ mask &= frame[VARIABLE_COLUMN].astype(str) == (
284
+ variant.variable or spec.y_measure
285
+ )
286
+ return mask
287
+
288
+
289
+ def auto_label(
290
+ selection: dict[str, Any],
291
+ *,
292
+ index: int = 0,
293
+ variable: str | None = None,
294
+ latest_column: str | None = None,
295
+ ) -> str:
296
+ """The name a variant gets until the user types over it.
297
+
298
+ **The variable comes first, and the selection qualifies it** —
299
+ ``RawEMG · low_hz=20``. A row's subject is the variable it draws; the
300
+ selection only narrows it. Naming rows by their selection alone was how two
301
+ rows reading "current" and "latest" ended up saying nothing about which was
302
+ EMG and which was force (observed 2026-09-11, scidb.log 12:26:18).
303
+
304
+ ``variable`` is the row's own, or the primary measure when it names none.
305
+ Callers pass it; this function never guesses.
306
+
307
+ ``latest_column`` spells the source's "these are the current results" flag
308
+ as **current**. It is a boolean flag rather than a coordinate, and
309
+ ``CodeIsLatest=True`` in a legend tells a reader nothing.
310
+
311
+ An empty selection with no variable is ``(not set)``, not "all variants":
312
+ an unfilled row is inert (:func:`defined_sets`), so a label promising every
313
+ variant would describe a row that contributes nothing.
314
+ """
315
+ parts: list[str] = []
316
+ for column, value in selection.items():
317
+ text = _level_text(value)
318
+ if latest_column and column == latest_column:
319
+ # A False flag is "not the current code", which is a real thing to
320
+ # ask for and must not read as its opposite.
321
+ parts.append("current" if value is True else f"not {latest_column}")
322
+ elif is_code_axis(column):
323
+ parts.append(f"{column[len(CODE_FACTOR_PREFIX):]} {text}")
324
+ else:
325
+ # Branch params arrive namespaced (``bandpass.low_hz``); the
326
+ # function is usually obvious from context in a legend, the
327
+ # parameter never is.
328
+ parts.append(f"{column.rsplit('.', 1)[-1]}={text}")
329
+
330
+ if variable:
331
+ return f"{variable} · {' · '.join(parts)}" if parts else variable
332
+ if not parts:
333
+ return "(not set)" if index == 0 else f"(not set {index + 1})"
334
+ return " · ".join(parts)
335
+
336
+
337
+ def _level_text(value: Any) -> str:
338
+ if isinstance(value, (list, tuple, set, frozenset)):
339
+ return "+".join(str(v) for v in value)
340
+ return str(value)
341
+
342
+
343
+ def label_variable(
344
+ variant_set: VariantSet, index: int, primary: str | None
345
+ ) -> str | None:
346
+ """The variable a row is LABELLED by, or None when it is unfilled.
347
+
348
+ Three cases, and the third is why this is a function rather than
349
+ ``variant.variable or primary``:
350
+
351
+ * the row names its own variable -> that one;
352
+ * the row says something (a selection), or it is **row 0** -> the primary
353
+ measure, because that is what it plots;
354
+ * otherwise -> None, and the row labels itself ``(not set)``.
355
+
356
+ Row 0 is special because it always exists: ``roles.default_spec`` seeds it
357
+ for every table, and an empty row 0 means "the primary measure, unnarrowed"
358
+ — a complete statement about the figure, not a blank the user forgot to
359
+ fill in. A row the user ADDED and left empty is the blank, and has to keep
360
+ saying so.
361
+ """
362
+ if variant_set.variable:
363
+ return variant_set.variable
364
+ if variant_set.selection or index == 0:
365
+ return primary
366
+ return None
367
+
368
+
369
+ def is_stated(variant_set: VariantSet, index: int) -> bool:
370
+ """Whether this row says anything — what the panel's "not set" tag reads.
371
+
372
+ Row 0 always does (see :func:`label_variable`). Beyond it, a row has to
373
+ name a variable or select something; clicking "+" is not a statement.
374
+
375
+ Distinct from :func:`defined_sets`, which decides what the FIGURE does: an
376
+ empty row 0 states "the primary measure" and needs no ``Variant`` factor to
377
+ express, so it is stated but not a defined set.
378
+ """
379
+ return index == 0 or bool(variant_set.selection) or bool(variant_set.variable)
380
+
381
+
382
+ def set_name(
383
+ variant_set: VariantSet,
384
+ index: int,
385
+ *,
386
+ primary: str | None = None,
387
+ latest_column: str | None = None,
388
+ ) -> str:
389
+ """What this row is CALLED — in the legend, and in the name box.
390
+
391
+ One definition for both, deliberately: a box showing "FilteredEMG" beside a
392
+ legend reading "current" would be two answers to the same question.
393
+ """
394
+ if variant_set.name:
395
+ return variant_set.name
396
+ return auto_label(
397
+ variant_set.selection,
398
+ index=index,
399
+ variable=label_variable(variant_set, index, primary),
400
+ latest_column=latest_column,
401
+ )
402
+
403
+
404
+ def defined_sets(sets: list[VariantSet]) -> list[VariantSet]:
405
+ """The variants the figure folds into a ``Variant`` factor.
406
+
407
+ A row the user has added but not filled in is **inert**: it claims no data,
408
+ contributes no level, and decides nothing about which columns the Variants
409
+ section answers. Treating it as "all variants" instead — which is what an
410
+ empty selection means once applied — made adding a row change the figure
411
+ before the user had said anything about it, and un-answered the code axis
412
+ for every *other* row, dropping `Code:<fn>` back into Factors with a
413
+ pooling error attached.
414
+
415
+ Clicking "+" is not a statement about the data. Nothing should happen until
416
+ the row says something.
417
+
418
+ Naming a **variable** is saying something, even with no selection: "also
419
+ plot FilteredEMG, all of it" is a complete instruction, and the row it
420
+ describes is a series the figure has to draw.
421
+
422
+ **Row 0 folds alongside the others once ANY row is concrete**, even while
423
+ it selects nothing itself. It is seeded for every table
424
+ (``roles.default_spec``) and means "the primary measure, unnarrowed" — so
425
+ leaving it out would make adding a second row *replace* the figure with
426
+ that row alone instead of drawing both. The GUI used to paper over this by
427
+ quietly filling in row 0's variable when the user clicked "+", which put a
428
+ rule about what a figure draws in TypeScript; this is that rule, in the one
429
+ place that decides it.
430
+
431
+ With no concrete row anywhere there is nothing to fold: one unnarrowed
432
+ series needs no ``Variant`` factor to express.
433
+ """
434
+ if not any(variant.selection or variant.variable for variant in sets):
435
+ return []
436
+ return [
437
+ variant for index, variant in enumerate(sets) if is_stated(variant, index)
438
+ ]
439
+
440
+
441
+ def _answered(table: LongTable, sets: list[VariantSet]) -> set[str]:
442
+ """Columns the named variants have already accounted for.
443
+
444
+ These leave the factor list: once "baseline" *means* ``Code:bandpass == v1``,
445
+ keeping the column as its own factor states the same thing twice, and with
446
+ two variants it is an unassigned two-level variant factor that
447
+ ``roles.validate`` refuses — rejecting the very comparison the user asked
448
+ for.
449
+
450
+ Two rules, and they differ by axis kind on purpose.
451
+
452
+ **Code axes belong to the Variants section, entirely.** Once any variant is
453
+ defined, every ``Code:<fn>`` column is answered — whether that variant named
454
+ a version, asked for ``latest``, or selected on the chain-wide
455
+ ``CodeIsLatest`` flag. "Which version of the code" is the question the
456
+ variant rows exist to answer, so offering the same question again as a
457
+ factor is asking the user to decide the same thing twice, in two places,
458
+ with no way to tell which one wins.
459
+
460
+ That deliberately allows a variant to hold rows built by different ordinals.
461
+ Usually that is exactly right — under ``latest``, subject A on v2 and
462
+ subject B on v1 are each the newest AT THEIR OWN LOCATION, which is what
463
+ "current" means. Where it is *not* obviously right, the row says so rather
464
+ than the column coming back: see :func:`spanned_code_axes`.
465
+
466
+ **Branch-param axes are answered only when EVERY variant answers them** — an
467
+ intersection, not a union. Nothing about "current code" decides which filter
468
+ cutoff to plot, so if one variant pins ``low_hz == 20`` while another leaves
469
+ it open, the second still holds both cutoffs and the user must still say
470
+ what to do with them.
471
+ """
472
+ sets = defined_sets(sets)
473
+ if not sets:
474
+ return set()
475
+ frame = table.frame
476
+ answered = {column for column in frame.columns if is_code_axis(column)}
477
+ if VARIABLE_COLUMN in frame.columns and (
478
+ # ONE variable overall: the column is constant, so offering it as a
479
+ # factor with a single level is noise.
480
+ frame[VARIABLE_COLUMN].nunique(dropna=False) < 2
481
+ # ONE ROW PER VARIABLE: `Variant`'s levels already ARE the variables,
482
+ # so keeping `Variable` too would encode the same distinction twice —
483
+ # "FilteredEMG vs RawEMG" arriving as both a colour and a facet. This
484
+ # is the common stacking case and the original reason this column was
485
+ # always answered: which variable a row came from is what its NAME
486
+ # says.
487
+ or len({variant.variable for variant in sets}) == len(sets)
488
+ ):
489
+ answered.add(VARIABLE_COLUMN)
490
+ # Otherwise — several rows sharing a variable, e.g. three variants each of
491
+ # EMG and force — `Variable` STAYS a factor, and that is load-bearing
492
+ # rather than cosmetic. `Variant` folds all six rows into one flat factor,
493
+ # and `_collapse_aggregates` builds its groupby key from the ROLES, so a
494
+ # column with no role is not in it: averaging `Variant` would average EMG
495
+ # together with force. Keeping `Variable` (defaulted to FACET, and refused
496
+ # FREE/AGGREGATE by `roles.validate`) is what makes variants collapse
497
+ # WITHIN a variable and never across.
498
+ per_variant: list[set[str]] = []
499
+ for variant in sets:
500
+ per_variant.append(
501
+ set(
502
+ resolve_selection(
503
+ frame, variant.selection, latest_column=table.latest_column
504
+ )
505
+ )
506
+ )
507
+ return answered | set.intersection(*per_variant)
508
+
509
+
510
+ #: Schema locations named per version before a span report starts saying
511
+ #: "and N more". Enough to recognise a pattern ("ah, only subject 01"), few
512
+ #: enough to fit in a banner.
513
+ SPAN_LOCATION_LIMIT = 6
514
+
515
+
516
+ def spanned_code_axes(
517
+ rows: pd.DataFrame, selection: dict[str, Any], table: LongTable
518
+ ) -> dict[str, dict]:
519
+ """Code axes this variant left open and that its rows disagree on.
520
+
521
+ The honesty mechanism that lets code axes leave the factor list
522
+ unconditionally. A variant whose rows were built by two different versions
523
+ of a function is pooling code versions — and the figure looks exactly like
524
+ one that is not. Rather than resurrecting the column as a factor (asking the
525
+ user to answer in Factors a question they are already answering in
526
+ Variants), the *row* reports it.
527
+
528
+ Returns ``{column: {function, versions, locations, schema_levels,
529
+ truncated}}`` — ``versions`` counts rows per version, ``locations`` names
530
+ the schema locations holding each, capped at
531
+ :data:`SPAN_LOCATION_LIMIT`. Empty when nothing is spanned.
532
+
533
+ **The latest flag is no longer exempt, and that is a deliberate reversal.**
534
+ This function used to return ``{}`` outright whenever the selection resolved
535
+ through the per-location ``CodeIsLatest`` flag, on the reasoning that
536
+ spanning ordinals is what per-location "latest" *means*, so reporting it
537
+ would cry wolf on the most ordinary state there is. That argument is sound
538
+ about *frequency* and wrong about *consequence*: the state it stays silent
539
+ about is a figure whose points were computed by different versions of the
540
+ same function, which is precisely the thing a reader cannot see and cannot
541
+ afford to assume away. The user's call, 2026-09-11 — if the body actually
542
+ used differs between schema locations, say so prominently.
543
+
544
+ So "latest" is now reported like any other unpinned code axis. What makes
545
+ that tolerable rather than noisy is *which locations hold which version*:
546
+ "v1 everywhere except subject 01" is a sentence someone can act on, where a
547
+ bare "pools 2 versions" on the most common state in the system is not.
548
+
549
+ Do not restore the exemption without re-reading
550
+ ``docs/claude/plot-variant-rows.md`` §3, which argued for it.
551
+ """
552
+ from .table import natural_sort_key
553
+
554
+ resolved = resolve_selection(
555
+ rows, selection, latest_column=table.latest_column
556
+ )
557
+ levels_of = [key for key in table.schema_levels if key in rows.columns]
558
+ spans: dict[str, dict] = {}
559
+
560
+ for column in rows.columns:
561
+ if not is_code_axis(column) or column in resolved:
562
+ continue
563
+ present = rows[column].dropna().astype(str)
564
+ if present.nunique() <= 1:
565
+ continue
566
+
567
+ versions: dict[str, int] = {}
568
+ locations: dict[str, list[str]] = {}
569
+ truncated = False
570
+ for version in sorted(present.unique(), key=natural_sort_key):
571
+ at_version = rows.loc[present.index[present == version]]
572
+ versions[version] = int(len(at_version))
573
+ if not levels_of:
574
+ # A CSV, or a table with no schema — the versions are still
575
+ # worth reporting, there is just nowhere to place them.
576
+ locations[version] = []
577
+ continue
578
+ labels = sorted(
579
+ {
580
+ "/".join(str(value) for value in label)
581
+ for label in at_version[levels_of].itertuples(index=False)
582
+ },
583
+ key=natural_sort_key,
584
+ )
585
+ locations[version] = labels[:SPAN_LOCATION_LIMIT]
586
+ truncated = truncated or len(labels) > SPAN_LOCATION_LIMIT
587
+
588
+ spans[column] = {
589
+ "function": column[len(CODE_FACTOR_PREFIX):]
590
+ if column.startswith(CODE_FACTOR_PREFIX)
591
+ else column,
592
+ "versions": versions,
593
+ "locations": locations,
594
+ # What a location string means, outermost first, so a reader knows
595
+ # whether "01/pre/2" is subject/session/trial or something else.
596
+ "schema_levels": levels_of,
597
+ "truncated": truncated,
598
+ }
599
+ return spans
600
+
601
+
602
+ def describe_span(span: dict) -> str:
603
+ """One sentence naming the versions and where each one is.
604
+
605
+ Shared so the log warning, the row tag and the figure banner cannot describe
606
+ the same span three different ways — the reason every other count in this
607
+ module goes through one function.
608
+ """
609
+ parts = []
610
+ for version, count in span["versions"].items():
611
+ where = span["locations"].get(version) or []
612
+ if where:
613
+ listed = ", ".join(where)
614
+ if span["truncated"] and len(where) == SPAN_LOCATION_LIMIT:
615
+ listed += ", …"
616
+ parts.append(f"{version} ({listed})")
617
+ else:
618
+ parts.append(f"{version} ({count} row(s))")
619
+ return f"{span['function']}: {'; '.join(parts)}"
620
+
621
+
622
+ def strip_answered_roles(
623
+ spec: PlotSpec, table: LongTable, derived: LongTable
624
+ ) -> PlotSpec:
625
+ """Drop roles naming factors the variants answered.
626
+
627
+ A role assigned before a variant claimed its column is not a mistake, it is
628
+ stale — and ``validate`` would call it an unknown factor and refuse to draw
629
+ anything. Two ways in, both ordinary:
630
+
631
+ * ``default_roles`` puts a multi-level ``Code:<fn>`` on COLOUR, and the
632
+ table then opens on the "current" variant, which answers it;
633
+ * the user assigns a code axis to a facet, then adds a variant that pins it.
634
+
635
+ Only names that WERE factors of the undecided table are dropped. A role
636
+ naming something that was never a factor at all is still a typo, and
637
+ ``validate`` should still say so.
638
+ """
639
+ stale = [
640
+ name
641
+ for name in spec.roles
642
+ if table.has_factor(name) and not derived.has_factor(name)
643
+ ]
644
+ if not stale:
645
+ return spec
646
+ Log.debug(
647
+ "dropping role(s) %s — the named variants now account for those columns",
648
+ stale,
649
+ layer=LAYER,
650
+ )
651
+ return replace(
652
+ spec, roles={k: v for k, v in spec.roles.items() if k not in stale}
653
+ )
654
+
655
+
656
+ def apply_variant_sets(spec: PlotSpec, table: LongTable) -> LongTable:
657
+ """
658
+ Fold ``spec.variant_sets`` into a ``Variant`` factor on a derived table.
659
+
660
+ Returns ``table`` unchanged when the spec names no variants — or none that
661
+ say anything yet (:func:`defined_sets`) — so a project that never edited a
662
+ function or swept a parameter pays nothing and behaves exactly as before,
663
+ and a half-added row changes nothing until it is filled in.
664
+
665
+ Rows matching no set are dropped: the sets are the figure's subject, and a
666
+ row belonging to none of them was not asked for. A row matching several
667
+ goes to the **first** — overlapping selections are legal (``all where
668
+ low_hz=20`` and ``all where code=v1`` genuinely intersect) and duplicating
669
+ the row into both would double-count it in every mean.
670
+ """
671
+ sets = defined_sets(spec.variant_sets)
672
+ if not sets:
673
+ return table
674
+
675
+ frame = table.frame
676
+ names = [
677
+ set_name(
678
+ s,
679
+ i,
680
+ primary=spec.y_measure,
681
+ latest_column=table.latest_column,
682
+ )
683
+ for i, s in enumerate(sets)
684
+ ]
685
+ assigned = pd.Series(pd.NA, index=frame.index, dtype="object")
686
+ counts: list[int] = []
687
+
688
+ for name, variant in zip(names, sets, strict=True):
689
+ mask = row_mask(frame, spec, variant, latest_column=table.latest_column)
690
+ fresh = mask & assigned.isna()
691
+ counts.append(int(fresh.sum()))
692
+ assigned[fresh] = name
693
+
694
+ # Code axes leave the factor list unconditionally, so a variant that
695
+ # quietly straddles two versions has to say so here — this is the log
696
+ # half of what the GUI shows on the row.
697
+ spans = spanned_code_axes(frame[fresh], variant.selection, table)
698
+ if spans:
699
+ Log.warn(
700
+ "variant %r was built by MORE THAN ONE version of the code — "
701
+ "%s. The figure cannot show this; pin a version on the row, or "
702
+ "split it into one variant per version.",
703
+ name,
704
+ " | ".join(describe_span(span) for span in spans.values()),
705
+ layer=LAYER,
706
+ )
707
+
708
+ kept = frame[assigned.notna()].copy()
709
+ kept[VARIANT_FACTOR] = assigned[assigned.notna()]
710
+
711
+ factors = [f for f in table.factors if f.name not in _answered(table, sets)]
712
+ factors.insert(
713
+ 0,
714
+ FactorInfo(
715
+ name=VARIANT_FACTOR,
716
+ # Declared order, not the order the data happens to be in: the rows
717
+ # are a list the user arranged, and a legend that reorders itself
718
+ # when a variant loses its last record is disorienting.
719
+ levels=[name for name, count in zip(names, counts, strict=True) if count],
720
+ is_variant=True,
721
+ ),
722
+ )
723
+
724
+ empty = [name for name, count in zip(names, counts, strict=True) if not count]
725
+ if empty:
726
+ # Never silent: a variant that matched nothing is either a typo or a
727
+ # pipeline that was never run, and both look identical to "it worked"
728
+ # if the only symptom is a missing series.
729
+ Log.warn(
730
+ "variant(s) %s matched no rows — they contribute nothing to the "
731
+ "figure. Check the selection against what has actually run.",
732
+ empty,
733
+ layer=LAYER,
734
+ )
735
+ Log.info(
736
+ "variant sets kept %d of %d row(s): %s",
737
+ len(kept),
738
+ len(frame),
739
+ dict(zip(names, counts, strict=True)),
740
+ layer=LAYER,
741
+ )
742
+
743
+ return replace(table, frame=kept, factors=factors)