Skip to content

Architecture deepening: six structural refactors (ADR-0011 → 0014) - #90

Merged
AlexisJanin merged 9 commits into
mainfrom
code-quality-improvement
Aug 27, 2026
Merged

Architecture deepening: six structural refactors (ADR-0011 → 0014)#90
AlexisJanin merged 9 commits into
mainfrom
code-quality-improvement

Conversation

@AlexisJanin

Copy link
Copy Markdown
Collaborator

Eight commits from the architecture-deepening pass. Each one was designed before it was written, and each records its decision as an ADR rather than in this description — the ADRs are the durable artefact, this is a map to them.

66 files, +3477 / −2000. Seven new modules in src/, four new ADRs, two updated.

What is in it

Commit Change Recorded in
56197fc _load(file_path) -> DataFrame — the base template owns the parquet cache write, guarded on a non-empty frame. other opts out by never entering the template. ADR-0010 (update)
90a232e cst.NAIVE_BOUND_TZ split from cst.DISPLAY_TIMEZONE, so changing the app's display default cannot reinterpret a script's datetime bounds. ADR-0011 (new)
c50e1e1 Immutable AnnotationSet + Group (a group carries its annotations, so an empty one is unrepresentable), Annotation.create, Annotation.extra key passthrough. Group logic had zero coverage before. ADR-0012 (new)
e15f00c io/file_utils.py split into five modules — time_axis, parquet_pruning, discovery, column_patterns, export. Three dead functions deleted; no compat shim. ADR-0004 + ADR-0010 (updates)
7acc156 Latent KeyError when detection sampled a stored non-temporal index — pandas metadata restored a materialized index. Predates the split; found while testing it.
7079055 plot_assembly.py: config scope desugared once into qualified global refs, and group membership joins on signal identity rather than raw_name. ADR-0013 (new)
86099f6 TestMainGlobalGrouping's only assertion was behind a provably-empty guard and had never run. Replaced by three that assert.
31d5783 user_options.py: one pure implementation of the schema rules, called by every boundary that accepts a value. Load-time validation existed nowhere before. ADR-0014 (new)

Behaviour changes

All user-visible ones are in CHANGELOG.md under [Unreleased]. In short: same-named signals in different datasources stop suppressing each other; a group resolving to one signal keeps the group's name; per-datasource loop / spectrogram / psd resolve display names; trace_options applies to every datasource, not only other::<stem>; the annotation colour picker stops disagreeing with itself; and a hand-edited user_options.json is checked when it loads.

Reviewing

The ADRs carry the reasoning, including what was rejected and why. Commit messages carry the mechanics. Two things worth knowing while reading the diff:

  • Test assertions were deliberately left byte-identical where only placement moved (e15f00c especially) — a rewritten assertion stops a green suite from being evidence that a refactor preserved behaviour. Where assertions did change, the commit message says so and why.
  • Tests assert independent literals (== 300, not == cst.DEFAULT_SUBPLOT_HEIGHT), per the project convention: a test that restates the constant it exercises can never fail.

Opened primarily to get CI (ruff + pytest on 3.11 and 3.13) to report on the branch.

🤖 Generated with Claude Code

alexisj-inria and others added 8 commits August 25, 2026 16:39
`_load` now takes only the file path and returns a frame; DataSourceBase
writes the cache from it, at one site instead of in eight sources by
convention. This is the structural form of ADR-0010 — a source cannot
resolve an option inside `_load` because no option is in scope there.
Nine `**kwargs` + `noqa: ARG003` pairs go with it.

The base declines to cache an empty frame, and does not create the output
folder for one either. Four sources' early returns already skipped the
save for that reason; the rule is now uniform and stated once.

Removes the `configured_field_display` guard ADR-0010 flagged for removal:
its fresh-load branch could never fire under the rule. The parameter
survives on the quick-load branch, where it still prunes the cache read
serving inspect(configured_columns_only=True).

test_load_config_independence changes kind rather than level. Config
independence is no longer a behavioural property — both runs of a load now
take an identical path, and a module constant does not vary between them,
so comparing two runs would assert something that cannot fail. It reads
each `_load` through the AST instead and checks the rule's two channels:
the signature takes only the file path, and the body references neither
DATA_SOURCE_DEFAULT_TIMEZONE nor apply_timezone_to_dataframe.

Snapshots unchanged: every source saved exactly the frame it returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cst.DISPLAY_TIMEZONE was serving two unrelated concepts: the default of
the display_timezone user option, and the timezone a tz-naive
datetime_start/datetime_end is interpreted in on the load path. Reading
the second through the name of the first makes the load path look like
it wrongly ignores the user's setting -- a misreading an architecture
review made, and then made again while implementing the fix.

It does not ignore it. The UI qualifies its bounds at Submit, so the
Settings timezone governs the window there; both load-path sites
localize only when tzinfo is None, and are unreachable from the app.
The constant is a fallback for bounds that never met a user: scripts,
library calls, hand-edited files. Resolving a user option for those
would make extract_* output depend on ~/.clinical_scope/user_options.json.

Split out cst.NAIVE_BOUND_TZ as a separate literal, not an alias --
aliasing would let a change to the app's display default silently
reinterpret every script's naive bounds, which is the coupling the ADR
forbids. filter_data_by_timestamps' parameter follows the concept, and
resolve_display_timezone gained a fallback so the load path's
invalid-name branch cannot land on the display default.

Load-path tests monkeypatch the new name; the one site still patching
DISPLAY_TIMEZONE covers inspect()'s cosmetic date ranges, which is
genuinely the other concept. Behaviour is unchanged -- both constants
are "Europe/Paris".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
file_utils.py held seven unrelated concerns behind one name and had the
second-highest fan-in in the package. Its call graph is a stack, not a
partition: parquet pruning calls into datetime detection, sharing four
helpers. Split accordingly, one file per ADR:

  io/time_axis.py        ADR-0004's rule plus both its adapters
  io/parquet_pruning.py  ADR-0007: plan the prunings, then execute one
  io/discovery.py        find_files and the folder predicates
  io/column_patterns.py  one definition of what a `*` matches
  io/export.py           save_df + print_out_figure

Schema-only detection stays with full-frame detection: the two must pick
the same column, and that invariant is only cheap to hold in one file.

Provenance is now structural. `read_parquet_pruned(index_is_time_axis=)`
became two front doors -- read_parquet_pruned for a file of unknown
origin, read_cache_pruned for one we wrote -- so ADR-0010's guarantee is
carried by the name a caller reaches for instead of by a comment saying
"no other caller may claim that".

_build_datetime_row_filters now converts a bound into the column's tz
before dropping the label, rather than trusting _pushdown_bounds to have
converted already. Same behaviour, one less contract held at a distance;
the branch had no coverage, and now has a test that fails without it.

Also removed: load_csv_with_datetime_index (no callers),
load_parquet_with_datetime_index (both callers used the bare path, now
inlined), and timezone.py's dead _first_last_timestamp twin.

Hard cut, no compatibility shim. Test assertions are unchanged except
where a deleted wrapper had to become the code it contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detection samples candidate columns by name, but _sample_parquet_columns
let pyarrow restore pandas metadata -- so a materialized index column came
back as the frame's index and `sample[column_name]` raised KeyError. Any
plain parquet with a stored non-temporal index (a float or int index, not
one of our own caches) crashed instead of declining to prune. Predates the
io/file_utils split; found by the tests added here.

Reading with ignore_metadata=True keeps every requested column addressable
by name. Our own caches are unaffected: they declare provenance through
read_cache_pruned and never reach detection.

Tests cover the pruning decisions through the two readers rather than
through the plan they produce: the plan's shape is implementation, and
"a row predicate was built" is an optimization under ADR-0007, not
behaviour. Expectations come from a full read plus a plain pandas slice,
never from replaying the library's own resolution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Grouping and derived-plot construction were pure domain logic reachable
only by running the whole pipeline against a folder on disk. They now live
in plot_assembly.py, called once after the datasource loop, and are
exercised with in-memory Signals and literal config dicts.

Two rules replace the two-path shape (ADR-0013):

Config scope is desugared once. A per-datasource section is a namespace,
not a different kind of grouping, so its references are resolved against
that datasource's own signals and re-emitted as qualified global ones
before anything else runs. Downstream there is one resolver, one
suppression rule and one spelling of a reference. Both config spellings
stay valid: no parser change, no config migration.

Group membership joins on signal identity. A raw name is unique only
within a datasource, so the string-keyed accumulator and the post-hoc
prune each dropped a plot the first time two sources shared a name --
ordinary for HR, SpO2 and ABP, and silent when it happened. Derived
signals are new objects that were never in the input list, so they are
structurally immune to suppression and need no naming disguise.

Three deliberate behaviour changes, all in CHANGELOG: same-named signals
in different datasources stop suppressing each other; a group that
resolves to one signal keeps the group's name; and local loop /
spectrogram / grouped_fields resolve display names.

wrapper.py 931 -> 562 lines; main gains a docstring and loses the outer
try/except that wrapped the moved steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestMainGlobalGrouping guarded its only assertion behind
`global_fields & all_signal_names`, an intersection that is provably
always empty: an 'other' signal's raw_name is `<stem>::<column>`, so the
set holds `numerics::PNId`, never the bare `PNId` the guard compared
against. The assertion had never run. It is replaced by three that pin
what global grouping is for -- a group spanning three datasources, a
three-segment `other::<stem>::<column>` reference resolving, and a
grouped signal not also being plotted on its own.

The `pytest.skip("No time_series models produced")` guards go with it,
here and in its siblings. demo_patient/ is a fixed committed fixture, so
"maybe there is no data" is not a real condition -- it only meant the
tests would fall silent if the fixture ever regressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user_options schema rules -- MIN/MAX, CHOICES, valid IANA name --
were walked twice, in the settings modal on the way in and in
DisplayFallbacks.from_user_options on the way out, and on neither
occasion at load. A hand-edited ~/.clinical_scope/user_options.json
therefore reached the store raw: two copies of one rule set, and the
path that most needed them had none.

They now live once, in a new core module user_options.py, called by
every boundary that accepts a value. The module is pure -- no
Path.home(), no file reads -- which is what makes wrapper.main's
"the core never reads the on-disk file" structural rather than a
matter of nobody calling the function. Disk I/O stays in helper_api.

validate(raw) returns (clean, list[Correction]) rather than logging.
Detection is shared; the reaction belongs to the boundary that knows
the user's situation -- the modal discards silently because its widget
re-renders showing the corrected value, the loader logs because nobody
is watching a widget. That collapsed persist_user_options'
corrected_a_widget flag to bool(corrections): it existed only to
re-derive what per-field coercion had thrown away.

from_user_options becomes a projection that converts but does not
check. resolve_display_timezone stays, as the one tenant whose bad
value raises inside pandas/zoneinfo instead of merely rendering oddly,
covering a dict a library caller hand-built.

Unknown keys warn and drop, three lines in the loader. Deliberately
opposite to ADR-0012's Annotation.extra passthrough: the discriminator
is provenance. Annotations are human-authored and get shared, so an
unknown key is someone's data; user options are per-person state this
app writes, so an unknown key is a value stranded under a name the
schema no longer has.

Two behaviour changes, both in CHANGELOG: a bad value in a hand-edited
settings file is now corrected and reported at load, and the modal
refuses an inverted spectrogram dB pair on save rather than leaving
the render layer to fix it -- the cross-field rule moved into validate
with the rest. validate also always returns every schema field, so the
store can no longer be partial.

Validation tests move to tests/unit/test_user_options.py and assert on
returned Correction values, not caplog prose, which no longer breaks on
a reworded message. src is +48/-144 across three files, +150 in the new
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AlexisJanin AlexisJanin self-assigned this Aug 26, 2026
@AlexisJanin AlexisJanin added the Code quality Improve overall code quality (maintainability, robustness, readability) label Aug 26, 2026
… ref from docstring

test_user_options.py compared corrected display_timezone against cst.DISPLAY_TIMEZONE,
so the assertion could never fail independently of the constant it exercises. Also drops
an issue-number reference from a test_time_axis.py docstring, keeping the rationale in prose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@AlexisJanin
AlexisJanin merged commit c12c4df into main Aug 27, 2026
3 checks passed
@AlexisJanin
AlexisJanin deleted the code-quality-improvement branch August 27, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Code quality Improve overall code quality (maintainability, robustness, readability)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants