Skip to content

v0.14.0: results ownership boundary: slot-backed results, explicit persistence, typed fit output - #40

Open
InfinityMonkeyAtWork wants to merge 29 commits into
mainfrom
model-vs-fitresult
Open

v0.14.0: results ownership boundary: slot-backed results, explicit persistence, typed fit output#40
InfinityMonkeyAtWork wants to merge 29 commits into
mainfrom
model-vs-fitresult

Conversation

@InfinityMonkeyAtWork

Copy link
Copy Markdown
Owner

Summary

Completes the results-data ownership contract and plotting disentanglement
(execution record: docs/design/archive/results-ownership-and-plotting.md):

  • SavedFitSlot is the authoritative record of every completed fit,
    captured eagerly at fit time; FitResults is the single read/query/plot
    surface
    over slots — live sessions (Project.results) and loaded archives
    (FitResults.load) behave identically. File.get_* / File.plot_* are thin
    sugar.
  • Fits never write to disk. Fit methods compute, display (per
    show_output), and record slots; persistence is only ever the explicit
    save_fits (HDF5) / export_fits (CSV/PNG tree) pair, both slot-fed, with
    one default output root ./fit_results/<name>/.
  • Archive schema 3 (additive; v2 archives still load): correlation matrix,
    MCMC acceptance fraction, SbS per-slice stderr + parameter metadata, and
    fit_settings optimizer/seeding provenance on every slot.
  • Typed FitOutput (frozen dataclass: par_ini / par_fin / conf_ci /
    emcee_fin / emcee_ci) replaces the raw 5-element result list internally.

Breaking changes

  • Project.auto_export, Project.path_results, File.model_path removed;
    a project.yaml still setting removed keys fails loudly with migration hints.
    Everything auto-export wrote is reproducible: plot_mcmc (walker/corner from
    the persisted payload), plot_sbs_slices (per-slice panels, live sessions),
    save_baseline_fit / save_spectrum_fit (kept, explicit-only).
  • Project.name defaults to "my_project" (was "test").
  • File.get_correlations raises for covariance-less fits instead of returning
    an identity matrix.
  • Advanced API: Model.result is FitOutput | None (no list indexing);
    fitlib.plt_fit_res_1d(par_init=)par_ini;
    save_sbs_fit / save_2d_fit removed (use export_fit(s)).

Added

  • Explicit plot API with real energy/time axes on live and loaded archives:
    plot_fit, plot_param_evolution, plot_residuals, plot_mcmc,
    plot_sbs_slices. Inline fit-time display routes through the same API, so
    the figure shown at fit time is the one reproduced later.
  • Results accessors (get_fit_results / get_correlations /
    get_conf_intervals / get_mcmc) on FitResults, working on SbS fits and
    loaded archives.

…TODO

- llms.txt: fits are governed by Project.show_output / auto_export, not a
  save_img kwarg (passing save_img to a fit_* method raises TypeError);
  keep show_plot / save_img as the plot- and setup-helper convention.
- TODO.md: correct the road-to-v1 rationale to "contract surfaces (1-4)
  before the website (5)" after the stability-policy item was dropped, and
  note that llms.txt / AGENTS.md must be reconciled against the API tiers.
Under `-n auto --dist worksteal`, tests writing fit-completion CSV/PNG
side effects into the shared `tests_fits/` tree could race on colliding
`File.name`/`model_name` output paths. Default the shared helper to
auto_export=False so a test writes to disk only when export is its actual
subject; those tests opt in with auto_export=True and redirect
path_results to tmp_path. test_default_is_true now asserts the production
default on a bare Project. Full default (parallel) suite green; tests_fits/
no longer created.
The workers>1 MCMC pool started spawn workers directly, so a
`%run notebook.ipynb` session — where __main__.__file__ is the notebook
JSON — would crash every worker as spawn tries to runpy the JSON. The SbS
executor already guards this via sanitized_spawn_main; the MCMC pool did not.

Relocate sanitized_spawn_main from utils/sbs.py to a neutral utils/spawn.py
(fitlib importing sbs would be circular, and the helper is generic, not
SbS-specific) and wrap the MCMC pool with it, matching SbS.

Bump version to 0.13.1.
Populate PLAN.md with the six-phase design (decisions settled 2026-07-14:
full auto-export layout break, FitResults-homed plot API with File sugar,
slot-backed accessors). Tag both TODO items [ACTIVE] and record the
Simulator.sigma_data staleness sibling case in the mutation-guard item.
Add SavedFitSlot.correl (varying-parameter correlation matrix, captured
only when the optimizer produced covariance — None for covariance-less
fits and project joint fits, slice 0 for SbS) and acceptance_fraction in
the mcmc payload. Bump the archive schema to 3; the reader accepts 2 and
3 (older archives load the new fields as None), appends stay same-version
only. Extract the matrix builder into ulmfit.correl_to_df and delegate
File.get_correlations to it. Round-trip, v2-tolerance, and
unknown-version tests; fit_archive_schema.md updated.
FitResults gains get_fit_results / get_correlations / get_conf_intervals /
get_mcmc, reading the latest matching SavedFitSlot; File.get_* become
thin delegates (with a new optional model= filter) and File._result_model
is deleted. The accessors now serve sbs fits (slice-0 payloads) and
loaded archives, and hand out copies. Behavior changes: covariance-less
fits raise from get_correlations instead of returning an identity-with-
zeros matrix, and fits on Files without data record no slot so accessors
raise (data_base-only test fixtures now set file.data).
MCMCResult.acceptance_fraction is None for schema-2 archives.
results_to_df and results_to_fit_2d are now pure conversions: the
fit_pars.csv / fit_2d.csv writes, the per-parameter plotting, and the
save_df/save_2d flag logic move into their sole caller,
_save_sbs_fit_legacy (byte-identical output, pinned by the export-parity
tests). That legacy block is slated for removal when auto-export routes
through the slot exporter.
fit_slice_by_slice / File.fit_2d / Project.fit_2d now auto-export via
export_fit(path_results, overwrite=True) — the grouped slot layout —
and display inline from the captured SavedFitSlot (_display_fit_2d_maps
/ _display_sbs_fit), so the shown figure equals the exported one.
Project.fit_2d displays per-file maps instead of reading saved PNGs
back. Fit-time diagnostics keep writing under model_path(). Deletes
save_sbs_fit / save_2d_fit and the _save_*_fit_legacy impls; the
_append_*_slot helpers return the slot. Export-parity tests now pin
auto-export == explicit export; changelog and repo-architecture doc
updated for the breaking layout change.
Every slot gains fit_settings — stages, per-stage methods, try_ci, the
SbS seeding recipe, and MC sampling settings when MCMC ran (worker
counts deliberately excluded: serial == parallel dispatch is a tested
invariant). SbS slots additionally gain params_meta (slice-invariant
[name, vary, min, max, expr], captured from slice 0) and params_stderr
(per-slice standard errors, previously discarded; feeds the planned 1D
trace-fitting weights). _display_sbs_fit reads vary flags from the slot
now. All additive within the unreleased schema 3; v2 archives still
load with the new fields as None. Schema doc, changelog, round-trip and
capture tests updated.
FitResults now accepts fingerprint-matched axes providers (live Files
from Project.results, SavedFiles from FitResults.load) and gains
plot_fit (observed/fit/residual, all fit types) and plot_param_evolution
(SbS, varied-only default via params_meta), with File.plot_* sugar and
config resolution config= > live plot_config > defaults. plot_residuals
uses real axes now (index fallback kept). The fit methods' inline
display routes through this API — the _display_* helpers are gone, so
the figure shown at fit time is the one the API reproduces from a slot
or a loaded archive. fitlib is imported lazily to avoid a package-init
cycle.
Rewrite the fit_results.py section of repo_architecture.md around the
ownership contract; add docs/api/fit_results.rst (FitResults +
MCMCResult) and replace the deleted save_sbs_fit / save_2d_fit autodoc
entries with a results/plotting/persistence section, making the docs
build warning-free again. llms.txt gains plot_fit and a note that the
get_*/plot_* accessors read persisted records (live sessions and loaded
archives alike). TODO.md: both [ACTIVE] items are done — replaced by a
slim typed-result-object follow-up; the v1.0.0 legacy-shim item reflects
the removed savers. Version 0.13.1 -> 0.14.0 (breaking auto-export
layout + archive schema 3).
Move the durable decisions (ownership contract, auto-export layout
break, slot-backed accessor semantics, schema-3 additions, and the
rejected alternatives) into
docs/design/archive/results-ownership-and-plotting.md; reset PLAN.md to
its empty template per the completion protocol.
np.asarray is a no-copy passthrough for ndarray input, so the returned
acceptance_fraction aliased the slot's stored array, violating the
accessors-return-copies contract (review finding). Regression test pins
copy semantics for acceptance_fraction, flatchain, and the ci table.
Populate PLAN.md with Phases 7-8, continuing the results-ownership
branch (decisions settled 2026-07-17 in review discussion): remove
Project.auto_export entirely — fits compute, display, and capture slots
but never write; persistence stays the explicit save_fits/export_fits
pair fed from the slot history. Everything auto-export wrote maps to
persisted slot data or new on-demand plot_mcmc / plot_sbs_slices
methods. Unify on the ./fit_results/<name>/ output root (path_results
and model_path removed as dead), default Project.name becomes
my_project, and plot save_path=None means display-only. Phase 8
replaces the internal raw result 5-list with a typed class. TODO: tag
the unified-results item [ACTIVE]; add a new item for persisting
project-level joint fits as a first-class record (shared-parameter map,
joint covariance, sibling identity), noting the joint covariance
currently evaporates.
- delete Project.auto_export / Project.path_results / File.model_path;
  project.yaml files still setting the removed keys fail loudly
- fit methods and fitlib.fit_wrapper drop all disk IO (save_output /
  save_path / num_fmt / delim, the CSV/TXT dump block, SbS per-slice
  writes, post-fit export calls); show_output display gating unchanged
- add FitResults.plot_mcmc (walker acceptance + corner from the
  persisted payload, live sessions and archives) and
  File.plot_sbs_slices (per-slice panels from live results_sbs)
- Project.name default "test" -> "my_project"
- fix explicit dict SbS seeds crashing slot capture: fit_settings now
  records the normalized seed template
- reshape test_auto_export.py into test_fit_side_effects.py (fits write
  nothing, verified via cwd isolation); export parity re-anchored on
  two explicit exports; docs, examples, and changelog updated
Component decomposition for 1D fits (baseline/spectrum/SbS) was
live-only — describe_model(detail=1) and plot_sbs_slices could show it,
but FitResults.plot_fit never could, live or archived. No researcher
trusts a 1D fit whose components they can't see, so persist them
directly instead of accepting that gap permanently.

- add components/component_names to SavedFitSlot; bump schema 3 -> 4,
  extend SUPPORTED_READ_VERSIONS; write/read follow the existing
  additive-optional-field pattern (correl/mcmc). 2d untouched — no
  per-component concept there.
- baseline/spectrum/sbs slot construction evaluates components on the
  full grid then crops to e_lim, mirroring the fit curve's existing
  evaluate-then-crop correctness (grid-dependent components don't
  evaluate the same on a pre-cropped grid). component_names comes
  directly from model.components, not parsed from params - a static
  par_profile attachment splices a nested model's params into its host
  component's name block without adding a components entry, which
  would break prefix-based re-derivation.
- FitResults._plot_fit_1d renders the decomposition when present, falls
  back to the lean sum-only view for pre-schema-4 archives.
- extend the round-trip assertion (F1/F6/F8 families, including the F6
  static-profile edge case) with a components round-trip check and a
  sum-of-components-equals-fit invariant; add schema-3 backward-compat
  coverage and two focused _plot_fit_1d rendering tests.
- update docs/design/fit_archive_schema.md (the stated wire-format
  contract) with the schema-4 version history entry, the components /
  component_names dataset section, and the reader-mapping / cheat-sheet
  table updates; verified with a sphinx -W build.
The fit archive already stores the full, uncropped data/energy/time
once per file (SavedFile), independent of any slot's cropped
observed/fit. aux_axis (File.aux_axis, the auxiliary physical axis
used by par_profile-attached models, e.g. depth) was the one array in
that same family never persisted - reloading an archive with no live
File loses it entirely. Add it now, before the archive's full-data
story is otherwise considered complete.

- add optional aux_axis field to SavedFile; bump schema 4 -> 5, extend
  SUPPORTED_READ_VERSIONS; write/read follow the conditional-write /
  .get()-fallback pattern already used for correl/mcmc/components (not
  data/energy/time's unconditional write - most files have no aux
  axis at all).
- thread live.aux_axis through the SavedFile construction used by
  save_fits.
- extend the F1/F6/F8 baseline round-trip test with an aux_axis check
  (present + array-equal for F6/F8, None for F1, via the loaded
  FitResults axes-provider); add a schema-4 downgrade helper and a
  compat test confirming pre-5 archives still load with
  aux_axis=None.
- update docs/design/fit_archive_schema.md (the stated wire-format
  contract) with the schema-5 version history entry and the aux_axis
  file-group layout entry; verified with a sphinx -W build.
describe_model showed the full data range with fit-limit marker lines;
post-fit displays (plot_fit) showed only the fit-limits-cropped window.
The archive already persists the full, uncropped data/energy/time once
per file (SavedFile) independent of any slot's cropped observed/fit, so
give plot_fit a full_range mode instead of forcing one view down to
match the other: real full data, with fit/residual/components drawn
only inside the fit window (NaN outside it, never a fabricated padding
value) plus dashed boundary lines - the same visual language
describe_model already uses.

- FitResults._axes_for/_full_observed_for/_pad_axis reconstruct the real
  full-range view per fit type: baseline (mean over persisted
  base_t_ind), spectrum (resolves the raw time_point/time_range/
  time_type selection against the persisted time axis), sbs/2d
  (provider.data directly - already full). Falls back to today's cropped
  view when reconstruction isn't possible; never raises.
- Extracted File._resolve_time_selection into a standalone
  utils.arrays.resolve_time_selection so spectrum's reconstruction can
  resolve indices with no live File.
- fitlib.plt_fit_res_2d's title/scale calc now uses nanmin/nanmax for
  the NaN-padded fit array; identical result when no NaN is present.
- fit_baseline/fit_spectrum now route their post-fit display through
  self.plot_fit(...) instead of a bespoke plt_fit_res_1d call, dropping
  the show_init overlay (not a completed-fit artifact, can't be
  reconstructed from the archive) along with the dead initial_guess
  extraction and bespoke title construction.
- full_range is a real PlotConfig member (Project.full_range-backed,
  True out of the box), not a bool hardcoded at each call site -
  data_slice/x_lim/y_lim already establish "what to show" as a
  legitimate PlotConfig concern, not just style. plot_fit's full_range
  parameter is bool | None = None ("use config"), resolved once via
  cfg.full_range. All 6 live post-fit display call sites
  (fit_baseline/fit_spectrum/fit_slice_by_slice/File.fit_2d/
  Project.fit_baselines/Project.fit_2d) call plot_fit with no full_range
  kwarg at all, so a project.yaml override or a per-call override
  actually takes effect everywhere instead of being silently ignored
  (project.yaml keys are only applied if a matching Project attribute
  already exists) or shadowed by a hardcoded True.
- Add TestFullRangeConfigResolution: config default is True, a live
  fit's post-fit display honors config.full_range in both directions,
  and a per-call override wins over config - verified each failure mode
  actually fails without its fix via temporary-revert round-trips.
- No schema/wire-format change - reuses already-persisted fields.
While discussing whether the initial parameter guess should count as
fit-result provenance (a different seed can land the same algorithm on
a different local minimum), found that SavedFitSlot.params' init_value
column (baseline/spectrum/2d) was silently wrong for stages=2 fits.

lmfit's Minimizer.prepare_fit() unconditionally resets
Parameter.init_value = Parameter.value at the start of every stage.
fit_wrapper's stage 2 starts from stage 1's *output* Parameters object,
so a two-stage result's init_value ends up reflecting stage 1's output,
not the true original seed - verified directly against the installed
lmfit source. stages=1 fits were already correct.

- Add utils.lmfit.restore_true_init_values(result_params, par_ini).
  Fixed at the source, in fitlib.fit_wrapper itself, right after stage
  2's mini.minimize() and before the result is printed or returned:
  _result_params() returns result.params by reference (no copy), so
  this is the same object that becomes FitOutput.par_fin.params, which
  means the fix reaches lmfit.report_fit's own printed "(init = ...)"
  annotation and any direct consumer of FitOutput.par_fin - not just
  code that happens to pass through slot construction. An earlier pass
  applied the correction downstream in _append_baseline_slot/
  _append_spectrum_slot/_append_2d_slot instead; moved here after review
  because the live printed report would otherwise still show stage 1's
  output, contradicting the archive.
- Regression tests confirm a stages=2 fit's persisted init_value matches
  the true seed rather than stage 1's output, and - more directly -
  that both FitOutput.par_fin.params[name].init_value and
  report_fit's printed "(init = ...)" (captured via capsys, matching
  lmfit's own .7g format) show the true seed for the local-optimization
  stage. Verified each fails without its fix via temporary-revert
  round-trips.
- docs/design/fit_archive_schema.md: clarified init_value's semantics
  and pointed at fitlib.fit_wrapper as the fix location. No schema/
  wire-format change - same column, same shape, corrected values, no
  version bump.
- Out of scope (separately noted in PLAN.md): SbS initial-guess
  persistence and the seed-as-provenance schema extension for
  baseline/spectrum/2d.
The initial guess is fit-result provenance, not incidental — a
different seed can land the same optimizer on a different local
minimum. SavedFitSlot now persists fit_ini (model evaluated at the
true pre-fit seed, all 4 fit types) and, for SbS, per-slice
params_init (every slice, not just slice 0 — the seed was already
available at no extra cost). FitResults.plot_fit renders the
archive-side "initial guess" overlay via new PlotConfig.show_init
(Project-backed, default True, resolved the same way as full_range).
The components/aux_axis/full_range/init_value-fix/fit_ini work
(commits 23c9383..a461404) had no CHANGELOG entries yet. All three
PLAN.md sections are complete and verified; per the archival rule,
the changelog is documentation enough here, so PLAN.md is cleared.
The energy-resolved (1D) branch already said "...: initial guess" in
its title; the Dynamics and 2D branches didn't, so a live model
preview could be mistaken for a saved fit result. Hoist the title
string once and thread it through all three; plt_fit_res_2d gains an
optional figure-level title= (suptitle), additive and unused by
existing callers.
model_info is None on the (most common) default active-model path and
an int on index-based selection, so the hoisted title_mod literally
rendered `Model: "None"` or `Model: "0"`. mod is already guaranteed
non-None at that point; mod.name is the real resolved name regardless
of how the caller identified the model.
- schema-5 bullet wrongly described aux_axis as "the full energy or
  time array" and claimed it enables full_range; aux_axis is a
  separate auxiliary physical axis (e.g. depth) for par_profile
  models, and full_range doesn't reference it at all -- the full
  data/energy/time were already unconditionally persisted before this
  schema bump (confirmed against commit 5c97fb6 and a grep for
  aux_axis in fit_results.py).
- the "fits never write to disk" bullet's accepted-losses note
  predates schema 6 and called per-slice par_ini "re-derivable from
  fit_settings"; it's now directly persisted (params_init/fit_ini),
  just not exported to the legacy CSV tree (confirmed: export_fits
  writes no such CSV today).
… does

_plot_fit_1d has used _slot_title (file, model, fit type, yaml stem)
since the explicit plotting API landed; the 2D/SbS branch returns
early into plt_fit_res_2d with no title at all, so multi-file loops
(e.g. Project.fit_2d()) show visually identical, unlabeled panels.
plt_fit_res_2d already gained an optional title= kwarg for the
describe_model fix; reuse it here with the same _slot_title used by
the 1D path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant