scistackstatsdb 0.1.41__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.
@@ -0,0 +1,37 @@
1
+ .venv
2
+ .DS_Store
3
+ */*/__pycache__
4
+ others_projects/sciforge
5
+ scistack-gui/extension/node_modules/
6
+ scistack-gui/frontend/node_modules/__pycache__/
7
+ *.pyc
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
11
+ *.egg-info/
12
+ # Python package build output (rebuilt fresh by CI on every publish; a
13
+ # committed dist/ gets deleted by `rm -rf dist` mid-build and dirties the
14
+ # tree for hatch-vcs). Excludes scistack-gui/extension/dist/, which is an
15
+ # intentionally committed compiled bundle, not a Python build artifact.
16
+ /dist/
17
+ /*/dist/
18
+ # Generated database artifacts (DuckDB data/lineage + write-ahead logs)
19
+ *.duckdb
20
+ *.duckdb.wal
21
+ *.wal
22
+ scistack-gui/frontend/node_modules/
23
+ # Compiled output of the frontend's React-free unit tests (npm test in
24
+ # scistack-gui/frontend). Regenerated by `tsc -p tsconfig.test.json`; unlike
25
+ # extension/dist/, nothing loads it at runtime.
26
+ scistack-gui/frontend/dist/
27
+ # mkdocs build output (regenerated by `mkdocs build`)
28
+ /site/
29
+ # Runtime output of code_export_service (pipeline-to-code export) — timestamped
30
+ # per-run files, not source.
31
+ /exports/
32
+ *.log
33
+ output.txt
34
+ scimatlab/tests/matlab/test-failures.txt
35
+
36
+ # csv-stats is its own repo (github.com/mtillman14/csv-stats), cloned here for editable dev
37
+ /csv-stats/
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.5
2
+ Name: scistackstatsdb
3
+ Version: 0.1.41
4
+ Summary: scidb-backed statistics: saved StatStudio specs, linked plots and stat_ endpoints
5
+ Author: SciStack Contributors
6
+ License-Expression: MIT
7
+ Keywords: provenance,scidb,scistack,statistics,visualization
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: scistack-db==0.1.41
21
+ Requires-Dist: scistacklog==0.1.41
22
+ Requires-Dist: scistackplot==0.1.41
23
+ Requires-Dist: scistackplotdb==0.1.41
24
+ Requires-Dist: scistackstats==0.1.41
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # scistackstatsdb
31
+
32
+ scidb-backed statistics: loads StatStudio tables through `scistackplotdb`,
33
+ and (later stages) saved stats specs, linked plots and `stat_` endpoints.
@@ -0,0 +1,4 @@
1
+ # scistackstatsdb
2
+
3
+ scidb-backed statistics: loads StatStudio tables through `scistackplotdb`,
4
+ and (later stages) saved stats specs, linked plots and `stat_` endpoints.
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs", "hatch-dependency-coversion==0.0.1a4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.version]
6
+ source = "vcs"
7
+ raw-options = { search_parent_directories = true, local_scheme = "no-local-version" }
8
+
9
+ # Sibling SciStack packages are pinned to EXACTLY this build's version at build
10
+ # time (lockstep releases), so [project] dependencies lists them unversioned. The
11
+ # extra "dynamic" entry is what makes hatchling run the hook. Every sibling in
12
+ # dependencies must be named here: CI's build job fails a wheel whose internal
13
+ # dependency is not "==<its own version>".
14
+ [tool.hatch.metadata.hooks.dependency-coversion]
15
+ override-versions-of = ["scistackstats", "scistackplot", "scistackplotdb", "scistack-db", "scistacklog"]
16
+
17
+ [project]
18
+ name = "scistackstatsdb"
19
+ dynamic = ["version", "dependency-coversioning"]
20
+ description = "scidb-backed statistics: saved StatStudio specs, linked plots and stat_ endpoints"
21
+ readme = "README.md"
22
+ license = "MIT"
23
+ requires-python = ">=3.10"
24
+ authors = [
25
+ { name = "SciStack Contributors" }
26
+ ]
27
+ keywords = [
28
+ "statistics",
29
+ "visualization",
30
+ "scidb",
31
+ "scistack",
32
+ "provenance",
33
+ ]
34
+ classifiers = [
35
+ "Development Status :: 4 - Beta",
36
+ "Intended Audience :: Science/Research",
37
+ "License :: OSI Approved :: MIT License",
38
+ "Operating System :: OS Independent",
39
+ "Programming Language :: Python :: 3",
40
+ "Programming Language :: Python :: 3.10",
41
+ "Programming Language :: Python :: 3.11",
42
+ "Programming Language :: Python :: 3.12",
43
+ "Programming Language :: Python :: 3.13",
44
+ "Topic :: Scientific/Engineering :: Information Analysis",
45
+ "Typing :: Typed",
46
+ ]
47
+ dependencies = [
48
+ "scistackstats",
49
+ "scistackplot",
50
+ "scistackplotdb",
51
+ "scistack-db",
52
+ "scistacklog",
53
+ ]
54
+
55
+ [project.optional-dependencies]
56
+ dev = [
57
+ "pytest>=7.0",
58
+ "pytest-cov>=4.0",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.sdist]
62
+ include = [
63
+ "/src",
64
+ ]
65
+
66
+ [tool.hatch.build.targets.wheel]
67
+ packages = ["src/scistackstatsdb"]
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+ pythonpath = ["src"]
@@ -0,0 +1,53 @@
1
+ """
2
+ scistackstatsdb — scidb-backed statistics.
3
+
4
+ Loads StatStudio tables from a scidb database through scistackplotdb's
5
+ ``ScidbSource`` (the same loading the figures use), saves stats tables and
6
+ stats presets (``saved``), finds the stats tables linked to a saved plot, and
7
+ carries them in project bundles (``bundle_section``). The statistics
8
+ themselves are :mod:`scistackstats`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from scistackstats import StatsSpec, load_tables
16
+
17
+ from .endpoint import StatsEndpointCode, generate_stats_endpoint, required_declarations
18
+ from .saved import (
19
+ SavedStats,
20
+ SavedStatsError,
21
+ SavedStatsExists,
22
+ SavedStatsInfo,
23
+ StatsPresetInfo,
24
+ apply_stats_preset,
25
+ find_saved_stats,
26
+ hide_saved_stats,
27
+ hide_stats_preset,
28
+ linked_stats_for,
29
+ list_saved_stats,
30
+ list_stats_presets,
31
+ load_saved_stats,
32
+ rename_saved_stats,
33
+ rename_stats_preset,
34
+ save_stats,
35
+ save_stats_preset,
36
+ saved_stats_history,
37
+ )
38
+
39
+
40
+ def load_stats_tables(db: Any, stats_spec: StatsSpec) -> dict:
41
+ """``{variable: LongTable}`` for every DV of ``stats_spec``, from ``db``."""
42
+ from scistackplotdb import ScidbSource
43
+
44
+ return load_tables(ScidbSource(db), stats_spec)
45
+
46
+
47
+ __all__ = [
48
+ "StatsEndpointCode", "generate_stats_endpoint", "required_declarations",
49
+ "SavedStats", "SavedStatsError", "SavedStatsExists", "SavedStatsInfo", "StatsPresetInfo",
50
+ "apply_stats_preset", "find_saved_stats", "hide_saved_stats", "hide_stats_preset", "linked_stats_for",
51
+ "list_saved_stats", "list_stats_presets", "load_saved_stats", "load_stats_tables", "rename_saved_stats",
52
+ "rename_stats_preset", "save_stats", "save_stats_preset", "saved_stats_history",
53
+ ]
@@ -0,0 +1,42 @@
1
+ """The ``stats`` section of a project bundle: saved stats tables and stats
2
+ presets, copied row for row (``VersionedStore.dump_rows`` / ``load_rows``),
3
+ like scistackplotdb's ``plots`` section. A schema-key rename is applied with
4
+ the same exact-string remap the plots section uses."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ from scistacklog import Log
11
+ from scistackplotdb.bundle_section import _remap_row
12
+
13
+ from .saved import PRESET_STORE, STORE
14
+
15
+ LAYER = "scistackstatsdb"
16
+
17
+ _STORES = {"saved_stats.json": STORE, "stats_presets.json": PRESET_STORE}
18
+
19
+
20
+ class StatsSection:
21
+ name = "stats"
22
+
23
+ def export(self, ctx) -> "dict[str, bytes]":
24
+ files: dict[str, bytes] = {}
25
+ for filename, store in _STORES.items():
26
+ rows = store.dump_rows(ctx.db)
27
+ if rows:
28
+ files[filename] = json.dumps(rows, indent=2, default=str).encode("utf-8")
29
+ Log.info("[bundle_section] stats export: %s", sorted(files), layer=LAYER)
30
+ return files
31
+
32
+ def import_(self, ctx, files: "dict[str, bytes]") -> dict:
33
+ report = {}
34
+ key_map = getattr(ctx, "key_map", None)
35
+ for filename, store in _STORES.items():
36
+ if filename in files:
37
+ rows = json.loads(files[filename].decode("utf-8"))
38
+ if key_map is not None and not key_map.is_identity:
39
+ rows = [_remap_row(row, store, key_map, ctx.map_report) for row in rows]
40
+ report[store.table] = store.load_rows(ctx.db, rows)
41
+ Log.info("[bundle_section] stats import: %s", report, layer=LAYER)
42
+ return report
@@ -0,0 +1,75 @@
1
+ """StatStudio → pipeline: the ``stat_`` function plus its ``for_each`` call.
2
+
3
+ The function is ``scistackstats.codegen.generate_stats_function``. This
4
+ module writes the call around it:
5
+
6
+ - **no iteration keywords** (P7): one call over the whole fan-out, so a
7
+ correction family that spans strata or DVs is computed in one place. scidb
8
+ stores one record per call (a grand aggregation);
9
+ - one input per DV variable, loaded by name;
10
+ - ``finalized`` (record mode) by default, so the result is a lineage record
11
+ that goes stale when its inputs change and that ``scidb report`` lists.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from scistacklog import Log
19
+ from scistackplot import LongTable
20
+
21
+ from scistackstats import StatsSpec
22
+ from scistackstats.codegen import generate_stats_function
23
+
24
+ LAYER = "scistackstatsdb"
25
+
26
+
27
+ @dataclass
28
+ class StatsEndpointCode:
29
+ function_name: str
30
+ function_source: str
31
+ foreach_source: str
32
+ output_variable: str
33
+ inputs: dict[str, str] = field(default_factory=dict)
34
+
35
+ @property
36
+ def source(self) -> str:
37
+ return f"{self.function_source}\n\n{self.foreach_source}"
38
+
39
+
40
+ def default_output_variable(stats_spec: StatsSpec) -> str:
41
+ """``Ratings`` -> ``RatingsStats``."""
42
+ return f"{stats_spec.targets[0].variable}Stats"
43
+
44
+
45
+ def generate_stats_endpoint(
46
+ stats_spec: StatsSpec,
47
+ tables: dict[str, LongTable],
48
+ *,
49
+ function_name: str | None = None,
50
+ output_variable: str | None = None,
51
+ finalized: bool = True,
52
+ ) -> StatsEndpointCode:
53
+ """The ``stat_`` function and its ``for_each`` call. Raises
54
+ ``scistackstats.codegen.NotExportable`` with the reason when the spec
55
+ cannot be exported yet."""
56
+ fn = generate_stats_function(stats_spec, tables, function_name=function_name)
57
+ output = output_variable or default_output_variable(stats_spec)
58
+ lines = ["for_each(", f" {fn.name},", " inputs={"]
59
+ lines += [f' "{param}": {variable},' for param, variable in fn.inputs.items()]
60
+ lines += [
61
+ " },",
62
+ f" outputs=[{output}],",
63
+ f" as_table={list(fn.inputs)!r},",
64
+ f" finalized={finalized},",
65
+ ")",
66
+ ]
67
+ Log.info("[stats endpoint] %s: inputs %s -> %s, finalized=%s (no iteration: one record)",
68
+ fn.name, fn.inputs, output, finalized, layer=LAYER)
69
+ return StatsEndpointCode(function_name=fn.name, function_source=fn.source,
70
+ foreach_source="\n".join(lines), output_variable=output, inputs=fn.inputs)
71
+
72
+
73
+ def required_declarations(code: StatsEndpointCode) -> list[str]:
74
+ """Variable types the call needs that may not exist yet (the output)."""
75
+ return [code.output_variable]
@@ -0,0 +1,236 @@
1
+ """Saved StatStudio tables and stats presets, kept in the project database.
2
+
3
+ Both use scistackplotdb's :class:`~scistackplotdb._versioned.VersionedStore`,
4
+ the owner of the saved-plot rules (S20). So a saved stats table behaves like
5
+ a saved plot:
6
+
7
+ - the id is the identity, the name a label, unique across the project;
8
+ - re-saving a visible name appends a version, and the newest version opens;
9
+ - "remove" hides every version, and nothing is ever deleted;
10
+ - save is strict (``StatsSpec.from_dict``), open salvages
11
+ (``scistackstats.restore``), and opening never rewrites the stored copy.
12
+
13
+ A saved stats table may name the saved plot it syncs with
14
+ (``StatsSpec.linked_plot``, S11). The link is stored only here.
15
+ :func:`linked_stats_for` finds a plot's stats tables by query, so the plot
16
+ store never holds a back-reference.
17
+
18
+ A **stats preset** stores only the stats settings
19
+ (``scistackstats.restore.STATS_SETTINGS``): what to compute and how it reads.
20
+ Applying one to a stats table keeps that table's design, DVs and link.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from scistacklog import Log
29
+ from scistackplotdb._versioned import VersionedStore
30
+
31
+ from scistackstats import StatsSpec, StatsSpecError
32
+ from scistackstats.restore import restore_stats_spec, stats_settings, with_stats_settings
33
+
34
+ LAYER = "scistackstatsdb"
35
+ ENVELOPE_FORMAT = 1
36
+
37
+
38
+ class SavedStatsError(ValueError):
39
+ """A save, rename or load that cannot be done as asked."""
40
+
41
+
42
+ class SavedStatsExists(SavedStatsError):
43
+ """``save_stats(overwrite=False)`` found another visible item with that name."""
44
+
45
+ def __init__(self, existing: "SavedStatsInfo"):
46
+ super().__init__(f"There is already a saved stats table named {existing.name!r} (version {existing.version}).")
47
+ self.existing = existing
48
+
49
+
50
+ STORE = VersionedStore(
51
+ table="_stats_spec", id_column="stats_id", scope_column=None,
52
+ noun="saved stats table", tag="saved-stats", error=SavedStatsError,
53
+ )
54
+ PRESET_STORE = VersionedStore(
55
+ table="_stats_preset", id_column="preset_id", scope_column=None,
56
+ noun="stats preset", tag="stats-preset", error=SavedStatsError,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SavedStatsInfo:
62
+ stats_id: str
63
+ name: str
64
+ version: int
65
+ saved_at: str
66
+ hidden: bool = False
67
+ linked_plot: str | None = None
68
+ dvs: tuple[str, ...] = ()
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {"stats_id": self.stats_id, "name": self.name, "version": self.version, "saved_at": self.saved_at,
72
+ "hidden": self.hidden, "linked_plot": self.linked_plot, "dvs": list(self.dvs)}
73
+
74
+
75
+ @dataclass
76
+ class SavedStats:
77
+ info: SavedStatsInfo
78
+ spec: StatsSpec
79
+ #: Settings that did not come back as saved (``RestoreNote`` dicts).
80
+ notes: list[dict[str, Any]] = field(default_factory=list)
81
+ stored_format: Any = None
82
+
83
+
84
+ def _versions() -> dict[str, str]:
85
+ import scistackstats
86
+ from scistackstats import get_engine
87
+
88
+ out = {"scistackstats": getattr(scistackstats, "__version__", "dev")}
89
+ try:
90
+ out.update(get_engine().versions())
91
+ except Exception: # pragma: no cover - versions are display only
92
+ pass
93
+ return out
94
+
95
+
96
+ def _strict(spec: Any) -> StatsSpec:
97
+ if isinstance(spec, StatsSpec):
98
+ return spec
99
+ try:
100
+ return StatsSpec.from_dict(spec)
101
+ except (StatsSpecError, KeyError, TypeError, ValueError) as exc:
102
+ raise SavedStatsError(f"The stats settings could not be read: {exc}") from exc
103
+
104
+
105
+ def _info(row, envelope: dict | None = None) -> SavedStatsInfo:
106
+ stats_id, _scope, name, version, saved_at, hidden = row
107
+ envelope = envelope or {}
108
+ linked = envelope.get("linked_plot")
109
+ dvs = envelope.get("dvs")
110
+ return SavedStatsInfo(
111
+ stats_id=str(stats_id), name=str(name), version=int(version), saved_at=str(saved_at), hidden=bool(hidden),
112
+ linked_plot=linked if isinstance(linked, str) else None,
113
+ dvs=tuple(str(d) for d in dvs) if isinstance(dvs, list) else (),
114
+ )
115
+
116
+
117
+ def _newest(db, stats_id: str) -> SavedStatsInfo:
118
+ row, text = STORE.load(db, stats_id)
119
+ return _info(row, STORE.parse_envelope(text, f"{row[2]!r} version {row[3]}"))
120
+
121
+
122
+ def save_stats(db, name: str, spec: Any, *, overwrite: bool = True, current_stats_id: str | None = None) -> SavedStatsInfo:
123
+ """Save a stats spec (or its dict, read strictly) as *name*."""
124
+ name = STORE.check_name(name)
125
+ spec = _strict(spec)
126
+ envelope = {
127
+ "format": ENVELOPE_FORMAT,
128
+ "spec": spec.to_dict(),
129
+ "linked_plot": spec.linked_plot,
130
+ "dvs": [d.key for d in spec.targets],
131
+ "saved_with": _versions(),
132
+ }
133
+ row, _new = STORE.save(db, None, name, envelope, overwrite=overwrite, current_id=current_stats_id,
134
+ on_clash=lambda clash: SavedStatsExists(_info(clash)))
135
+ return _info(row, envelope)
136
+
137
+
138
+ def load_saved_stats(db, stats_id: str, *, version: int | None = None) -> SavedStats:
139
+ """One version (the newest by default), salvaged. Never rewrites it."""
140
+ row, text = STORE.load(db, stats_id, version)
141
+ envelope = STORE.parse_envelope(text, f"{row[2]!r} version {row[3]}")
142
+ info = _info(row, envelope)
143
+ restored = restore_stats_spec(envelope.get("spec"), fallback_measure=(info.dvs[0].split("[")[0] if info.dvs else None))
144
+ Log.info("[saved-stats] opened %r version %d (envelope format %r): %d note(s)",
145
+ info.name, info.version, envelope.get("format"), len(restored.notes), layer=LAYER)
146
+ return SavedStats(info=info, spec=restored.spec, notes=[n.to_dict() for n in restored.notes],
147
+ stored_format=envelope.get("format"))
148
+
149
+
150
+ def list_saved_stats(db, *, include_hidden: bool = False) -> list[SavedStatsInfo]:
151
+ return [_info(row[:6], STORE.parse_envelope(row[6], repr(row[2])))
152
+ for row in STORE.list_rows(db, include_hidden=include_hidden, with_envelope=True)]
153
+
154
+
155
+ def saved_stats_history(db, stats_id: str) -> list[SavedStatsInfo]:
156
+ return [_info(row[:6], STORE.parse_envelope(row[6], repr(row[2])))
157
+ for row in STORE.history(db, stats_id, with_envelope=True)]
158
+
159
+
160
+ def rename_saved_stats(db, stats_id: str, new_name: str) -> SavedStatsInfo:
161
+ STORE.rename(db, stats_id, new_name)
162
+ return _newest(db, stats_id)
163
+
164
+
165
+ def hide_saved_stats(db, stats_id: str, hidden: bool = True) -> SavedStatsInfo:
166
+ STORE.set_hidden(db, stats_id, hidden)
167
+ return _newest(db, stats_id)
168
+
169
+
170
+ def linked_stats_for(db, saved_plot_id: str) -> list[SavedStatsInfo]:
171
+ """The visible saved stats tables linked to one saved plot (S11)."""
172
+ found = [info for info in list_saved_stats(db) if info.linked_plot == saved_plot_id]
173
+ Log.debug("[saved-stats] plot %s: %d linked stats table(s)", saved_plot_id, len(found), layer=LAYER)
174
+ return found
175
+
176
+
177
+ def find_saved_stats(db, name: str) -> SavedStatsInfo | None:
178
+ row = STORE.visible_by_name(db, None, STORE.check_name(name))
179
+ return _newest(db, row[0]) if row else None
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Stats presets
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class StatsPresetInfo:
189
+ preset_id: str
190
+ name: str
191
+ version: int
192
+ saved_at: str
193
+ hidden: bool = False
194
+
195
+ def to_dict(self) -> dict[str, Any]:
196
+ return dict(self.__dict__)
197
+
198
+
199
+ def _preset_info(row) -> StatsPresetInfo:
200
+ preset_id, _scope, name, version, saved_at, hidden = row[:6]
201
+ return StatsPresetInfo(str(preset_id), str(name), int(version), str(saved_at), bool(hidden))
202
+
203
+
204
+ def save_stats_preset(db, name: str, spec: Any, *, overwrite: bool = True,
205
+ current_preset_id: str | None = None) -> StatsPresetInfo:
206
+ """Save *spec*'s stats settings (no design, DVs or link) as a preset."""
207
+ name = PRESET_STORE.check_name(name)
208
+ spec = _strict(spec)
209
+ envelope = {"format": ENVELOPE_FORMAT, "settings": stats_settings(spec), "saved_with": _versions()}
210
+ row, _ = PRESET_STORE.save(db, None, name, envelope, overwrite=overwrite, current_id=current_preset_id,
211
+ on_clash=lambda clash: SavedStatsError(PRESET_STORE.clash_message(None, name)))
212
+ return _preset_info(row)
213
+
214
+
215
+ def list_stats_presets(db, *, include_hidden: bool = False) -> list[StatsPresetInfo]:
216
+ return [_preset_info(row) for row in PRESET_STORE.list_rows(db, include_hidden=include_hidden)]
217
+
218
+
219
+ def hide_stats_preset(db, preset_id: str, hidden: bool = True) -> StatsPresetInfo:
220
+ PRESET_STORE.set_hidden(db, preset_id, hidden)
221
+ return _preset_info(PRESET_STORE.latest(db, preset_id))
222
+
223
+
224
+ def rename_stats_preset(db, preset_id: str, new_name: str) -> StatsPresetInfo:
225
+ PRESET_STORE.rename(db, preset_id, new_name)
226
+ return _preset_info(PRESET_STORE.latest(db, preset_id))
227
+
228
+
229
+ def apply_stats_preset(db, preset_id: str, target: StatsSpec, *, version: int | None = None) -> tuple[StatsSpec, list[dict]]:
230
+ """``target`` with the preset's stats settings; its design, DVs and link kept."""
231
+ row, text = PRESET_STORE.load(db, preset_id, version)
232
+ envelope = PRESET_STORE.parse_envelope(text, f"{row[2]!r} version {row[3]}")
233
+ spec, notes = with_stats_settings(target, envelope.get("settings") or {})
234
+ Log.info("[stats-preset] %r version %d applied to %s: %d note(s)", row[2], row[3],
235
+ ", ".join(d.key for d in target.targets), len(notes), layer=LAYER)
236
+ return spec, [n.to_dict() for n in notes]