scistackplotdb 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.
scistackplotdb/load.py ADDED
@@ -0,0 +1,463 @@
1
+ """
2
+ Loading scidb variables into long-format frames.
3
+
4
+ The long format itself is nearly free — schema keys are already ordinary
5
+ columns once a variable is joined to ``_schema``, which is the same shape
6
+ ``stat_`` functions receive via ``as_table``. What this module adds is the
7
+ part a flat CSV never needed: attaching branch params as columns, and knowing
8
+ which schema keys a given variable actually occupies.
9
+
10
+ Queries go through ``_fetchall``/``_fetchone`` (never ``_execute(...).fetchall()``
11
+ — see docs/claude on DuckDB fetch locking) and batch the branch-params walk
12
+ rather than asking per record (the N+1 trap).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ import pandas as pd
21
+ from scistacklog import Log
22
+ from scistackplot import CODE_FACTOR_PREFIX
23
+
24
+ LAYER = "scistackplotdb"
25
+
26
+ #: Column name given to the producing function's version when a variable holds
27
+ #: records built by more than one version of its function's source. Named like
28
+ #: the stack's other synthetic factor (``source.FIELD_FACTOR`` = ``ColName``)
29
+ #: rather than after a database column, because that is what it is to a reader
30
+ #: of the figure: a condition, not a record attribute.
31
+ #: Owned by scistackplot (``CODE_FACTOR_PREFIX``), re-exported here under the
32
+ #: name this layer's callers already use. The rendering layer decides how a code
33
+ #: axis is presented and defaulted, so it owns the convention; sources conform
34
+ #: rather than each inventing their own prefix.
35
+ VERSION_FACTOR_PREFIX = CODE_FACTOR_PREFIX
36
+
37
+ #: Level for a record whose chain does not include this column's function at
38
+ #: all — a raw save, or a record that reached this schema location by a route
39
+ #: that never ran it. Without a level of its own those rows would hold NaN and
40
+ #: drop silently out of every facet.
41
+ #:
42
+ #: Note this replaced a single ``CodeVersion`` column carrying ``"(raw)"``. One
43
+ #: column per function is what makes a multi-layer chain expressible, and it is
44
+ #: also what lets the level mean the same thing everywhere: ``v2`` in
45
+ #: ``Code:bandpass_filter`` is the same code at every schema location, which a
46
+ #: single merged column could not promise once two functions were in play.
47
+ MISSING_VERSION_LEVEL = "(n/a)"
48
+
49
+ #: Per-row flag for "this record's code version is the newest at ITS OWN schema
50
+ #: location". Not a variant factor — a helper the default pin filters on.
51
+ #:
52
+ #: Pinning has to happen on this rather than on ``Code:fn == "v2"``, and the
53
+ #: difference is not cosmetic. Version ordinals are numbered per function so
54
+ #: their levels mean the same thing everywhere, which means pinning a level
55
+ #: drops every schema location that was never re-run under the newest code —
56
+ #: silently losing subjects from the figure. This flag is resolved per location,
57
+ #: so pinning it keeps each location's own newest record and loses nothing.
58
+ #:
59
+ #: It is also **one flag for the whole chain**, not one per code column. That is
60
+ #: what keeps the default a single checkbox however many layers were edited: an
61
+ #: N-function chain would otherwise need N pins to express "just show me the
62
+ #: current results".
63
+ LATEST_COLUMN = "CodeIsLatest"
64
+
65
+
66
+ @dataclass
67
+ class VariableFrame:
68
+ """A variable's records as a long frame, plus what its columns mean."""
69
+
70
+ name: str
71
+ frame: pd.DataFrame
72
+ #: Schema keys this variable actually occupies (non-null for its records).
73
+ levels: list[str] = field(default_factory=list)
74
+ #: Data column(s) of the variable's table, excluding record_id.
75
+ data_columns: list[str] = field(default_factory=list)
76
+ #: Variant columns attached from the provenance graph — branch params, plus
77
+ #: the producing function's version when there is more than one.
78
+ variant_columns: list[str] = field(default_factory=list)
79
+ #: One entry per variant column saying where it came from:
80
+ #: ``{"column", "kind": "code"|"param", "function", "param"}``.
81
+ #:
82
+ #: Column names encode this already (``Code:bandpass``, ``bandpass.low_hz``),
83
+ #: but only as *scidb's* namespacing conventions. Anything that needed the
84
+ #: producing function — the GUI mapping an axis to its pipeline node, most
85
+ #: obviously — would otherwise re-implement those conventions by splitting
86
+ #: strings, one layer away from where they are defined and first to break
87
+ #: when they change. Carrying the structure costs nothing here, where both
88
+ #: halves are still in hand.
89
+ variant_axes: list[dict] = field(default_factory=list)
90
+ #: Name of the per-row "this is my location's newest code version" flag, or
91
+ #: None when the variable holds only one version. See :data:`LATEST_COLUMN`.
92
+ latest_column: str | None = None
93
+
94
+ @property
95
+ def value_column(self) -> str:
96
+ return self.data_columns[0] if self.data_columns else self.name
97
+
98
+
99
+ def schema_keys(db) -> list[str]:
100
+ return list(db._duck.dataset_schema)
101
+
102
+
103
+ def registered_variables(db) -> list[str]:
104
+ rows = db._duck._fetchall("SELECT variable_name FROM _variables ORDER BY variable_name")
105
+ return [row[0] for row in rows]
106
+
107
+
108
+ def table_name_for(db, variable: str) -> str:
109
+ """
110
+ Resolve a variable's data table.
111
+
112
+ ``_registered_types.table_name`` is deliberately NOT unique (see the note
113
+ in ``DatabaseManager._ensure_meta_tables``), so every query below also
114
+ filters on ``_record.type`` — reading a shared table without that filter
115
+ would silently mix two variables' records into one plot.
116
+ """
117
+ row = db._duck._fetchone(
118
+ "SELECT table_name FROM _registered_types WHERE type_name = ?", [variable]
119
+ )
120
+ return row[0] if row and row[0] else f"{variable}_data"
121
+
122
+
123
+ def data_columns_for(db, variable: str) -> list[str]:
124
+ table = table_name_for(db, variable)
125
+ rows = db._duck._fetchall(
126
+ "SELECT column_name FROM information_schema.columns "
127
+ "WHERE table_name = ? AND column_name != 'record_id' "
128
+ "ORDER BY ordinal_position",
129
+ [table],
130
+ )
131
+ return [row[0] for row in rows]
132
+
133
+
134
+ def sample_value(db, variable: str) -> Any:
135
+ """
136
+ One value from a variable's data column — enough to classify its shape.
137
+
138
+ Deliberately a value rather than a declared SQL type name: the duckdb
139
+ client's own Python type for a cell (float for a scalar column, list for a
140
+ LIST column) is the ground truth, with no dependency on how DuckDB spells
141
+ list/array types across versions. The GUI's pre-existing
142
+ ``_numeric_plot_kind`` made the same call for the same reason.
143
+ """
144
+ columns = data_columns_for(db, variable)
145
+ if not columns:
146
+ return None
147
+ table = table_name_for(db, variable)
148
+ row = db._duck._fetchone(
149
+ f'SELECT t."{columns[0]}" FROM "{table}" t '
150
+ f"JOIN _record r ON t.record_id = r.record_id "
151
+ f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE "
152
+ f"AND t.\"{columns[0]}\" IS NOT NULL LIMIT 1",
153
+ [variable],
154
+ )
155
+ return row[0] if row else None
156
+
157
+
158
+ def variable_levels(db, variable: str) -> list[str]:
159
+ """
160
+ Which schema keys a variable occupies, without loading any data.
161
+
162
+ ``describe()`` needs this for every registered variable, and loading each
163
+ one's full frame to find out would mean reading every 1-D array in the
164
+ database just to open the panel. COUNT ignores NULLs, so one row of counts
165
+ says exactly which keys are populated.
166
+ """
167
+ keys = schema_keys(db)
168
+ if not keys:
169
+ return []
170
+ counts = ", ".join(f'COUNT(s."{key}")' for key in keys)
171
+ row = db._duck._fetchone(
172
+ f"SELECT {counts} FROM _record r "
173
+ f"LEFT JOIN _schema s ON r.schema_id = s.schema_id "
174
+ f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE",
175
+ [variable],
176
+ )
177
+ if row is None:
178
+ return []
179
+ return [key for key, count in zip(keys, row, strict=True) if count]
180
+
181
+
182
+ def load_variable(db, variable: str, *, with_variants: bool = True) -> VariableFrame:
183
+ """Load every non-excluded record of ``variable`` as a long frame."""
184
+ with Log.timer("load_variable", layer=LAYER, extra=variable):
185
+ keys = schema_keys(db)
186
+ columns = data_columns_for(db, variable)
187
+ if not columns:
188
+ Log.warn("variable %r has no data columns", variable, layer=LAYER)
189
+ return VariableFrame(name=variable, frame=pd.DataFrame())
190
+
191
+ table = table_name_for(db, variable)
192
+ schema_select = "".join(f', s."{key}"' for key in keys)
193
+ data_select = "".join(f', t."{column}"' for column in columns)
194
+ query = (
195
+ f"SELECT t.record_id{data_select}{schema_select} "
196
+ f'FROM "{table}" t '
197
+ f"JOIN _record r ON t.record_id = r.record_id "
198
+ f"LEFT JOIN _schema s ON r.schema_id = s.schema_id "
199
+ f"WHERE r.type = ? AND r.excluded IS DISTINCT FROM TRUE"
200
+ )
201
+ rows = db._duck._fetchall(query, [variable])
202
+
203
+ frame = pd.DataFrame(
204
+ rows, columns=["record_id", *columns, *keys]
205
+ )
206
+ for key in keys:
207
+ frame[key] = frame[key].map(lambda v: None if v is None else str(v))
208
+
209
+ levels = [key for key in keys if frame[key].notna().any()]
210
+ variant_columns: list[str] = []
211
+ variant_axes: list[dict] = []
212
+ latest_column: str | None = None
213
+ if with_variants and len(frame):
214
+ frame, variant_columns, latest_column, variant_axes = attach_variants(
215
+ db, frame
216
+ )
217
+
218
+ Log.info(
219
+ "loaded %s: %d record(s), levels=%s, variants=%s",
220
+ variable,
221
+ len(frame),
222
+ levels,
223
+ variant_columns or "none",
224
+ layer=LAYER,
225
+ )
226
+ return VariableFrame(
227
+ name=variable,
228
+ frame=frame,
229
+ levels=levels,
230
+ data_columns=columns,
231
+ variant_columns=variant_columns,
232
+ variant_axes=variant_axes,
233
+ latest_column=latest_column,
234
+ )
235
+
236
+
237
+ def attach_variants(
238
+ db, frame: pd.DataFrame
239
+ ) -> tuple[pd.DataFrame, list[str], str | None, list[dict]]:
240
+ """
241
+ Add one column per thing that distinguishes these records, from the
242
+ provenance graph: each branch param, plus the producing function's version.
243
+
244
+ Returns ``(frame, variant_columns, latest_column, variant_axes)`` — the
245
+ third being the name of the :data:`LATEST_COLUMN` flag when versions are in
246
+ play (or None), and the fourth the structured description of each column
247
+ (see :attr:`VariableFrame.variant_axes`).
248
+
249
+ This is the correctness-critical step. A variable produced at two filter
250
+ cutoffs has **two records per schema combination**; without these columns
251
+ those rows look like replicates of one another and get overplotted — a
252
+ figure that is wrong in a way that looks like data. With them, the variant
253
+ is an ordinary factor the user must assign (``roles.validate`` refuses to
254
+ let a multi-level variant sit unassigned).
255
+
256
+ Branch params alone were not enough. Two records produced by **different
257
+ versions of the same function's source** carry identical branch params, so
258
+ they arrived here indistinguishable and were overplotted as replicates —
259
+ precisely the failure this function exists to prevent, reached by the one
260
+ route it did not cover.
261
+
262
+ Nor was the producing function's own version enough, for the same reason one
263
+ hop further out: two records whose producer never changed are still
264
+ different when something *upstream* of it did. ``code_chain`` closes that,
265
+ contributing one ``Code:<fn>`` column per upstream function that genuinely
266
+ holds more than one version. scidb omits single-version functions, so an
267
+ unedited project gets no code columns at all and nothing changes for it.
268
+
269
+ Code columns come **first**: they are the axis a reader most often wants
270
+ pinned, and a stable leading position beats having them appear wherever the
271
+ branch-param iteration order happened to put them.
272
+
273
+ **Only columns that actually distinguish something are attached.** A branch
274
+ param holding the same value on every record is dropped: it cannot separate
275
+ two records, so as a factor it asks the user to choose between one thing,
276
+ and the ``variant`` tag makes it look like a swept parameter that needs a
277
+ decision. Code axes were always filtered this way (scidb omits
278
+ single-version functions); branch params were not, because
279
+ ``branch_params_batch`` returns every upstream *constant* regardless of
280
+ whether it varies. Absence counts as a value in that test — a key present on
281
+ some records and missing on others does tell them apart.
282
+
283
+ See ``docs/claude/variant-selection.md`` and
284
+ ``docs/claude/function-version-variants.md``.
285
+ """
286
+ from scidb.provenance_query import variant_identity_batch
287
+
288
+ record_ids = frame["record_id"].tolist()
289
+ ident = variant_identity_batch(db._duck, record_ids)
290
+
291
+ # --- code chain: one column per multi-version upstream function ---
292
+ # Sorted by function name so the column order is a property of the data and
293
+ # not of dict iteration — a saved PlotSpec must keep meaning the same thing.
294
+ fn_names = sorted(
295
+ {name for info in ident.values() for name in info.get("code_chain", {})}
296
+ )
297
+ code_keys: list[str] = []
298
+ axes: list[dict] = []
299
+ for fn_name in fn_names:
300
+ column = f"{VERSION_FACTOR_PREFIX}{fn_name}"
301
+ while column in frame.columns: # never shadow a schema key
302
+ column += "_"
303
+ frame[column] = [
304
+ (ident.get(rid) or {}).get("code_chain", {}).get(
305
+ fn_name, MISSING_VERSION_LEVEL
306
+ )
307
+ for rid in record_ids
308
+ ]
309
+ code_keys.append(column)
310
+ axes.append(
311
+ {"column": column, "kind": "code", "function": fn_name, "param": None}
312
+ )
313
+
314
+ # --- branch params ---
315
+ candidate_keys: list[str] = []
316
+ for info in ident.values():
317
+ for key in info["branch_params"]:
318
+ if key not in candidate_keys:
319
+ candidate_keys.append(key)
320
+
321
+ param_keys: list[str] = []
322
+ constants: dict[str, Any] = {}
323
+ for key in candidate_keys:
324
+ values = [
325
+ _stringify(ident.get(rid, {}).get("branch_params", {}).get(key))
326
+ for rid in record_ids
327
+ ]
328
+ # A constant is not an axis.
329
+ #
330
+ # A variant column exists for exactly one reason: to stop records that
331
+ # DIFFER from being overplotted as replicates. A column holding the same
332
+ # value on every record cannot do that, so offering it as a factor asks
333
+ # the user to choose between one thing — and, being tagged `variant`, it
334
+ # is indistinguishable from a genuinely swept parameter until you count
335
+ # its levels.
336
+ #
337
+ # Code axes have had this guard from the start: scidb's
338
+ # `code_version_ordinals` omits single-version functions, which is why an
339
+ # unedited project gets no `Code:` columns at all. Branch params never
340
+ # got the matching rule, because `branch_params_batch` returns every
341
+ # upstream CONSTANT whether it varies or not — the name promises a branch,
342
+ # the query does not check for one. Measured on a real project
343
+ # (2026-09-11): `filterDelsys.config` and `filterDelsys.Fs` each held ONE
344
+ # level across both records and still demanded a role.
345
+ #
346
+ # **Absence counts as a value.** The test is over the raw list, not over
347
+ # the non-null values, because a key present on some records and missing
348
+ # on others genuinely does tell them apart — dropping it there would
349
+ # reintroduce the very overplotting this function exists to prevent. A
350
+ # key missing everywhere is all-None, one distinct value, and goes.
351
+ #
352
+ # Known gap, pre-existing and deliberately not addressed here: a
353
+ # partially-present key survives with None on the records that lack it,
354
+ # and `FactorInfo.levels` drops nulls, so those rows carry a level-less
355
+ # NaN. Code axes solve this with MISSING_VERSION_LEVEL; branch params
356
+ # have no equivalent sentinel yet.
357
+ if len(set(values)) <= 1:
358
+ constants[key] = values[0] if values else None
359
+ continue
360
+ frame[key] = values
361
+ param_keys.append(key)
362
+ # Branch params are namespaced ``{producing_fn}.{param}`` by
363
+ # `_build_upstream_closure`. A bare key (no dot) is possible in principle
364
+ # and split defensively rather than assumed away.
365
+ function, _, param = key.rpartition(".")
366
+ axes.append(
367
+ {
368
+ "column": key,
369
+ "kind": "param",
370
+ "function": function or None,
371
+ "param": param,
372
+ }
373
+ )
374
+
375
+ keys = code_keys + param_keys
376
+
377
+ if constants:
378
+ # Never silent. These values are real provenance — they are simply not a
379
+ # CHOICE — and a user hunting for the filter cutoff they swept needs to
380
+ # see that this layer looked at it and found one value, rather than that
381
+ # it was never there.
382
+ Log.info(
383
+ "%d constant branch param(s) are not variant axes (one value over "
384
+ "%d record(s)): %s",
385
+ len(constants),
386
+ len(record_ids),
387
+ {key: _truncate(value) for key, value in sorted(constants.items())},
388
+ layer=LAYER,
389
+ )
390
+
391
+ if keys:
392
+ counts = {
393
+ column: int(frame[column].astype(str).nunique(dropna=False))
394
+ for column in keys
395
+ }
396
+ # A code axis reporting one level means scidb emitted a single-version
397
+ # function, which `code_version_ordinals` promises not to do. Deliberately
398
+ # NOT filtered here: duplicating that rule would hide the regression
399
+ # instead of surfacing it.
400
+ thin = sorted(
401
+ column for column in code_keys if counts.get(column, 0) <= 1
402
+ )
403
+ if thin:
404
+ Log.warn(
405
+ "code axis/axes %s hold one version — scidb is expected to omit "
406
+ "single-version functions, so this is a provenance bug, not a "
407
+ "plotting one",
408
+ thin,
409
+ layer=LAYER,
410
+ )
411
+ Log.info(
412
+ "variant axis levels over %d record(s): %s",
413
+ len(record_ids),
414
+ counts,
415
+ layer=LAYER,
416
+ )
417
+
418
+ latest_column = None
419
+ if code_keys:
420
+ latest_column = LATEST_COLUMN
421
+ while latest_column in frame.columns:
422
+ latest_column += "_"
423
+ # Deliberately NOT appended to `keys`: it is a filter helper, not a
424
+ # condition anyone plots by.
425
+ frame[latest_column] = [
426
+ bool((ident.get(rid) or {}).get("is_latest")) for rid in record_ids
427
+ ]
428
+
429
+ Log.info(
430
+ "attached %d code column(s) %s over %d record(s) (%d row(s) current) "
431
+ "— these would otherwise plot as replicates of each other",
432
+ len(code_keys),
433
+ code_keys,
434
+ len(record_ids),
435
+ int(frame[latest_column].sum()),
436
+ layer=LAYER,
437
+ )
438
+
439
+ if not keys:
440
+ return frame, [], None, []
441
+
442
+ Log.debug("attached %d variant column(s): %s", len(keys), keys, layer=LAYER)
443
+ return frame, keys, latest_column, axes
444
+
445
+
446
+ def _stringify(value: Any) -> Any:
447
+ if value is None:
448
+ return None
449
+ if isinstance(value, bool):
450
+ return str(value)
451
+ return str(value)
452
+
453
+
454
+ def _truncate(value: Any, limit: int = 60) -> Any:
455
+ """Shorten a value for a log line.
456
+
457
+ A struct-valued branch param stringifies to its whole repr — measured at
458
+ ~100 characters for one filter config, and unbounded in principle. Several
459
+ of those on one line buries the key names the line exists to report.
460
+ """
461
+ if not isinstance(value, str) or len(value) <= limit:
462
+ return value
463
+ return f"{value[: limit - 1]}…"