You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rewritten after a grilling session (2026-08-26). The original scope — consolidate two duplicate lists and the hovertemplate if/elif — was too small. Measuring first found ~830 lines of per-plot-type code interleaved into five shared modules, and six spellings of the derived-section list rather than three. Full design record: .scratch/architecture/10-plot-types-package.md.
The decision
A plot type is a module. Everything that varies by plot type lives in that type's package; no other module branches on plot type.
Why — the measurement
PSD touched 7 src files / +680 lineswith the PlotType abstraction already built, and still missed a site. The code records the miss itself, at other/find_load_format.py:112:
Adding a fifth derived plot type means adding a row here -- forgetting to is what made 'psd' validate cleanly yet never render.
That is the failure mode to design against: a plot type that passes validation and renders nothing.
Per-plot-type code, currently interleaved into shared modules:
Module
Total
Per-plot-type
Share
database_options_xlsx.py
657
~248 (3 sheet blocks + 2 psd-only helpers)
38%
database_options_parser.py
379
~128 (_check_spectral_types, _check_psd_entries, the pair)
34%
plot_assembly.py
526
~137 (builders, qualifiers, _DERIVED_PLOTS)
26%
signal_container.py
1253
~285 (3 factories, heatmap branch, hover if/elif)
23%
other/find_load_format.py
—
~36 (PER_FILE_DERIVED_SECTIONS + 3 qualifiers)
—
spectral.py
230
230 (already isolated)
100%
Six spellings of the same list, three memberships.loop is absent from the parser's two because it has no config class — grep -n loop database_options_parser.py returns one hit, and it is the word "loop" in a comment. loop has zero config validation today.
constants.py:720-726 — the import-time guard (LOOP, SPEC, PSD)
database_options_parser.py:104-106 (SPEC, PSD) — config class
database_options_parser.py:135-139 (SPEC, PSD) — config class
database_options_xlsx.py:38-45 + three reader blocks (LOOPS, SPECTROGRAMS, PSDS)
Sites 2 and 3 are the same three shape-walks with a different leaf op — resolve-then-prefix datasource:: vs blind-prefix stem::. Six functions, three shapes, two leaf ops; the duplicated copy is the one forgotten for PSD.
The split is by import-reachability, not by "declarative vs machinery".
schema.py — what every layer may import: name/section_key, config keys, validation, map_refs, xlsx sheet spec + row interpretation, and the five capabilities (pure booleans, read by signal_container, hence in the leaf half).
plot.py — what only the top of the stack may: build(), the maths, the installed rendering. Imports signal_container and spectral.
time_series is not a package. It is the substrate: one registry line, PlotTypeBase(name=...), inheriting every default; derived = [t for t in AVAILABLE if t.section_key]. Precedent for a registered-but-degenerate member: OtherDataSource stubs _load/extract and stays in DataSource.AVAILABLE. The capability table already encodes this — every default is time_series's behaviour and each derived type is a small delta:
Capability
time_series
loop
spectrogram
psd
time axis
✓
✗
✓
✗
unified hover
✓
✗
✗
✗
resampled
✓
✗
✗
✗
grid layout
✗
✓
✗
✗
colorbar
✗
✗
✓
✗
The constraint that shapes everything: a real import cycle
signal_container.py:16 imports datasource.formatting.timezone; that executes datasource/__init__.py → registry.py → every source module → other/find_load_format.py, which imports signal_container. That loop exists today and survives only because Python tolerates submodule imports of a half-initialised package.
plot.py needs Signal to build one. So signal_container must never import a plot.py — import signal_container first and it stops at its own import line, before class Signal exists, and the from … import Signal inside plot.py raises ImportError. Not the tolerable case; a hard failure whose occurrence depends on the entry point.
Hence: capabilities live in schema.py, and rendering is pushed, not pulled.
Decisions
Signal becomes type-agnostic.loop_from_signals, spectrogram_from_signal, psd_from_signal (~215 lines) move out of Signal into their type's plot.py. signal_container.py 1253 → ~970.
Layout mirrors datasource/sources/ — package per type, two files.
Registry holds all four; time_series is degenerate. The five capability tuples leave constants.py; all ten read sites go through the registry. Rejected: capabilities staying in constants (a new type would declare behaviour in two places); fallback-to-defaults for unregistered types (a typo'd string would silently get time_series behaviour — the same silent acceptance this issue exists to kill).
xlsx: the reader transcribes, the plot type interprets. Sheet name, required columns and row→config mapping move to schema.py; _read_optional_sheet, _is_empty, _to_float, _is_truthy and file-level orchestration/error reporting stay in database_options_xlsx.py. Same seam as ADR-0010, one layer up; exact line is an implementation judgement. Tiebreaker: the spreadsheet columns and the JSON keys are one schema in two spellings (psds requires freq_min/freq_maxbecausePsdConfig requires freq_range), and splitting them across modules is what lets them drift — in the format clinicians actually author.
Trace construction: template method, two hooks. Base builds the Scatter and applies the hover spec; loop and psd override hover only; spectrogram overrides trace construction outright (a go.Heatmap is a different Plotly primitive, not a Scatter variant). The ~50 shared lines (timezone conversion, line/marker dicts, unit suffixes, hover_formatters keyword path, legendonly) are shared by nature — they are about TraceOptions, not about plot type.
Push, not pull.build()installs rendering on the Signal it returns — a HoverSpec(template, customdata) and, for spectrogram, a trace factory. to_plotly_trace precedence becomes: user's hover_template → keyword formatter → installed spec → None. Strictly one-way dependency, no lazy imports. Rejected: pull plus a function-local import Signal in each build() — the cycle would then be held off by an unwritten convention, invisible at the point of violation, failing as a non-deterministic ImportError; the same bug class being fixed. A plain time_series Signal needs no installation — Signal's own defaults are its behaviour.
Nothing plot-type-related stays in constants.py. Verified: the file's only PlotType reference is the guard at :720-726, which is deleted (DEFAULT_LOOP_SUBPLOT_HEIGHT / LoopSubplotHeight only look related — "loop_subplot_height" is a user-settings key string). SpectrogramConfig/PsdConfig move to their schema.py, following datasource/sources/<name>/options.py: constants.py holds cross-cutting literals, a module's own option schema lives with the module. KNOWN_SECTION_KEYS declares only non-derived keys and the parser unions {s.section_key for s in schemas}, so a new plot type can no longer trigger "Unknown key" warnings on a valid config. cst.Spectralstays — it is the signal-analysis domain shared by two plot types, the relation io/ has to datasources.
Deliberately no ADR. The datasource module layout has none either — ADR-0008 records the genuine judgement call (module vs other/), not the layout, which the folder and /new-datasource teach on their own. The one rule here with a revivable alternative (push vs pull) is enforced by a test instead of a document.
Delivery — two commits, one PR, branch off main
Split along the two-file seam, so staging costs nothing extra to design and carries no transitional duplication:
Moves
Guarded by
Touches
1 — plot.py
builders, trace, hover, capabilities, base + registry
Each stage has one dominant failure mode with one suite that catches it. Stage 1 is the risky half and carries the visible win; stage 2 is mechanical.
Consequences to handle
ValidationIssue must move to a leaf. A 3-field NamedTuple in database_options_parser.py; a schema.py returning issues would cycle. ~6 lines plus two import sites (wrapper.py:12, data_callbacks.py:37).
PAGE_ORDER moves to plot_types/registry.py — an ordering across types belongs to the collection. Same deviation from CLAUDE.md's "orderings in constants.py" that DataSource.AVAILABLE already makes, for the same reason.
other's loop walker crashes on a non-list config where plot_assembly's returns it unchanged; other's spectrogram walker copies where plot_assembly's aliases. Unifying takes the guarded version — a small behaviour change in the safe direction: a malformed per-file loop currently skips the whole file, afterwards it is skipped as one bad plot.
CLAUDE.md's "Where things live" tree and Architecture section need a plot_types/ entry.
Acceptance criteria
No module outside plot_types/ branches on plot type or hardcodes a derived section key.
signal_container imports no plot_types/*/plot module (asserted by test).
Adding a plot type = one package + one registry line. Nothing in constants.py, database_options_parser.py, database_options_xlsx.py, other/find_load_format.py, plot_assembly.py or signal_container.py changes.
A forgotten piece is an import-time crash, never a config that validates and renders nothing.
loop gains the config validation the other two have.
Snapshot tests unchanged — no figure moves.
Tests
Fake fourth plot type (tests/plot_types/fake/), registered by a fixture, driven through all six paths: validates, warns on a bad key, scopes references inside an other::<stem> block, reads an xlsx sheet, builds, renders. ~100 lines, milliseconds, no committed fixture — xlsx_bytes_to_database_options(bytes) already exists (database_options_xlsx.py:622), so the workbook is built to a BytesIO at test time. Shaped deliberately unlike the real three (a config section whose value is a bare string) so it exercises map_refs on an unused shape.
AST boundary test, in the style of tests/datasource/test_load_config_independence.py: no module outside plot_types/ compares against a plot type string or hardcodes a section key, and signal_container imports no plot_types/*/plot. This replaces the ADR for decision 6, and guards the two Dash callback files — the least-tested layer, and the easiest place for a type branch to reappear.
Import-time completeness guard in plot_types/registry.py: every member has both halves, section_key matches its package name, capabilities declared. Inherits the deleted constants.py guard's job.
Relations
Split from #81; the :: half of that section is deferred to #80. Follow-ups opened alongside this rewrite: collapsing other's per-file scoping into plot_assembly, and a /new-plot-type skill.
The decision
Why — the measurement
PSD touched 7 src files / +680 lines with the
PlotTypeabstraction already built, and still missed a site. The code records the miss itself, atother/find_load_format.py:112:That is the failure mode to design against: a plot type that passes validation and renders nothing.
Per-plot-type code, currently interleaved into shared modules:
database_options_xlsx.pydatabase_options_parser.py_check_spectral_types,_check_psd_entries, the pair)plot_assembly.py_DERIVED_PLOTS)signal_container.pyother/find_load_format.pyPER_FILE_DERIVED_SECTIONS+ 3 qualifiers)spectral.pySix spellings of the same list, three memberships.
loopis absent from the parser's two because it has no config class —grep -n loop database_options_parser.pyreturns one hit, and it is the word "loop" in a comment.loophas zero config validation today.constants.py:720-726— the import-time guard (LOOP, SPEC, PSD)plot_assembly.py:278-291_DERIVED_PLOTS— build + qualify + refusalsother/find_load_format.py:114-118PER_FILE_DERIVED_SECTIONS— qualifydatabase_options_parser.py:104-106(SPEC, PSD) — config classdatabase_options_parser.py:135-139(SPEC, PSD) — config classdatabase_options_xlsx.py:38-45+ three reader blocks (LOOPS, SPECTROGRAMS, PSDS)Sites 2 and 3 are the same three shape-walks with a different leaf op — resolve-then-prefix
datasource::vs blind-prefixstem::. Six functions, three shapes, two leaf ops; the duplicated copy is the one forgotten for PSD.Layout
Mirrors
datasource/sources/<name>/exactly —options.py/find_load_format.pybecomesschema.py/plot.py.The split is by import-reachability, not by "declarative vs machinery".
schema.py— what every layer may import:name/section_key, config keys, validation,map_refs, xlsx sheet spec + row interpretation, and the five capabilities (pure booleans, read bysignal_container, hence in the leaf half).plot.py— what only the top of the stack may:build(), the maths, the installed rendering. Importssignal_containerandspectral.time_seriesis not a package. It is the substrate: one registry line,PlotTypeBase(name=...), inheriting every default;derived = [t for t in AVAILABLE if t.section_key]. Precedent for a registered-but-degenerate member:OtherDataSourcestubs_load/extractand stays inDataSource.AVAILABLE. The capability table already encodes this — every default is time_series's behaviour and each derived type is a small delta:The constraint that shapes everything: a real import cycle
signal_container.py:16importsdatasource.formatting.timezone; that executesdatasource/__init__.py→registry.py→ every source module →other/find_load_format.py, which importssignal_container. That loop exists today and survives only because Python tolerates submodule imports of a half-initialised package.plot.pyneedsSignalto build one. Sosignal_containermust never import aplot.py— importsignal_containerfirst and it stops at its own import line, beforeclass Signalexists, and thefrom … import Signalinsideplot.pyraisesImportError. Not the tolerable case; a hard failure whose occurrence depends on the entry point.Hence: capabilities live in
schema.py, and rendering is pushed, not pulled.Decisions
Signalbecomes type-agnostic.loop_from_signals,spectrogram_from_signal,psd_from_signal(~215 lines) move out ofSignalinto their type'splot.py.signal_container.py1253 → ~970.datasource/sources/— package per type, two files.time_seriesis degenerate. The five capability tuples leaveconstants.py; all ten read sites go through the registry. Rejected: capabilities staying in constants (a new type would declare behaviour in two places); fallback-to-defaults for unregistered types (a typo'd string would silently get time_series behaviour — the same silent acceptance this issue exists to kill).schema.py;_read_optional_sheet,_is_empty,_to_float,_is_truthyand file-level orchestration/error reporting stay indatabase_options_xlsx.py. Same seam as ADR-0010, one layer up; exact line is an implementation judgement. Tiebreaker: the spreadsheet columns and the JSON keys are one schema in two spellings (psdsrequiresfreq_min/freq_maxbecausePsdConfigrequiresfreq_range), and splitting them across modules is what lets them drift — in the format clinicians actually author.go.Heatmapis a different Plotly primitive, not a Scatter variant). The ~50 shared lines (timezone conversion, line/marker dicts, unit suffixes,hover_formatterskeyword path,legendonly) are shared by nature — they are aboutTraceOptions, not about plot type.build()installs rendering on the Signal it returns — aHoverSpec(template, customdata)and, for spectrogram, a trace factory.to_plotly_traceprecedence becomes: user'shover_template→ keyword formatter → installed spec →None. Strictly one-way dependency, no lazy imports. Rejected: pull plus a function-localimport Signalin eachbuild()— the cycle would then be held off by an unwritten convention, invisible at the point of violation, failing as a non-deterministicImportError; the same bug class being fixed. A plain time_series Signal needs no installation —Signal's own defaults are its behaviour.constants.py. Verified: the file's onlyPlotTypereference is the guard at:720-726, which is deleted (DEFAULT_LOOP_SUBPLOT_HEIGHT/LoopSubplotHeightonly look related —"loop_subplot_height"is a user-settings key string).SpectrogramConfig/PsdConfigmove to theirschema.py, followingdatasource/sources/<name>/options.py:constants.pyholds cross-cutting literals, a module's own option schema lives with the module.KNOWN_SECTION_KEYSdeclares only non-derived keys and the parser unions{s.section_key for s in schemas}, so a new plot type can no longer trigger "Unknown key" warnings on a valid config.cst.Spectralstays — it is the signal-analysis domain shared by two plot types, the relationio/has to datasources.Deliberately no ADR. The datasource module layout has none either — ADR-0008 records the genuine judgement call (module vs
other/), not the layout, which the folder and/new-datasourceteach on their own. The one rule here with a revivable alternative (push vs pull) is enforced by a test instead of a document.Delivery — two commits, one PR, branch off
mainSplit along the two-file seam, so staging costs nothing extra to design and carries no transitional duplication:
plot.pysignal_container,plot_assembly, 2 dash callback files,constants.PlotTypecapabilitiesschema.pymap_refsdatabase_options_parser,database_options_xlsx,other/find_load_format,constants.DatabaseOptions,ValidationIssuemoveEach stage has one dominant failure mode with one suite that catches it. Stage 1 is the risky half and carries the visible win; stage 2 is mechanical.
Consequences to handle
ValidationIssuemust move to a leaf. A 3-fieldNamedTupleindatabase_options_parser.py; aschema.pyreturning issues would cycle. ~6 lines plus two import sites (wrapper.py:12,data_callbacks.py:37).PAGE_ORDERmoves toplot_types/registry.py— an ordering across types belongs to the collection. Same deviation from CLAUDE.md's "orderings in constants.py" thatDataSource.AVAILABLEalready makes, for the same reason.other's loop walker crashes on a non-list config whereplot_assembly's returns it unchanged;other's spectrogram walker copies whereplot_assembly's aliases. Unifying takes the guarded version — a small behaviour change in the safe direction: a malformed per-fileloopcurrently skips the whole file, afterwards it is skipped as one bad plot.plot_types/entry.Acceptance criteria
plot_types/branches on plot type or hardcodes a derived section key.signal_containerimports noplot_types/*/plotmodule (asserted by test).constants.py,database_options_parser.py,database_options_xlsx.py,other/find_load_format.py,plot_assembly.pyorsignal_container.pychanges.loopgains the config validation the other two have.Tests
tests/plot_types/fake/), registered by a fixture, driven through all six paths: validates, warns on a bad key, scopes references inside another::<stem>block, reads an xlsx sheet, builds, renders. ~100 lines, milliseconds, no committed fixture —xlsx_bytes_to_database_options(bytes)already exists (database_options_xlsx.py:622), so the workbook is built to aBytesIOat test time. Shaped deliberately unlike the real three (a config section whose value is a bare string) so it exercisesmap_refson an unused shape.tests/datasource/test_load_config_independence.py: no module outsideplot_types/compares against a plot type string or hardcodes a section key, andsignal_containerimports noplot_types/*/plot. This replaces the ADR for decision 6, and guards the two Dash callback files — the least-tested layer, and the easiest place for a type branch to reappear.plot_types/registry.py: every member has both halves,section_keymatches its package name, capabilities declared. Inherits the deletedconstants.pyguard's job.Relations
Split from #81; the
::half of that section is deferred to #80. Follow-ups opened alongside this rewrite: collapsingother's per-file scoping intoplot_assembly, and a/new-plot-typeskill.