From 074de8e7e599d31ddc493125b552c601a062aa50 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sat, 25 Jul 2026 13:08:49 +0800 Subject: [PATCH] feat(core): version fields and cache derived contour geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contour geometry was still built on the thread that asked for a figure, so a colour or line-width edit re-ran marching squares over the whole grid, and the level policy, the field statistics it depended on, and the geometry itself were resolved together with no way to reuse any of them. Split derived geometry into three stages. A field provider owns the grid and its cheap summary; the main thread resolves a `ContourSpec` into absolute levels; a worker turns a neutral `ScalarGrid2D` plus those levels into segments and receives no spec, summary or estimate. Style is applied when the figure is assembled, so it never enters a cache key and a restyle reuses cached geometry. `FieldVersion` is a session-only monotonic token owned by `ComputeService`. It covers everything that changes marching-squares input — values, shape, bounds, sampling — and nothing that does not, and it is neither persisted nor part of undo. Estimates and geometry are addressed by content, so a stale worker result cannot overwrite newer state and no traversal-based invalidation is needed. Both caches are bounded and never evict a key whose work is still in flight. Noise and background estimates are computed on demand by `EstimateField` and memoized per estimator selection, so a field pays only for the estimator it actually uses. A zero scale is a valid, cacheable measurement rather than a failure, which stops a flat field from re-queueing an identically failing job on every rebuild; an estimator that genuinely cannot run stays an error that reaches the status line. Field capabilities now derive from a cheap representation query — shape and per-axis sampling — so a descriptor lookup no longer materializes the whole grid, and `regular` still comes from the actual sampling rather than a domain guess. An explicit contour threshold above the field's peak is obeyed literally and reported with both the threshold and the peak, rather than silently drawing nothing or being rewritten to a value that happens to produce output. --- crates/analysis/src/robust.rs | 9 + crates/cli/src/main.rs | 7 +- crates/core/src/actions/app_impl/mod.rs | 10 +- crates/core/src/actions/tests/more.rs | 4 +- crates/core/src/actions/tests/stack.rs | 33 +- crates/core/src/contour_ladder.rs | 85 ++ crates/core/src/contour_probe.rs | 69 ++ crates/core/src/figures.rs | 370 +++++---- crates/core/src/lib.rs | 3 + crates/core/src/project/mod.rs | 9 + crates/core/src/state/afm.rs | 44 +- crates/core/src/state/app_impl.rs | 22 +- crates/core/src/state/app_impl_compute.rs | 163 +++- .../core/src/state/app_impl_compute_tests.rs | 60 ++ crates/core/src/state/app_impl_figures.rs | 145 +++- crates/core/src/state/app_impl_slice.rs | 4 +- crates/core/src/state/compute.rs | 358 ++++----- crates/core/src/state/compute/tests.rs | 121 ++- crates/core/src/state/compute_field.rs | 309 +++++++ crates/core/src/state/compute_worker.rs | 173 ++++ crates/core/src/state/datasets.rs | 14 +- crates/core/src/state/datasets_2d_figure.rs | 138 ++-- crates/core/src/state/electrophysiology.rs | 4 +- crates/core/src/state/field.rs | 223 ++---- crates/core/src/state/field_cache.rs | 267 +++++++ crates/core/src/state/field_cache_tests.rs | 158 ++++ crates/core/src/state/field_catalog.rs | 79 +- crates/core/src/state/field_payload.rs | 362 +++++++++ crates/core/src/state/field_runtime.rs | 647 +++++++++++++++ .../state/field_runtime_degenerate_tests.rs | 135 ++++ crates/core/src/state/field_runtime_tests.rs | 751 ++++++++++++++++++ .../state/field_runtime_threshold_tests.rs | 184 +++++ crates/core/src/state/field_tests.rs | 226 +++++- crates/core/src/state/mod.rs | 11 +- crates/core/src/state/stack.rs | 6 +- crates/core/src/state/table.rs | 4 +- crates/core/src/workflow.rs | 33 +- crates/core/tests/afm_canvas.rs | 31 +- crates/core/tests/slice2d.rs | 41 +- crates/figure/src/encoding.rs | 2 +- 40 files changed, 4553 insertions(+), 761 deletions(-) create mode 100644 crates/core/src/contour_ladder.rs create mode 100644 crates/core/src/contour_probe.rs create mode 100644 crates/core/src/state/compute_field.rs create mode 100644 crates/core/src/state/compute_worker.rs create mode 100644 crates/core/src/state/field_cache.rs create mode 100644 crates/core/src/state/field_cache_tests.rs create mode 100644 crates/core/src/state/field_payload.rs create mode 100644 crates/core/src/state/field_runtime.rs create mode 100644 crates/core/src/state/field_runtime_degenerate_tests.rs create mode 100644 crates/core/src/state/field_runtime_tests.rs create mode 100644 crates/core/src/state/field_runtime_threshold_tests.rs diff --git a/crates/analysis/src/robust.rs b/crates/analysis/src/robust.rs index cb6ccee..5fb7079 100644 --- a/crates/analysis/src/robust.rs +++ b/crates/analysis/src/robust.rs @@ -1,5 +1,14 @@ //! Robust scale estimators shared by scientific field providers. +/// Stable identity and implementation version for the 2D NMR noise estimator. +pub const ROBUST_DIFFERENCE_MAD_ID: &str = "robust_difference_mad"; +pub const ROBUST_DIFFERENCE_MAD_VERSION: u32 = 1; + +/// Stable identity and implementation version for the de-planed background +/// estimator used by scalar height-like fields. +pub const DEPLANED_LOCATION_SCALE_ID: &str = "deplaned_location_scale"; +pub const DEPLANED_LOCATION_SCALE_VERSION: u32 = 1; + /// Estimate white-noise scale from horizontal and vertical first differences. /// Differences never cross a row boundary; combining both axes avoids giving a /// preferred direction to a regular 2D spectrum. diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6481e80..706163a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -449,9 +449,10 @@ fn fail_automation(error: AutomationError) -> Status { fn fail(error: WorkflowError) -> Status { let status = match &error { WorkflowError::Load(_) => Status::Input, - WorkflowError::Scheme(_) | WorkflowError::Processing(_) | WorkflowError::Integration(_) => { - Status::Scheme - } + WorkflowError::Scheme(_) + | WorkflowError::Processing(_) + | WorkflowError::Integration(_) + | WorkflowError::FieldRuntime(_) => Status::Scheme, WorkflowError::FigureUnavailable(_) => Status::Canvas, WorkflowError::Export(_) => Status::Export, }; diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 99d78c8..5ed7cb4 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -1,12 +1,10 @@ use super::*; - mod axis_overrides; mod meta_edits; mod processing; mod revert; mod table_edit; mod validate; - pub use validate::ActionApplyError; use validate::{ValidationShape, validate_action}; @@ -18,9 +16,7 @@ impl PlotxApp { } } - /// Validate and atomically commit one action transaction. Validation walks - /// composites before the first child is applied, so a stale later child can - /// never leave a partially modified document. + /// Validate a whole action before applying it, preventing partial stale composites. pub fn try_execute_action(&mut self, action: Action) -> Result<(), ActionApplyError> { if action.is_noop() { return Ok(()); @@ -400,6 +396,9 @@ impl PlotxApp { if *dataset_index != self.doc.datasets.len() { return; } + if !self.register_loaded_dataset_fields(dataset.as_ref()) { + return; + } self.doc.datasets.push(dataset.as_ref().clone()); if let Some(ci) = inserted_into_existing_canvas { let Some(canvas) = self.doc.canvases.get(*ci) else { @@ -438,6 +437,7 @@ impl PlotxApp { } } self.doc.canvases.push(canvas); + self.rebuild_canvases_for(*dataset_index); self.session.active_canvas = Some(*canvas_index); } self.focus_single(*dataset_index); diff --git a/crates/core/src/actions/tests/more.rs b/crates/core/src/actions/tests/more.rs index 04941b2..706a52e 100644 --- a/crates/core/src/actions/tests/more.rs +++ b/crates/core/src/actions/tests/more.rs @@ -41,7 +41,7 @@ fn stacked_binding_builds_distinctly_coloured_series_with_legend() { #[test] fn single_table_color_override_recolors_points_and_error_bars() { use crate::state::{ChartSpec, DataBinding, DataDomain, SeriesBinding, StackSpec}; - let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); + let (mut app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44); let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(); series.set_primary_color(color); @@ -65,7 +65,7 @@ fn single_table_color_override_recolors_points_and_error_bars() { #[test] fn single_table_color_override_recolors_bar_polygons() { use crate::state::{ChartSpec, DataBinding, SeriesBinding, StackSpec}; - let (app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); + let (mut app, _) = table_app_with_sigma(vec![0.1, 0.1, 0.1]); let color = plotx_figure::Color::rgb(0xaa, 0x22, 0x44); let mut series = SeriesBinding::from_dataset(&app.doc.datasets[0]).unwrap(); series.set_primary_color(color); diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index c007f6c..dca9fe7 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -14,7 +14,10 @@ fn stacked_figure_is_domain_generic_with_offset_scale_and_hide() { let second = second_table_with_sigma(vec![0.2, 0.2, 0.2]); table.doc.datasets.push(Dataset::Table(Box::new(second))); - for (app, domain) in [(&nmr, DataDomain::Nmr1d), (&table, DataDomain::Table)] { + for (app, domain) in [ + (&mut nmr, DataDomain::Nmr1d), + (&mut table, DataDomain::Table), + ] { let chart = ChartSpec::default_for(domain); let binding = DataBinding { series: vec![ @@ -72,7 +75,13 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { signed_grid.domain = plotx_io::Domain::Frequency; for (index, value) in signed_grid.data.iter_mut().enumerate() { let column = index % signed_grid.cols; - *value = num_complex::Complex64::new(column as f64 - 15.5, 0.0); + let row = index / signed_grid.cols; + // A non-zero robust noise scale keeps this signed test field on the + // concrete NoiseSigma path rather than exercising a degenerate plane. + *value = num_complex::Complex64::new( + column as f64 - 15.5 + ((row * 17 + column * 13) % 11) as f64 * 0.037, + 0.0, + ); } app.doc .datasets @@ -96,6 +105,26 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { mode: StackMode::ColorOverlay, ..StackSpec::default() }; + let initial = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]); + assert!( + initial.contours.is_empty(), + "contours are resolved asynchronously" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); + // The first completion supplies the estimate. Resolving the binding again + // then queues its geometry, which is a separate worker stage. + let geometry_pending = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]); + assert!(geometry_pending.contours.is_empty()); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); let fig = app.build_binding_figure(&binding, &chart, &stack, [120.0, 80.0]); assert!(fig.show_legend, "a Field overlay shows a legend"); diff --git a/crates/core/src/contour_ladder.rs b/crates/core/src/contour_ladder.rs new file mode 100644 index 0000000..a5d7b92 --- /dev/null +++ b/crates/core/src/contour_ladder.rs @@ -0,0 +1,85 @@ +//! The one contour level-ladder policy, shared by every contour path. +//! +//! `plotx_render::contour::geometric_levels` is a pure ladder generator: given a +//! usable base it emits levels and truncates at the peak. Deciding what to do +//! when the base is *not* usable is policy rather than rendering, so it lives +//! here instead of in `plotx-render` — and in exactly one place, so the legacy +//! ILT/DOSY analysis-map path and the versioned field resolver cannot drift. + +use plotx_figure::{ContourBasePolicy, ContourLevelSpec}; + +/// One contour half's ladder, together with the reason it may be empty. +pub(crate) struct ContourLadder { + pub levels: Vec, + /// The user's own [`ContourBasePolicy::Absolute`] threshold, recorded only + /// when this half's peak never reaches it so nothing could be drawn. Callers + /// that can talk to the user must say so; a blank half with no explanation + /// is the failure this carries the numbers to prevent. + pub threshold_above_peak: Option, +} + +/// Resolve one contour half's level ladder. +/// +/// `base` and `peak` are unsigned magnitudes belonging to a single half: a base +/// policy never produces a signed absolute value, and the caller applies its +/// half's sign to the returned magnitudes. +/// +/// An unusable base is handled according to *where the base came from*, because +/// the two cases mean opposite things: +/// +/// - A base a policy *derived* — most often a zero scale estimate on a flat or +/// ideal synthetic grid — is not something the user typed, and would otherwise +/// leave a permanently blank plot with no indication of why. It falls back to +/// a base derived from the spec the user actually selected, never to a hidden +/// peak fraction, so `count` and `ratio` still control the output. A fallback +/// that is itself unusable (a non-finite peak, or a ratio ladder that +/// overflows) draws nothing. +/// - [`ContourBasePolicy::Absolute`] *is* the user's explicit input, the +/// strongest term of the value-resolution order. Rewriting it would silently +/// draw a ladder at levels the user never asked for, so it is obeyed +/// literally: a threshold the field never reaches yields no levels, and +/// `threshold_above_peak` carries the numbers needed to explain that. +pub(crate) fn contour_level_ladder( + base: f64, + peak: f64, + level: &ContourLevelSpec, +) -> ContourLadder { + let usable = |value: f64| value.is_finite() && value > 0.0 && value < peak; + let base = if usable(base) { + base + } else if matches!(level.base, ContourBasePolicy::Absolute(_)) { + // `Absolute` wraps a `PositiveFiniteF64`, and both callers reject a + // non-positive peak before reaching here, so the only way an explicit + // threshold is unusable is that it sits at or above the peak. The + // comparison is still written out rather than assumed, so a future + // caller with a non-finite peak reports nothing instead of a bad number. + return ContourLadder { + levels: Vec::new(), + threshold_above_peak: (base >= peak).then_some(base), + }; + } else if level.count == 1 { + // One level always means one visible, interior contour: a lone level at + // or beyond the peak has no crossing at all. + peak / 2.0 + } else { + peak / level + .ratio + .get() + .powi(i32::from(level.count.saturating_sub(1))) + }; + if !usable(base) { + return ContourLadder { + levels: Vec::new(), + threshold_above_peak: None, + }; + } + ContourLadder { + levels: plotx_render::contour::geometric_levels( + base, + peak, + usize::from(level.count), + level.ratio.get(), + ), + threshold_above_peak: None, + } +} diff --git a/crates/core/src/contour_probe.rs b/crates/core/src/contour_probe.rs new file mode 100644 index 0000000..3517a34 --- /dev/null +++ b/crates/core/src/contour_probe.rs @@ -0,0 +1,69 @@ +//! Test-only probes proving where derived contour work happens — which thread +//! runs marching squares, and whether resolving a warm cache still materializes +//! a full field payload. +//! +//! The counters are deliberately `thread_local`, and that is the whole point: +//! a `BuildContour` worker increments `MARCHING_SQUARES` on *its* thread, which +//! the test thread cannot see. "Zero on the calling thread" therefore means +//! exactly "the caller did not run marching squares itself". Promoting these to +//! an `AtomicUsize` (or any process-global counter) would make the assertions +//! count worker work as caller work and quietly destroy the property they +//! check, so keep them thread-local. +//! +//! Every marching-squares call site inside `plotx-core` must record here. +//! `ilt_figure_runs_marching_squares_on_the_calling_thread` is the positive +//! control: it fails if the instrumentation is ever dropped, so a "0 builds" +//! assertion can never pass merely because nothing counts. + +use std::cell::Cell; + +thread_local! { + static MARCHING_SQUARES: Cell = const { Cell::new(0) }; + static QUEUED_CONTOUR_BUILDS: Cell = const { Cell::new(0) }; + static QUEUED_ESTIMATES: Cell = const { Cell::new(0) }; + static FIELD_PAYLOADS: Cell = const { Cell::new(0) }; +} + +pub(crate) fn reset() { + MARCHING_SQUARES.with(|count| count.set(0)); + QUEUED_CONTOUR_BUILDS.with(|count| count.set(0)); + QUEUED_ESTIMATES.with(|count| count.set(0)); + FIELD_PAYLOADS.with(|count| count.set(0)); +} + +/// Record one marching-squares invocation on the current thread. +pub(crate) fn record_marching_squares() { + MARCHING_SQUARES.with(|count| count.set(count.get().saturating_add(1))); +} + +/// Marching-squares invocations made *on this thread* since [`reset`]. +pub(crate) fn marching_squares_on_this_thread() -> usize { + MARCHING_SQUARES.with(Cell::get) +} + +pub(crate) fn record_queued_contour_build() { + QUEUED_CONTOUR_BUILDS.with(|count| count.set(count.get().saturating_add(1))); +} + +pub(crate) fn queued_contour_builds() -> usize { + QUEUED_CONTOUR_BUILDS.with(Cell::get) +} + +/// Record one `EstimateField` job actually handed to the workers. A result that +/// is cached — including a degenerate one — must never make this grow again. +pub(crate) fn record_queued_estimate() { + QUEUED_ESTIMATES.with(|count| count.set(count.get().saturating_add(1))); +} + +pub(crate) fn queued_estimates() -> usize { + QUEUED_ESTIMATES.with(Cell::get) +} + +/// Record one `Dataset::field_payload` call, i.e. one O(rows × cols) buffer. +pub(crate) fn record_field_payload() { + FIELD_PAYLOADS.with(|count| count.set(count.get().saturating_add(1))); +} + +pub(crate) fn field_payload_materializations() -> usize { + FIELD_PAYLOADS.with(Cell::get) +} diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 80f123f..6fda64c 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -39,23 +39,10 @@ pub fn apply_peak_labels(mut fig: Figure, peaks: &[ResolvedPeak]) -> Figure { fig } -/// Build a contour figure from a processed true-2D spectrum. F2 (direct) is the -/// x-axis with high ppm on the left; F1 (indirect) is the y-axis with high ppm -/// at the bottom (low ppm at the top) — the standard 2D NMR orientation. -pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D, contour: &ContourSpec) -> Figure { - build_figure_2d_cancellable(spec, preset, contour, &|| false) - .expect("non-cancelling contour figure") -} - -pub fn build_figure_2d_cancellable( - spec: &Spectrum2D, - preset: Preset2D, - contour: &ContourSpec, - cancelled: &impl Fn() -> bool, -) -> Option
{ - if cancelled() { - return None; - } +/// Build the non-geometric shell of a processed true-2D figure. Contour +/// geometry is supplied later by the versioned field cache; this convenience +/// builder intentionally never runs marching squares on the caller's thread. +pub fn build_figure_2d(spec: &Spectrum2D, preset: Preset2D) -> Figure { let (f2_lo, f2_hi) = spec.f2_bounds(); let (f1_lo, f1_hi) = spec.f1_bounds(); let x = Axis::new(axis_label(&spec.direct.nucleus), f2_lo, f2_hi).reversed(true); @@ -65,114 +52,12 @@ pub fn build_figure_2d_cancellable( .with_axis_frame(AxisFrame::Box); // Homonuclear spectra share a nucleus/range on both axes; render them square. fig.lock_aspect = preset.homonuclear(); - - if spec.f2_size >= 2 && spec.f1_size >= 2 { - let z = spec.real(); - // Grid columns run low→high ppm (index 0 = f2_ppm[0]); rows likewise. - let bounds = [ - spec.f2_ppm[0], - spec.f2_ppm[spec.f2_size - 1], - spec.f1_ppm[0], - spec.f1_ppm[spec.f1_size - 1], - ]; - let positive = contour_levels(&z, spec.f1_size, spec.f2_size, &contour.positive, false); - let segments = if positive.is_empty() { - Vec::new() - } else { - contour_segments(&z, spec.f1_size, spec.f2_size, bounds, &positive, cancelled)? - }; - if !segments.is_empty() { - fig = fig.with_contour(Contour { - segments, - color: contour.style.positive_color.resolve(), - width: contour.style.width.get(), - }); - } - if let Some(negative) = contour.negative.as_ref() { - let levels = contour_levels(&z, spec.f1_size, spec.f2_size, negative, true); - let segments = if levels.is_empty() { - Vec::new() - } else { - contour_segments(&z, spec.f1_size, spec.f2_size, bounds, &levels, cancelled)? - }; - if !segments.is_empty() { - fig = fig.with_contour(Contour { - segments, - color: contour.style.negative_color.resolve(), - width: contour.style.width.get(), - }); - } - } - } - Some(fig) -} - -fn contour_segments( - values: &[f32], - rows: usize, - cols: usize, - bounds: [f64; 4], - levels: &[f64], - cancelled: &impl Fn() -> bool, -) -> Option> { - plotx_render::contour::segments_cancellable( - values, rows, cols, bounds[0], bounds[1], bounds[2], bounds[3], levels, cancelled, - ) -} - -pub(crate) fn scalar_contour_overlays( - values: &[f32], - rows: usize, - cols: usize, - bounds: [f64; 4], - contour: &ContourSpec, -) -> Vec { - scalar_contour_overlays_cancellable(values, rows, cols, bounds, contour, &|| false) - .unwrap_or_default() -} - -pub(crate) fn scalar_contour_overlays_cancellable( - values: &[f32], - rows: usize, - cols: usize, - bounds: [f64; 4], - contour: &ContourSpec, - cancelled: &impl Fn() -> bool, -) -> Option> { - if cancelled() { - return None; - } - let mut overlays = Vec::new(); - let positive = contour_levels(values, rows, cols, &contour.positive, false); - if !positive.is_empty() { - let segments = contour_segments(values, rows, cols, bounds, &positive, cancelled)?; - if !segments.is_empty() { - overlays.push(Contour { - segments, - color: contour.style.positive_color.resolve(), - width: contour.style.width.get(), - }); - } - } - if let Some(negative) = contour.negative.as_ref() { - let levels = contour_levels(values, rows, cols, negative, true); - if !levels.is_empty() { - let segments = contour_segments(values, rows, cols, bounds, &levels, cancelled)?; - if !segments.is_empty() { - overlays.push(Contour { - segments, - color: contour.style.negative_color.resolve(), - width: contour.style.width.get(), - }); - } - } - } - Some(overlays) + fig } -/// Resolve a level policy directly for the current model-layer renderer. The -/// phase-4 cache/job split will memoize estimates; this pure calculation keeps -/// policy state out of marching squares in the meantime. +/// Resolve levels for the existing DOSY/ILT analysis-map workers. Ordinary +/// `FieldPayload::ScalarGrid2D` contours use the versioned field resolver and +/// never reach this legacy analysis-only helper on the UI thread. fn contour_levels( values: &[f32], rows: usize, @@ -191,7 +76,7 @@ fn contour_levels( if peak <= 0.0 { return Vec::new(); } - let mut base = match &level.base { + let base = match &level.base { ContourBasePolicy::Absolute(value) => value.get(), ContourBasePolicy::NoiseSigma { multiplier, .. } => { robust_difference_mad(values, rows, cols) * multiplier.get() @@ -204,37 +89,12 @@ fn contour_levels( minimum + fraction.get() * (maximum - minimum) } }; - if level.count == 1 { - // One level always means one visible, interior contour. A policy at the - // exact peak has no crossing, so degenerate estimates resolve halfway - // to the signed peak rather than silently yielding an empty figure. - if !base.is_finite() || base <= 0.0 || base >= peak { - base = peak / 2.0; - } - return (base > 0.0 && base < peak) - .then_some(if negative { -base } else { base }) - .into_iter() - .collect(); - } - if !base.is_finite() || base <= 0.0 || base >= peak { - // A perfectly synthetic/noiseless grid has a zero MAD estimate. Use - // the user-selected ladder span rather than restoring a hidden peak - // fraction, so the concrete `ContourLevelSpec` still controls output. - base = peak - / level - .ratio - .get() - .powi(i32::from(level.count.saturating_sub(1))); - } - if !base.is_finite() || base <= 0.0 || base >= peak { - return Vec::new(); - } - let levels = plotx_render::contour::geometric_levels( - base, - peak, - usize::from(level.count), - level.ratio.get(), - ); + // The ladder — including which policies may be rewritten when their base is + // unusable — is shared with the versioned field resolver, and works purely + // in positive magnitudes; this half applies its own sign afterwards. These + // analysis maps are `FractionOfRange` (see `bounded_scalar_contour_spec`), + // so they never carry an explicit threshold for the ladder to report on. + let levels = crate::contour_ladder::contour_level_ladder(base, peak, level).levels; if negative { levels.into_iter().map(|value| -value).collect() } else { @@ -387,6 +247,8 @@ pub fn build_dosy_figure_cancellable( let levels = contour_levels(&grid, NY, NX, &contour.positive, false); if !levels.is_empty() { // Grid rows map onto [logd_lo, logd_hi], cols onto [ppm_lo, ppm_hi]. + #[cfg(test)] + crate::contour_probe::record_marching_squares(); let segments = plotx_render::contour::segments_cancellable( &grid, NY, NX, ppm_lo, ppm_hi, logd_lo, logd_hi, &levels, cancelled, )?; @@ -458,6 +320,8 @@ pub fn build_ilt_figure_cancellable( let contour = bounded_scalar_contour_spec(); let levels = contour_levels(&grid, ny, nx, &contour.positive, false); if !levels.is_empty() { + #[cfg(test)] + crate::contour_probe::record_marching_squares(); let segments = plotx_render::contour::segments_cancellable( &grid, ny, @@ -486,10 +350,131 @@ fn bounded_scalar_contour_spec() -> ContourSpec { #[cfg(test)] mod tests { use super::*; + use crate::state::{ + ContourResolution, DatasetId, EstimateProvenance, EstimateResult, EstimatedScale, FieldId, + FieldRef, FieldSummary, FieldVersion, FiniteF64, ScaleEstimate, VersionedFieldRef, + resolve_contour_levels, + }; use num_complex::Complex64; + use plotx_figure::ContourStyle; use plotx_processing::AxisMeta; use plotx_render::{Margins, Projector, Rect}; + /// A tilted plane: every first difference is identical, so its robust MAD is + /// exactly zero — the degenerate estimate an ideal noiseless grid produces. + fn planar_values() -> Vec { + (0..4u8) + .flat_map(|row| (0..4u8).map(move |col| 1.0 + f32::from(row) + f32::from(col))) + .collect() + } + + /// Resolve the same positive half through both contour paths: the legacy + /// analysis-map helper, and the versioned field resolver fed the degenerate + /// estimate its worker would produce for these values. + fn both_paths(values: &[f32], level: &ContourLevelSpec) -> (Vec, Vec) { + let legacy = contour_levels(values, 4, 4, level, false); + let (minimum, maximum) = finite_range(values).expect("fixture values are finite"); + let spec = ContourSpec { + positive: level.clone(), + negative: None, + style: ContourStyle::default(), + }; + let source = VersionedFieldRef { + field: FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(77)), + field: FieldId::new(0), + }, + version: FieldVersion(1), + }; + let summary = FieldSummary { + min: FiniteF64::new(minimum).expect("finite"), + max: FiniteF64::new(maximum).expect("finite"), + }; + let resolution = resolve_contour_levels(source, &spec, summary, |_| { + Some(EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::Degenerate, + provenance: EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + })) + }); + let ContourResolution::Ready { + levels: resolved, .. + } = resolution + else { + panic!("the estimate is supplied, so resolution is not pending"); + }; + ( + legacy, + resolved.positive.iter().map(|level| level.get()).collect(), + ) + } + + fn noise_sigma_level(count: u16) -> ContourLevelSpec { + ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: plotx_figure::PositiveFiniteF64::new(5.0).unwrap(), + estimator: plotx_figure::EstimatorSelection::FollowLatest, + }, + count, + ratio: plotx_figure::PositiveFiniteF64::new(1.5).unwrap(), + } + } + + // The whole point of extracting `contour_ladder`: there is one policy for + // what an unusable base means, so the legacy ILT/DOSY path and the versioned + // field resolver cannot drift apart before ILT/DOSY move onto + // `FieldId`/`FieldVersion`. + #[test] + fn degenerate_bases_resolve_identically_on_both_contour_paths() { + let planar = planar_values(); + + // One level, degenerate (zero) base. + let (legacy, resolved) = both_paths(&planar, &noise_sigma_level(1)); + assert_eq!(legacy, resolved); + assert_eq!(legacy.len(), 1); + + // A ladder of levels, degenerate (zero) base. + let (legacy, resolved) = both_paths(&planar, &noise_sigma_level(5)); + assert_eq!(legacy, resolved); + assert!(!legacy.is_empty()); + + // An explicit threshold beyond the peak has no crossing, and is obeyed + // literally rather than rewritten — on both paths alike. + let above_peak = ContourLevelSpec { + base: ContourBasePolicy::Absolute( + plotx_figure::PositiveFiniteF64::new(1_000.0).unwrap(), + ), + count: 4, + ratio: plotx_figure::PositiveFiniteF64::new(1.5).unwrap(), + }; + let (legacy, resolved) = both_paths(&planar, &above_peak); + assert_eq!(legacy, resolved); + assert!(legacy.is_empty()); + } + + #[test] + fn one_level_specs_draw_exactly_one_level_on_both_paths() { + let planar = planar_values(); + // A usable base stays exactly where the user put it. + let usable = ContourLevelSpec { + base: ContourBasePolicy::Absolute(plotx_figure::PositiveFiniteF64::new(2.0).unwrap()), + count: 1, + ratio: plotx_figure::PositiveFiniteF64::new(1.5).unwrap(), + }; + let (legacy, resolved) = both_paths(&planar, &usable); + assert_eq!(legacy, [2.0]); + assert_eq!(resolved, [2.0]); + + // A degenerate one resolves halfway to the peak, on both paths. + let (legacy, resolved) = both_paths(&planar, &noise_sigma_level(1)); + assert_eq!(legacy.len(), 1); + assert_eq!(resolved.len(), 1); + assert_eq!(legacy, [7.0 / 2.0]); + assert_eq!(resolved, [7.0 / 2.0]); + } + fn spectrum_2d() -> Spectrum2D { let f2_ppm = vec![0.0, 1.0, 2.0, 3.0]; let f1_ppm = vec![0.0, 1.0, 2.0, 3.0]; @@ -518,11 +503,7 @@ mod tests { // share the projector, so a wrong flip is invisible in preview). #[test] fn contour_places_low_f1_ppm_near_the_top() { - let fig = build_figure_2d( - &spectrum_2d(), - Preset2D::Hsqc, - &bounded_scalar_contour_spec(), - ); + let fig = build_figure_2d(&spectrum_2d(), Preset2D::Hsqc); assert_eq!(fig.axis_frame, AxisFrame::Box); let proj = Projector::new(&fig, Rect::new(0.0, 0.0, 400.0, 300.0), &Margins::default()); let (_, py_low_ppm) = proj.project([1.5, 0.0]); @@ -534,6 +515,35 @@ mod tests { ); } + // Positive control for the marching-squares probe. Every "no synchronous + // contour build" assertion elsewhere reads zero from the same counter, so + // that counter must be shown to move at least once: this test fails the + // moment an increment is dropped from a call site. + // + // It also pins the remaining synchronous path: ILT/DOSY analysis maps still + // contour on the caller's thread and have not been moved onto the versioned + // field cache. + #[test] + fn ilt_figure_runs_marching_squares_on_the_calling_thread() { + crate::contour_probe::reset(); + let result = IltResult { + ppm: vec![0.0, 1.0, 2.0, 3.0], + d_grid: vec![1.0e-10, 2.0e-10, 4.0e-10, 8.0e-10], + amp: vec![vec![0.0, 0.5, 1.0, 0.5]; 4], + }; + + let figure = build_ilt_figure(&result, "1H", "probe"); + + assert!( + !figure.contours.is_empty(), + "the fixture must actually reach contour extraction" + ); + assert!( + crate::contour_probe::marching_squares_on_this_thread() > 0, + "the marching-squares probe must observe a build on the calling thread" + ); + } + #[test] fn axis_label_formats_isotope_mass_as_superscript() { assert_eq!(axis_label("13C"), "¹³C chemical shift (ppm)"); @@ -583,6 +593,46 @@ mod tests { }; assert_eq!(contour_levels(&[-40.0, -5.0], 1, 2, &level, true), [-10.0]); assert!(contour_levels(&[-40.0, -5.0], 1, 2, &level, false).is_empty()); + + // The shared ladder speaks only in positive magnitudes, so each path + // must still apply its own half's sign. The versioned resolver agrees. + let spec = ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + }; + let source = VersionedFieldRef { + field: FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(78)), + field: FieldId::new(0), + }, + version: FieldVersion(1), + }; + let summary = FieldSummary { + min: FiniteF64::new(-40.0).expect("finite"), + max: FiniteF64::new(-5.0).expect("finite"), + }; + let ContourResolution::Ready { + levels: resolved, + unreachable, + } = resolve_contour_levels(source, &spec, summary, |_| None) + else { + panic!("an absolute contour needs no estimate"); + }; + assert!( + unreachable.is_empty(), + "an all-negative field has no positive signal at all; that is the \ + field's shape, not a mistyped threshold" + ); + assert_eq!( + resolved + .negative + .iter() + .map(|level| level.get()) + .collect::>(), + [-10.0] + ); + assert!(resolved.positive.is_empty()); } #[test] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4e731b8..e35f0a7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -21,6 +21,9 @@ pub mod xlsx; /// Backend arrays remain private to `plotx-data`. pub use plotx_data as data; +mod contour_ladder; +#[cfg(test)] +mod contour_probe; mod figures; pub use figures::*; diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 5b27eba..f20ca30 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -439,6 +439,15 @@ pub fn load_project(path: &Path) -> Result { })?); let di = app.doc.datasets.len(); app.doc.datasets.push(dataset); + app.session + .compute + .register_loaded_dataset_fields(&app.doc.datasets[di]) + .map_err(|error| { + ProjectError::Invalid(format!( + "could not allocate runtime field versions for dataset {}: {error:?}", + binding.data + )) + })?; recipe_to_dataset.insert(binding.recipe.clone(), di); data_to_dataset.insert(binding.data.clone(), di); } diff --git a/crates/core/src/state/afm.rs b/crates/core/src/state/afm.rs index 645cfde..045682c 100644 --- a/crates/core/src/state/afm.rs +++ b/crates/core/src/state/afm.rs @@ -1,5 +1,5 @@ use super::*; -use plotx_figure::{AxisFrame, ColormapId, ContourSpec, HeatmapGrid, Series}; +use plotx_figure::{AxisFrame, ColormapId, Contour, ContourStyle, HeatmapGrid, Series}; use std::sync::Arc; #[derive(Clone)] @@ -21,9 +21,11 @@ impl AfmDataset { [forces.grid_width / 2, forces.grid_height / 2] }); let image_field_keys = crate::state::afm_channel_keys(&data); + let mut field_catalog = crate::state::afm_field_catalog_for_keys(&data, &image_field_keys); + field_catalog.attach_provenance(&data.source, None); Self { resource_id: DatasetId::new(), - field_catalog: crate::state::afm_field_catalog_for_keys(&data, &image_field_keys), + field_catalog, data: Arc::new(data), image_field_keys, name: None, @@ -65,30 +67,42 @@ impl AfmDataset { Some(figure) } - pub fn contour_figure(&self, field: FieldId, contour: &ContourSpec) -> Option
{ + pub(crate) fn contour_base_figure(&self, field: FieldId) -> Option
{ let channel = self.image_for_field(field)?; - let values: Vec = channel - .raw - .iter() - .map(|value| channel.scale.apply(*value) as f32) - .collect(); let mut figure = Figure::new( &channel.name, Axis::new(&channel.lateral_unit, 0.0, channel.scan_size_x), Axis::new(&channel.lateral_unit, 0.0, channel.scan_size_y), ); - figure.contours = crate::figures::scalar_contour_overlays( - &values, - channel.height, - channel.width, - [0.0, channel.scan_size_x, 0.0, channel.scan_size_y], - contour, - ); figure.lock_aspect = true; figure.axis_frame = AxisFrame::Box; Some(figure) } + pub(crate) fn contour_figure_from_geometry( + &self, + field: FieldId, + geometry: &ContourGeometry, + style: &ContourStyle, + ) -> Option
{ + let mut figure = self.contour_base_figure(field)?; + if !geometry.positive.is_empty() { + figure.contours.push(Contour { + segments: geometry.positive.as_ref().to_vec(), + color: style.positive_color.resolve(), + width: style.width.get(), + }); + } + if !geometry.negative.is_empty() { + figure.contours.push(Contour { + segments: geometry.negative.as_ref().to_vec(), + color: style.negative_color.resolve(), + width: style.width.get(), + }); + } + Some(figure) + } + pub fn force_figure(&self, field: FieldId) -> Option
{ (self.field_catalog.id_for_key("afm.force_curve") == Some(field)).then_some(())?; let forces = self.data.forces.as_ref()?; diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index cabecd6..3e54ccb 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -99,7 +99,7 @@ impl PlotxApp { /// (resize, binding/chart/stack/projection edit, load) routes through, so the /// projections survive every rebuild. pub fn build_object_figure( - &self, + &mut self, binding: &DataBinding, chart: &ChartSpec, stack: &StackSpec, @@ -186,7 +186,7 @@ impl PlotxApp { } pub fn build_plot_object( - &self, + &mut self, dataset: usize, frame: ObjectFrame, id: ObjectId, @@ -199,11 +199,21 @@ impl PlotxApp { id, name, ); + let Some((binding, chart, stack, projections, size_mm)) = object.plot().map(|plot| { + ( + plot.binding.clone(), + plot.chart.clone(), + plot.stack, + plot.projections.clone(), + [frame.width / MM_TO_PT, frame.height / MM_TO_PT], + ) + }) else { + return object; + }; + let figure = self.build_object_figure(&binding, &chart, &stack, &projections, size_mm); if let Some(plot) = object.plot_mut() { - plot.figure.typography = self.doc.style_library.figure_typography; - if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { - plot.figure.integral_curves = nmr.integral_curves(); - } + plot.viewport = CanvasViewport::from_figure(&figure); + plot.figure = figure; } object } diff --git a/crates/core/src/state/app_impl_compute.rs b/crates/core/src/state/app_impl_compute.rs index 948b556..71909c1 100644 --- a/crates/core/src/state/app_impl_compute.rs +++ b/crates/core/src/state/app_impl_compute.rs @@ -23,7 +23,30 @@ fn enqueue_error_status(error: EnqueueError) -> String { } } +fn field_enqueue_error_status(error: FieldEnqueueError) -> String { + match error { + FieldEnqueueError::WorkersUnavailable => { + "Background contour computation is unavailable in this session.".into() + } + FieldEnqueueError::VersionExhausted => { + "Field runtime versions are exhausted; reopen PlotX to continue.".into() + } + } +} + impl PlotxApp { + /// Register the immutable fields that entered this session with runtime + /// versions before an action makes the dataset visible to rendering. + pub(crate) fn register_loaded_dataset_fields(&mut self, dataset: &Dataset) -> bool { + match self.session.compute.register_loaded_dataset_fields(dataset) { + Ok(()) => true, + Err(error) => { + self.session.status = field_enqueue_error_status(error); + false + } + } + } + /// Async twin of `build_dosy_map_for`: same validation, but hand the heavy /// per-column diffusion fit to the compute worker instead of blocking the UI. pub fn request_dosy_map(&mut self, dataset: usize) { @@ -220,24 +243,13 @@ impl PlotxApp { } } Done::Processing2D { - generation, dataset, - epoch, base, processed, - figure, + fields, params, + .. } => { - if epoch != self.session.dataset_epoch - || (base.is_some() - && !self.session.compute.is_current( - dataset, - ComputeKind::Processing2D, - generation, - )) - { - continue; - } let Some(dataset) = self.doc.dataset_index(dataset) else { continue; }; @@ -249,10 +261,10 @@ impl PlotxApp { else { continue; }; - // Full results replace the cached base and therefore pass the - // strict generation check above. A Reapply result has no base - // to overwrite and may be shown while a newer recipe is queued; - // single-flight execution prevents out-of-order rollback. + // ComputeService emits only the active processing completion. + // A Reapply result has no base to overwrite and may be shown + // while a newer recipe is queued; single-flight execution + // prevents out-of-order rollback. // `params` may also lag `d2.params` for a paused edit, which is // the intended display-trails-recipe contract. if let Some(base) = base { @@ -261,16 +273,91 @@ impl PlotxApp { d2.base_stale = false; } d2.processed = processed; - d2.processed_figure = figure; + d2.processed_figure = + std::sync::Arc::new(build_processed_figure(&d2.processed, d2.preset)); d2.dosy_map = None; d2.ilt_map = None; d2.dosy_figure = None; d2.ilt_figure = None; + for field in fields { + self.session + .compute + .promote_field_version(field.source, field.summary); + } self.recompute_integrals_2d_after_processing(dataset); self.rebuild_canvases_for(dataset); self.doc.dirty = true; self.session.status = "Updated 2D processing.".into(); } + Done::EstimateField { key, result } => { + let dataset = + self.doc + .dataset_index(key.source.field.resource) + .filter(|&index| { + self.doc.datasets.get(index).is_some_and(|dataset| { + dataset.has_field(key.source.field.field) + }) + }); + let current = dataset + .and_then(|_| self.session.compute.current_field_version(key.source.field)); + if self.session.compute.finish_estimate(key, result, current) + && let Some(dataset) = dataset + { + // The completed job only populated a content-addressed + // cache. Rebuilding resolves each binding's current key; + // it never writes a worker result into a plot directly. + self.rebuild_canvases_for(dataset); + } + } + Done::EstimateFieldFailed { key, message } => { + let current = self + .doc + .dataset_index(key.source.field.resource) + .filter(|&index| { + self.doc + .datasets + .get(index) + .is_some_and(|dataset| dataset.has_field(key.source.field.field)) + }) + .and_then(|_| self.session.compute.current_field_version(key.source.field)); + if current == Some(key.source.version) { + self.session.status = + format!("Field estimate could not be computed: {message}"); + } + } + Done::BuildContour { key, geometry } => { + let dataset = + self.doc + .dataset_index(key.source.field.resource) + .filter(|&index| { + self.doc.datasets.get(index).is_some_and(|dataset| { + dataset.has_field(key.source.field.field) + }) + }); + let current = dataset + .and_then(|_| self.session.compute.current_field_version(key.source.field)); + if self.session.compute.finish_contour(key, geometry, current) + && let Some(dataset) = dataset + { + self.rebuild_canvases_for(dataset); + } + } + Done::BuildContourFailed { key, message } => { + let current = self + .doc + .dataset_index(key.source.field.resource) + .filter(|&index| { + self.doc + .datasets + .get(index) + .is_some_and(|dataset| dataset.has_field(key.source.field.field)) + }) + .and_then(|_| self.session.compute.current_field_version(key.source.field)); + if current == Some(key.source.version) { + self.session.status = + format!("Contour geometry could not be computed: {message}"); + } + } Done::Cancelled { .. } => {} Done::Failed { dataset, kind, .. } => { let name = self @@ -311,24 +398,42 @@ impl PlotxApp { || d2.base_stale || plotx_processing::needs_retransform_2d(&d2.params, &d2.base_params); let params = d2.params.clone(); - let preset = d2.preset; let dataset_id = d2.resource_id; - let aborted = if full { + let fields = [ + d2.field_catalog + .id_for_key("nmr.real") + .map(|field| ProcessingField { + field, + component: ProcessedFieldComponent::Real, + }), + d2.field_catalog + .id_for_key("nmr.magnitude") + .map(|field| ProcessingField { + field, + component: ProcessedFieldComponent::Magnitude, + }), + ] + .into_iter() + .flatten() + .collect::>(); + let outcome = if full { self.session.compute.request_2d_full( dataset_id, - self.session.dataset_epoch, + &fields, std::sync::Arc::clone(&d2.data), params, - preset, ) } else { - self.session.compute.request_2d_reapply( - dataset_id, - self.session.dataset_epoch, - d2.base.clone(), - params, - preset, - ) + self.session + .compute + .request_2d_reapply(dataset_id, &fields, d2.base.clone(), params) + }; + let aborted = match outcome { + Ok(aborted) => aborted, + Err(error) => { + self.session.status = field_enqueue_error_status(error); + return false; + } }; self.session.status = match aborted.first() { Some(kind) => format!( diff --git a/crates/core/src/state/app_impl_compute_tests.rs b/crates/core/src/state/app_impl_compute_tests.rs index 2680238..bf21e59 100644 --- a/crates/core/src/state/app_impl_compute_tests.rs +++ b/crates/core/src/state/app_impl_compute_tests.rs @@ -78,3 +78,63 @@ fn process_2d_result_follows_dataset_identity_after_earlier_deletion() { "the completed result must land on the same DatasetId after index shift" ); } + +#[test] +fn successful_processing_promotes_fresh_runtime_versions_for_each_scalar_field() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data_2d( + "versioned target", + ))))); + let resource = app.doc.datasets[0].resource_id(); + let fields = app.doc.datasets[0] + .field_descriptors() + .into_iter() + .filter(|field| matches!(field.local_id.as_str(), "nmr.real" | "nmr.magnitude")) + .map(|field| FieldRef { + resource, + field: field.id, + }) + .collect::>(); + assert_eq!(fields.len(), 2); + assert!( + fields + .iter() + .all(|field| { app.session.compute.current_field_version(*field).is_none() }) + ); + + assert!(app.schedule_2d_processing(0, true)); + let deadline = Instant::now() + Duration::from_secs(3); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + let first = fields + .iter() + .map(|field| app.session.compute.current_field_version(*field).unwrap()) + .collect::>(); + assert_ne!(first[0], first[1], "each FieldId has its own version token"); + + let dataset = app.doc.datasets[0].as_nmr2d_mut().unwrap(); + let id = dataset.allocate_step_id(); + dataset.params.f2.steps.push(ProcessingStep { + id, + kind: StepKind::Invert, + enabled: true, + source: StepSource::User, + }); + assert!(app.schedule_2d_processing(0, false)); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + for (field, previous) in fields.iter().zip(first) { + assert!( + app.session.compute.current_field_version(*field).unwrap() > previous, + "a successfully installed processing artifact advances its field version" + ); + } +} diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 2876d53..aee4da5 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -1,5 +1,6 @@ use super::*; use plotx_figure::Figure; +use std::sync::Arc; impl PlotxApp { /// Build a dataset's figure through the chart registry: resolve `chart`'s @@ -27,7 +28,7 @@ impl PlotxApp { /// datasets share one stackable (line-series) domain is combined into one /// figure honouring `stack`; any other binding renders the primary alone. pub fn build_binding_figure( - &self, + &mut self, binding: &DataBinding, chart: &ChartSpec, stack: &StackSpec, @@ -139,13 +140,153 @@ impl PlotxApp { figure } - pub(super) fn build_encoded_series_figure(&self, series: &SeriesBinding) -> Option
{ + pub(super) fn build_encoded_series_figure(&mut self, series: &SeriesBinding) -> Option
{ let dataset = self.doc.dataset_by_id(series.source.resource)?; if !dataset.supports_encoding(series.source.field, &series.encoding) { return None; } + let plotx_figure::SeriesEncoding::Contour(contour) = &series.encoding else { + return dataset.encoded_field_figure(series.source.field, &series.encoding); + }; + + let field = FieldRef { + resource: series.source.resource, + field: series.source.field, + }; + let version = match self.session.compute.field_version_for(field) { + Ok(version) => version, + Err(error) => { + self.session.status = field_enqueue_error_status(error); + return dataset.encoded_field_figure(series.source.field, &series.encoding); + } + }; + // A cache hit must not touch the values: the summary is looked up first, + // and the payload is materialized only on the paths that actually hand a + // grid to a worker. + let source = VersionedFieldRef { field, version }; + let summary = match self.session.compute.cached_field_summary(source) { + Some(summary) => summary, + None => { + let snapshot = dataset.field_snapshot(series.source.field, version, None)?; + self.session.compute.remember_field_summary(&snapshot); + snapshot.summary? + } + }; + let resolution = resolve_contour_levels(source, contour, summary, |key| { + self.session.compute.estimate_for(key).cloned() + }); + match resolution { + ContourResolution::Ready { + levels, + unreachable, + } => { + // A threshold the field never reaches draws nothing, which is + // exactly what the user asked for — but silently is how a + // mistyped magnitude becomes an unexplained blank plot. + if !unreachable.is_empty() { + self.session.status = unreachable_threshold_status(&unreachable); + } + let key = ContourGeometryCacheKey { source, levels }; + if let Some(geometry) = self.session.compute.geometry_for(&key) { + return dataset.contour_figure_from_geometry( + series.source.field, + &geometry, + &contour.style, + ); + } + let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + if let Err(error) = self.session.compute.enqueue_contour(key, grid) { + self.session.status = field_enqueue_error_status(error); + } + } + ContourResolution::Pending(keys) => { + let grid = self.contour_grid(dataset, series.source.field, version, summary)?; + for key in keys { + if let Err(error) = self + .session + .compute + .enqueue_estimate(key, Arc::clone(&grid)) + { + self.session.status = field_enqueue_error_status(error); + break; + } + } + } + ContourResolution::Unavailable => { + self.session.status = "Contour levels are unavailable for this field.".into(); + } + } dataset.encoded_field_figure(series.source.field, &series.encoding) } + + /// Materialize the worker-owned grid. Only the enqueue paths call this; + /// resolving against a warm geometry cache never does. + fn contour_grid( + &self, + dataset: &Dataset, + field: FieldId, + version: FieldVersion, + summary: FieldSummary, + ) -> Option> { + let snapshot = dataset.field_snapshot(field, version, Some(summary))?; + Some(Arc::new(snapshot.payload.scalar_grid()?.clone())) + } +} + +/// Word the thresholds a field never reaches. +/// +/// Both numbers appear side by side because that is what makes the common +/// mistake legible: a threshold of 20 against a peak of 10 reads as an extra +/// zero at a glance, where "no contours available" reads as a broken plot. The +/// message ends on the action that fixes it rather than on the failure. +fn unreachable_threshold_status(unreachable: &[UnreachableContourThreshold]) -> String { + unreachable + .iter() + .map(|report| { + let half = if report.negative { + "negative" + } else { + "positive" + }; + // The negative half's threshold and peak are magnitudes, so name the + // peak as one instead of implying a signed comparison. + let peak_label = if report.negative { + "peak magnitude" + } else { + "peak" + }; + let peak = format_level(report.peak.get()); + format!( + "The {half} contour threshold {threshold} is above this field's {half} \ + {peak_label} {peak}, so no {half} contours are drawn. \ + Lower the threshold below {peak}.", + threshold = format_level(report.threshold.get()), + ) + }) + .collect::>() + .join(" ") +} + +/// Print a contour level so a mistyped magnitude stays legible: plain decimals +/// across the range users type by hand, scientific notation once a digit count +/// stops being readable. Only strictly positive magnitudes reach this. +fn format_level(value: f64) -> String { + if value.abs() >= 1e5 || value.abs() < 1e-3 { + format!("{value:.3e}") + } else { + format!("{value}") + } +} + +fn field_enqueue_error_status(error: FieldEnqueueError) -> String { + match error { + FieldEnqueueError::WorkersUnavailable => { + "Background contour computation is unavailable in this session.".into() + } + FieldEnqueueError::VersionExhausted => { + "Field runtime versions are exhausted; reopen PlotX to continue.".into() + } + } } fn unsupported_series_figure(series: &SeriesBinding) -> Figure { diff --git a/crates/core/src/state/app_impl_slice.rs b/crates/core/src/state/app_impl_slice.rs index 2073476..cf5f621 100644 --- a/crates/core/src/state/app_impl_slice.rs +++ b/crates/core/src/state/app_impl_slice.rs @@ -45,9 +45,11 @@ impl NmrDataset { StepSource::Default, )], }; + let mut field_catalog = nmr_field_catalog(); + field_catalog.attach_provenance(&data.source, None); Self { resource_id: DatasetId::new(), - field_catalog: nmr_field_catalog(), + field_catalog, data, base: spectrum.clone(), pipeline, diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index 513271f..52cb9bd 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -10,15 +10,26 @@ use plotx_analysis::ilt::{IltResult, ilt_map_cancellable}; use plotx_figure::Figure; use plotx_io::{DiffusionMeta, NmrData2D}; use plotx_processing::{ - Params2D, Preset2D, Processed2D, StackSpectrum, process_2d_cancellable, reapply_2d_cancellable, + Params2D, Processed2D, StackSpectrum, process_2d_cancellable, reapply_2d_cancellable, }; use super::DatasetId; -use super::build_processed_figure_cancellable; +use super::{ + ContourGeometry, ContourGeometryCacheKey, EstimateKey, EstimateResult, FieldId, FieldRef, + FieldRuntime, FieldSummary, FieldVersion, ScalarGrid2D, VersionedFieldRef, nmr_scalar_grid, +}; use crate::{IltParams, build_dosy_figure_cancellable, build_ilt_figure_cancellable}; -/// Which heavy operation a job/result belongs to, keying the per-dataset -/// newest-generation map that rejects stale results. +#[path = "compute_field.rs"] +mod compute_field; +pub(crate) use compute_field::FieldEnqueueError; +#[path = "compute_worker.rs"] +mod compute_worker; +use compute_worker::run_job; + +/// Which user-visible heavy operation is running. ILT/DOSY retain their own +/// generation guard; scalar field artifacts use `FieldVersion` and +/// content-addressed caches instead. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ComputeKind { Ilt, @@ -46,6 +57,33 @@ pub enum EnqueueError { WorkersUnavailable, } +/// Which scalar payload a processing result populates. The field identity is +/// carried beside it, so real and magnitude never accidentally share a version +/// or geometry cache entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProcessedFieldComponent { + Real, + Magnitude, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProcessingField { + pub field: FieldId, + pub component: ProcessedFieldComponent, +} + +#[derive(Clone, Copy, Debug)] +struct VersionedProcessingField { + source: VersionedFieldRef, + component: ProcessedFieldComponent, +} + +#[derive(Clone, Copy, Debug)] +pub struct ProcessedFieldArtifact { + pub source: VersionedFieldRef, + pub summary: Option, +} + enum Job { Ilt { generation: u64, @@ -72,13 +110,20 @@ enum Job { source: String, }, Process2D { - generation: u64, + version: FieldVersion, dataset: DatasetId, - epoch: u64, token: Arc, input: ProcessingInput, params: Params2D, - preset: Preset2D, + fields: Vec, + }, + EstimateField { + key: EstimateKey, + grid: Arc, + }, + BuildContour { + key: ContourGeometryCacheKey, + grid: Arc, }, } @@ -103,12 +148,11 @@ impl ProcessingInput { } struct DeferredProcessing { - generation: u64, + version: FieldVersion, dataset: DatasetId, - epoch: u64, input: ProcessingInput, params: Params2D, - preset: Preset2D, + fields: Vec, } struct ActiveJob { @@ -118,8 +162,9 @@ struct ActiveJob { processing_input: Option, } -/// A finished computation handed back to the main thread. `generation` is -/// checked against the newest request before the result is installed. +/// A finished computation handed back to the main thread. ILT/DOSY generations +/// are checked against their newest request; field-derived results validate the +/// `FieldVersion` embedded in their content-addressed key. pub enum Done { Ilt { generation: u64, @@ -137,14 +182,29 @@ pub enum Done { figure: Arc
, }, Processing2D { - generation: u64, + version: FieldVersion, dataset: DatasetId, - epoch: u64, base: Option, processed: Processed2D, - figure: Arc
, + fields: Vec, params: Params2D, }, + EstimateField { + key: EstimateKey, + result: EstimateResult, + }, + EstimateFieldFailed { + key: EstimateKey, + message: String, + }, + BuildContour { + key: ContourGeometryCacheKey, + geometry: ContourGeometry, + }, + BuildContourFailed { + key: ContourGeometryCacheKey, + message: String, + }, Cancelled { generation: u64, dataset: DatasetId, @@ -172,6 +232,7 @@ pub struct ComputeService { latest: HashMap<(DatasetId, ComputeKind), u64>, active: HashMap<(DatasetId, ComputeKind), ActiveJob>, deferred_processing: HashMap, + field_runtime: FieldRuntime, /// Dispatch failures awaiting collection by `try_drain`. failures: Vec, } @@ -198,6 +259,7 @@ impl ComputeService { latest: HashMap::new(), active: HashMap::new(), deferred_processing: HashMap::new(), + field_runtime: FieldRuntime::default(), failures: Vec::new(), } } @@ -300,61 +362,68 @@ impl ComputeService { /// Queue a retransform-from-FID. Returns the user-initiated analyses this /// request aborted, so the caller can say so. - pub fn request_2d_full( + pub(crate) fn request_2d_full( &mut self, dataset: DatasetId, - epoch: u64, + fields: &[ProcessingField], data: Arc, params: Params2D, - preset: Preset2D, - ) -> Vec { - self.request_2d(dataset, epoch, ProcessingInput::Full(data), params, preset) + ) -> Result, FieldEnqueueError> { + self.request_2d(dataset, fields, ProcessingInput::Full(data), params) } /// Queue a re-apply from the cached base. Returns the aborted analyses, as /// [`Self::request_2d_full`] does. - pub fn request_2d_reapply( + pub(crate) fn request_2d_reapply( &mut self, dataset: DatasetId, - epoch: u64, + fields: &[ProcessingField], base: Processed2D, params: Params2D, - preset: Preset2D, - ) -> Vec { - self.request_2d( - dataset, - epoch, - ProcessingInput::Reapply(base), - params, - preset, - ) + ) -> Result, FieldEnqueueError> { + self.request_2d(dataset, fields, ProcessingInput::Reapply(base), params) } fn request_2d( &mut self, dataset: DatasetId, - epoch: u64, + fields: &[ProcessingField], input: ProcessingInput, params: Params2D, - preset: Preset2D, - ) -> Vec { + ) -> Result, FieldEnqueueError> { let input_kind = input.kind(); let aborted = self.cancel_incompatible_for_processing(dataset, input_kind); - let generation = self.next_generation(dataset, ComputeKind::Processing2D); + let mut versioned_fields = Vec::with_capacity(fields.len()); + for field in fields { + let version = self.reserve_field_version()?; + versioned_fields.push(VersionedProcessingField { + source: VersionedFieldRef { + field: FieldRef { + resource: dataset, + field: field.field, + }, + version, + }, + component: field.component, + }); + } + let version = versioned_fields + .first() + .map(|field| field.source.version) + .ok_or(FieldEnqueueError::VersionExhausted)?; self.deferred_processing.insert( dataset, DeferredProcessing { - generation, + version, dataset, - epoch, input, params, - preset, + fields: versioned_fields, }, ); // Avoid waiting for the next UI poll when no processing job is active. self.dispatch_ready_processing(); - aborted + Ok(aborted) } fn next_generation(&mut self, dataset: DatasetId, kind: ComputeKind) -> u64 { @@ -391,7 +460,7 @@ impl ComputeService { self.active.insert( (dataset, ComputeKind::Processing2D), ActiveJob { - generation: request.generation, + generation: request.version.0, started_at: Instant::now(), token: Arc::clone(&token), processing_input: Some(input_kind), @@ -400,19 +469,18 @@ impl ComputeService { if self .job_tx .send(Job::Process2D { - generation: request.generation, + version: request.version, dataset: request.dataset, - epoch: request.epoch, token, input: request.input, params: request.params, - preset: request.preset, + fields: request.fields, }) .is_err() { - self.cancel_failed_enqueue(dataset, ComputeKind::Processing2D, request.generation); + self.cancel_failed_enqueue(dataset, ComputeKind::Processing2D, request.version.0); self.failures.push(Done::Failed { - generation: request.generation, + generation: request.version.0, dataset, kind: ComputeKind::Processing2D, }); @@ -424,7 +492,26 @@ impl ComputeService { self.dispatch_ready_processing(); let mut out = std::mem::take(&mut self.failures); while let Ok(done) = self.done_rx.try_recv() { - let (dataset, kind, generation) = done_identity(&done); + match &done { + Done::EstimateField { key, .. } | Done::EstimateFieldFailed { key, .. } => { + self.field_runtime.finish_estimate_request(key); + out.push(done); + continue; + } + Done::BuildContour { key, .. } | Done::BuildContourFailed { key, .. } => { + self.field_runtime.finish_geometry_request(key); + out.push(done); + continue; + } + Done::Ilt { .. } + | Done::Dosy { .. } + | Done::Processing2D { .. } + | Done::Cancelled { .. } + | Done::Failed { .. } => {} + } + let Some((dataset, kind, generation)) = done_identity(&done) else { + continue; + }; let matching_active = self .active .get(&(dataset, kind)) @@ -448,7 +535,9 @@ impl ComputeService { } pub fn is_busy(&self) -> bool { - !self.active.is_empty() || !self.deferred_processing.is_empty() + !self.active.is_empty() + || !self.deferred_processing.is_empty() + || self.field_runtime.has_in_flight() } pub fn progress(&self, dataset: DatasetId, kind: ComputeKind) -> Option { @@ -507,11 +596,7 @@ impl ComputeService { aborted.push(*kind); } } - for kind in [ - ComputeKind::Ilt, - ComputeKind::Dosy, - ComputeKind::Processing2D, - ] { + for kind in [ComputeKind::Ilt, ComputeKind::Dosy] { self.latest.remove(&(dataset, kind)); } aborted @@ -527,7 +612,7 @@ impl ComputeService { { cancelled = true; } - if cancelled { + if cancelled && kind != ComputeKind::Processing2D { self.latest.remove(&(dataset, kind)); } cancelled @@ -556,172 +641,21 @@ fn worker_loop(job_rx: Arc>>, done_tx: Sender) { } } -fn run_job(job: Job) -> Done { - match job { - Job::Ilt { - generation, - dataset, - epoch, - token, - stack, - b_factors, - d_grid, - lambda, - params, - nucleus, - source, - } => { - let cancelled = || token.load(Ordering::Relaxed); - match ilt_map_cancellable(&*stack, &b_factors, &d_grid, lambda, &cancelled) { - Some(result) if !cancelled() => { - let Some(figure) = - build_ilt_figure_cancellable(&result, &nucleus, &source, &cancelled) - .map(Arc::new) - else { - return Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Ilt, - }; - }; - Done::Ilt { - generation, - dataset, - epoch, - result, - params, - figure, - } - } - None => Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Ilt, - }, - Some(_) => Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Ilt, - }, - } - } - Job::Dosy { - generation, - dataset, - epoch, - token, - stack, - values, - meta, - nucleus, - source, - } => { - let cancelled = || token.load(Ordering::Relaxed); - match diffusion_map_cancellable(&*stack, &values, &meta, 0.05, &cancelled) { - Some(result) if !cancelled() => { - let Some(figure) = - build_dosy_figure_cancellable(&result, &nucleus, &source, &cancelled) - .map(Arc::new) - else { - return Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Dosy, - }; - }; - Done::Dosy { - generation, - dataset, - epoch, - result, - figure, - } - } - None => Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Dosy, - }, - Some(_) => Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Dosy, - }, - } - } - Job::Process2D { - generation, - dataset, - epoch, - token, - input, - params, - preset, - } => { - let cancelled = || token.load(Ordering::Relaxed); - let (base, processed) = match input { - ProcessingInput::Full(data) => { - let Some(base) = process_2d_cancellable(&data, ¶ms, &cancelled) else { - return cancelled_done(generation, dataset); - }; - let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { - return cancelled_done(generation, dataset); - }; - (Some(base), processed) - } - ProcessingInput::Reapply(base) => { - let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { - return cancelled_done(generation, dataset); - }; - (None, processed) - } - }; - if cancelled() { - return cancelled_done(generation, dataset); - } - let Some(figure) = - build_processed_figure_cancellable(&processed, preset, &cancelled).map(Arc::new) - else { - return cancelled_done(generation, dataset); - }; - Done::Processing2D { - generation, - dataset, - epoch, - base, - processed, - figure, - params, - } - } - } -} - -fn cancelled_done(generation: u64, dataset: DatasetId) -> Done { - Done::Cancelled { - generation, - dataset, - kind: ComputeKind::Processing2D, - } -} - -fn done_identity(done: &Done) -> (DatasetId, ComputeKind, u64) { +fn done_identity(done: &Done) -> Option<(DatasetId, ComputeKind, u64)> { match done { Done::Ilt { dataset, generation, .. - } => (*dataset, ComputeKind::Ilt, *generation), + } => Some((*dataset, ComputeKind::Ilt, *generation)), Done::Dosy { dataset, generation, .. - } => (*dataset, ComputeKind::Dosy, *generation), + } => Some((*dataset, ComputeKind::Dosy, *generation)), Done::Processing2D { - dataset, - generation, - .. - } => (*dataset, ComputeKind::Processing2D, *generation), + dataset, version, .. + } => Some((*dataset, ComputeKind::Processing2D, version.0)), Done::Cancelled { dataset, generation, @@ -731,7 +665,11 @@ fn done_identity(done: &Done) -> (DatasetId, ComputeKind, u64) { dataset, generation, kind, - } => (*dataset, *kind, *generation), + } => Some((*dataset, *kind, *generation)), + Done::EstimateField { .. } + | Done::EstimateFieldFailed { .. } + | Done::BuildContour { .. } + | Done::BuildContourFailed { .. } => None, } } diff --git a/crates/core/src/state/compute/tests.rs b/crates/core/src/state/compute/tests.rs index 15a45be..4fe0258 100644 --- a/crates/core/src/state/compute/tests.rs +++ b/crates/core/src/state/compute/tests.rs @@ -1,7 +1,7 @@ use super::*; use num_complex::Complex64; use plotx_io::{Dim, Domain, QuadMode}; -use plotx_processing::{PhaseParams, ProcessingStep, StepKind, process_2d}; +use plotx_processing::{PhaseParams, Preset2D, ProcessingStep, StepKind, process_2d}; fn dataset(value: u128) -> DatasetId { DatasetId::from_uuid(uuid::Uuid::from_u128(value)) @@ -55,6 +55,19 @@ fn diffusion_meta() -> DiffusionMeta { } } +fn processing_fields() -> [ProcessingField; 2] { + [ + ProcessingField { + field: FieldId::new(0), + component: ProcessedFieldComponent::Real, + }, + ProcessingField { + field: FieldId::new(1), + component: ProcessedFieldComponent::Magnitude, + }, + ] +} + #[test] fn repeated_processing_requests_coalesce_to_latest_recipe() { let mut service = ComputeService::new(); @@ -67,8 +80,13 @@ fn repeated_processing_requests_coalesce_to_latest_recipe() { plotx_processing::StepSource::User, )); - service.request_2d_full(dataset(0), 4, data_2d(), first, preset); - service.request_2d_full(dataset(0), 4, data_2d(), latest.clone(), preset); + let fields = processing_fields(); + service + .request_2d_full(dataset(0), &fields, data_2d(), first) + .unwrap(); + service + .request_2d_full(dataset(0), &fields, data_2d(), latest.clone()) + .unwrap(); assert_eq!(service.deferred_processing.len(), 1); let deadline = Instant::now() + Duration::from_secs(2); @@ -80,11 +98,22 @@ fn repeated_processing_requests_coalesce_to_latest_recipe() { completed.extend(service.try_drain()); assert!(!service.is_busy()); assert_eq!(completed.len(), 1); - let Done::Processing2D { params, epoch, .. } = &completed[0] else { + let Done::Processing2D { + fields, + params, + version, + .. + } = &completed[0] + else { panic!("expected processing result"); }; assert_eq!(params, &latest); - assert_eq!(*epoch, 4); + assert!(version.0 > 0); + assert_eq!(fields.len(), 2); + assert!( + fields.iter().all(|field| field.summary.is_some()), + "successful scalar Process2D artifacts carry their cheap FieldSummary" + ); } #[test] @@ -93,7 +122,10 @@ fn an_idle_processing_request_dispatches_immediately() { let preset = Preset2D::Cosy; let params = Params2D::default_for(preset); - service.request_2d_full(dataset(0), 0, data_2d(), params, preset); + let fields = processing_fields(); + service + .request_2d_full(dataset(0), &fields, data_2d(), params) + .unwrap(); assert!(service.deferred_processing.is_empty()); assert!( service @@ -125,13 +157,18 @@ fn reapply_to_reapply_keeps_the_active_job_and_replaces_the_deferred_recipe() { StepKind::Phase(PhaseParams::MANUAL_ZERO), plotx_processing::StepSource::User, )); - service.request_2d_reapply(dataset(0), 0, base.clone(), first, preset); + let fields = processing_fields(); + service + .request_2d_reapply(dataset(0), &fields, base.clone(), first) + .unwrap(); assert!(!token.load(Ordering::Relaxed)); - let first_generation = service.deferred_processing[&dataset(0)].generation; + let first_version = service.deferred_processing[&dataset(0)].version; - service.request_2d_reapply(dataset(0), 0, base, Params2D::default_for(preset), preset); + service + .request_2d_reapply(dataset(0), &fields, base, Params2D::default_for(preset)) + .unwrap(); assert!(!token.load(Ordering::Relaxed)); - assert!(service.deferred_processing[&dataset(0)].generation > first_generation); + assert!(service.deferred_processing[&dataset(0)].version > first_version); } #[test] @@ -149,13 +186,15 @@ fn any_full_retransform_cancels_an_active_reapply() { ); let preset = Preset2D::Cosy; - service.request_2d_full( - dataset(0), - 0, - data_2d(), - Params2D::default_for(preset), - preset, - ); + let fields = processing_fields(); + service + .request_2d_full( + dataset(0), + &fields, + data_2d(), + Params2D::default_for(preset), + ) + .unwrap(); assert!(token.load(Ordering::Relaxed)); assert!(matches!( service.deferred_processing[&dataset(0)].input, @@ -182,13 +221,15 @@ fn a_processing_request_reports_the_analysis_it_cancels() { .expect("an idle dataset accepts a DOSY job"); let preset = Preset2D::Cosy; - let aborted = service.request_2d_full( - dataset(1), - 0, - data_2d(), - Params2D::default_for(preset), - preset, - ); + let fields = processing_fields(); + let aborted = service + .request_2d_full( + dataset(1), + &fields, + data_2d(), + Params2D::default_for(preset), + ) + .unwrap(); assert_eq!(aborted, vec![ComputeKind::Dosy]); } @@ -230,13 +271,15 @@ fn a_cancelled_analysis_stops_blocking_a_re_run() { fn pending_processing_blocks_dosy_under_its_own_name() { let mut service = ComputeService::new(); let preset = Preset2D::Cosy; - service.request_2d_full( - dataset(4), - 0, - data_2d(), - Params2D::default_for(preset), - preset, - ); + let fields = processing_fields(); + service + .request_2d_full( + dataset(4), + &fields, + data_2d(), + Params2D::default_for(preset), + ) + .unwrap(); assert_eq!( service.blocking_work_for(dataset(4)), Some(ComputeKind::Processing2D) @@ -247,13 +290,15 @@ fn pending_processing_blocks_dosy_under_its_own_name() { fn cancelling_processing_discards_its_result_and_releases_the_service() { let mut service = ComputeService::new(); let preset = Preset2D::Cosy; - service.request_2d_full( - dataset(3), - 2, - data_2d(), - Params2D::default_for(preset), - preset, - ); + let fields = processing_fields(); + service + .request_2d_full( + dataset(3), + &fields, + data_2d(), + Params2D::default_for(preset), + ) + .unwrap(); assert!(service.cancel(dataset(3), ComputeKind::Processing2D)); assert_eq!( diff --git a/crates/core/src/state/compute_field.rs b/crates/core/src/state/compute_field.rs new file mode 100644 index 0000000..0872bd2 --- /dev/null +++ b/crates/core/src/state/compute_field.rs @@ -0,0 +1,309 @@ +//! Field-versioned estimate and contour work layered onto `ComputeService`. + +use super::*; +use crate::state::{ + Dataset, EstimateKind, EstimateProvenance, EstimatedScale, FieldPayload, FieldProvenance, + FieldRef, FieldSnapshot, FieldSummary, FieldVersion, FiniteF64, LocationScaleEstimate, + ScaleEstimate, VersionedFieldRef, +}; +use plotx_analysis::robust::{ + DEPLANED_LOCATION_SCALE_ID, DEPLANED_LOCATION_SCALE_VERSION, ROBUST_DIFFERENCE_MAD_ID, + ROBUST_DIFFERENCE_MAD_VERSION, deplaned_location_scale, robust_difference_mad, +}; + +/// Failure to queue field-derived work. Unlike a contour miss, this reaches the +/// application status path because a requested render cannot eventually appear. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldEnqueueError { + WorkersUnavailable, + VersionExhausted, +} + +impl ComputeService { + /// Import/load boundary for a field provider that has no processing + /// pipeline (for example an RGB image). It still receives a session version + /// from `ComputeService`; payload type decides whether it has a summary or + /// can ever reach scalar contour work. + pub(crate) fn register_imported_field( + &mut self, + field: FieldRef, + payload: FieldPayload, + provenance: FieldProvenance, + ) -> Result { + let version = self.field_version_for(field)?; + let source = VersionedFieldRef { field, version }; + let cached = self.field_runtime.summary(source); + let snapshot = FieldSnapshot::new(source, payload, provenance, cached); + self.remember_field_summary(&snapshot); + Ok(snapshot) + } + + /// Register versions for a newly loaded immutable dataset before any plot + /// asks for it. This is the import/load allocation point for raw fields; + /// processing results use [`Self::reserve_field_version`] and promotion + /// instead. Registering a scalar snapshot also makes its cheap summary + /// available immediately, without scheduling an estimate. + pub(crate) fn register_loaded_dataset_fields( + &mut self, + dataset: &Dataset, + ) -> Result<(), FieldEnqueueError> { + for descriptor in dataset.field_descriptors() { + let field = FieldRef { + resource: dataset.resource_id(), + field: descriptor.id, + }; + let version = self.field_version_for(field)?; + let cached = self.cached_field_summary(VersionedFieldRef { field, version }); + if let Some(snapshot) = dataset.field_snapshot(descriptor.id, version, cached) { + // Dataset adapters already construct their provenance; the + // same import path is used by providers without a Dataset + // implementation yet. + let FieldSnapshot { + payload, + provenance, + .. + } = snapshot; + let imported = self.register_imported_field(field, payload, provenance)?; + debug_assert_eq!(imported.source.version, version); + } + } + Ok(()) + } + + pub(crate) fn field_version_for( + &mut self, + field: FieldRef, + ) -> Result { + self.field_runtime + .version_for(field) + .ok_or(FieldEnqueueError::VersionExhausted) + } + + pub(crate) fn reserve_field_version(&mut self) -> Result { + self.field_runtime + .reserve_version() + .ok_or(FieldEnqueueError::VersionExhausted) + } + + /// The summary already known for this exact `(field, version)`, if any. + /// Callers consult it *before* building a snapshot so a hit skips the full + /// min/max scan instead of discarding one that already ran. + pub(crate) fn cached_field_summary( + &mut self, + source: VersionedFieldRef, + ) -> Option { + self.field_runtime.summary(source) + } + + pub(crate) fn remember_field_summary(&mut self, snapshot: &FieldSnapshot) { + if let Some(summary) = snapshot.summary { + self.field_runtime + .remember_summary(snapshot.source, summary); + } + } + + pub(crate) fn promote_field_version( + &mut self, + source: VersionedFieldRef, + summary: Option, + ) { + self.field_runtime.promote(source, summary); + } + + pub(crate) fn current_field_version(&self, field: FieldRef) -> Option { + self.field_runtime.current_version(field) + } + + // Cache reads take `&mut self`: the derived-data caches are bounded and + // least-recently-used, so a read is also a recency update. + pub(crate) fn estimate_for(&mut self, key: &EstimateKey) -> Option<&EstimateResult> { + self.field_runtime.estimate(key) + } + + pub(crate) fn geometry_for( + &mut self, + key: &ContourGeometryCacheKey, + ) -> Option> { + self.field_runtime.geometry(key) + } + + pub(crate) fn enqueue_estimate( + &mut self, + key: EstimateKey, + grid: Arc, + ) -> Result { + if self.field_runtime.estimate(&key).is_some() + || !self.field_runtime.begin_estimate(key.clone()) + { + return Ok(false); + } + #[cfg(test)] + crate::contour_probe::record_queued_estimate(); + if self + .job_tx + .send(Job::EstimateField { + key: key.clone(), + grid, + }) + .is_ok() + { + return Ok(true); + } + self.field_runtime.finish_estimate_request(&key); + Err(FieldEnqueueError::WorkersUnavailable) + } + + pub(crate) fn enqueue_contour( + &mut self, + key: ContourGeometryCacheKey, + grid: Arc, + ) -> Result { + if self.field_runtime.geometry(&key).is_some() + || !self.field_runtime.begin_geometry(key.clone()) + { + return Ok(false); + } + #[cfg(test)] + crate::contour_probe::record_queued_contour_build(); + if self + .job_tx + .send(Job::BuildContour { + key: key.clone(), + grid, + }) + .is_ok() + { + return Ok(true); + } + self.field_runtime.finish_geometry_request(&key); + Err(FieldEnqueueError::WorkersUnavailable) + } + + pub(crate) fn finish_estimate( + &mut self, + key: EstimateKey, + result: EstimateResult, + current: Option, + ) -> bool { + self.field_runtime.install_estimate(key, result, current) + } + + pub(crate) fn finish_contour( + &mut self, + key: ContourGeometryCacheKey, + geometry: ContourGeometry, + current: Option, + ) -> bool { + self.field_runtime.install_geometry(key, geometry, current) + } +} + +pub(super) fn run_estimate_field( + key: EstimateKey, + grid: Arc, +) -> Result { + if !grid.has_valid_shape() { + return Err("scalar grid dimensions do not match its row-major values".to_owned()); + } + let provenance = resolved_estimator(&key)?; + match key.kind { + EstimateKind::Noise => { + let scale = robust_difference_mad(&grid.values, grid.rows, grid.cols); + // A zero scale measures a flat field; it is an answer, not a + // failure. Caching it is what stops an identically failing job from + // being re-queued on every canvas rebuild. Only a non-finite or + // negative scale means the estimator itself misbehaved. + let scale = EstimatedScale::new(scale) + .ok_or_else(|| "noise estimator returned a non-finite scale".to_owned())?; + Ok(EstimateResult::Scale(ScaleEstimate { scale, provenance })) + } + EstimateKind::Background => { + let (location, scale) = deplaned_location_scale(&grid.values, grid.rows, grid.cols); + let location = FiniteF64::new(location) + .ok_or_else(|| "background estimator returned a non-finite location".to_owned())?; + let scale = EstimatedScale::new(scale) + .ok_or_else(|| "background estimator returned a non-finite scale".to_owned())?; + Ok(EstimateResult::LocationScale(LocationScaleEstimate { + location, + scale, + provenance, + })) + } + } +} + +fn resolved_estimator(key: &EstimateKey) -> Result { + let (latest_id, latest_version) = match key.kind { + EstimateKind::Noise => (ROBUST_DIFFERENCE_MAD_ID, ROBUST_DIFFERENCE_MAD_VERSION), + EstimateKind::Background => (DEPLANED_LOCATION_SCALE_ID, DEPLANED_LOCATION_SCALE_VERSION), + }; + match &key.estimator { + plotx_figure::EstimatorSelection::FollowLatest => Ok(EstimateProvenance { + estimator: latest_id.to_owned(), + version: latest_version, + }), + plotx_figure::EstimatorSelection::Frozen { estimator, version } + if estimator == latest_id && *version == latest_version => + { + Ok(EstimateProvenance { + estimator: estimator.clone(), + version: *version, + }) + } + plotx_figure::EstimatorSelection::Frozen { estimator, version } => Err(format!( + "{} v{version} is not available for this field estimate", + estimator + )), + } +} + +pub(super) fn run_build_contour( + key: ContourGeometryCacheKey, + grid: Arc, +) -> Result { + if !grid.has_valid_shape() { + return Err("scalar grid dimensions do not match its row-major values".to_owned()); + } + let Some([x0, x1, y0, y1]) = grid.linear_bounds() else { + return Err("contour geometry requires finite linear axis bounds".to_owned()); + }; + let levels = &key.levels; + #[cfg(test)] + crate::contour_probe::record_marching_squares(); + let positive = plotx_render::contour::segments( + &grid.values, + grid.rows, + grid.cols, + x0, + x1, + y0, + y1, + &levels + .positive + .iter() + .map(|value| value.get()) + .collect::>(), + ); + #[cfg(test)] + crate::contour_probe::record_marching_squares(); + let negative = plotx_render::contour::segments( + &grid.values, + grid.rows, + grid.cols, + x0, + x1, + y0, + y1, + &levels + .negative + .iter() + .map(|value| value.get()) + .collect::>(), + ); + Ok(ContourGeometry { + positive: Arc::from(positive), + negative: Arc::from(negative), + positive_levels: u16::try_from(levels.positive.len()).unwrap_or(u16::MAX), + negative_levels: u16::try_from(levels.negative.len()).unwrap_or(u16::MAX), + }) +} diff --git a/crates/core/src/state/compute_worker.rs b/crates/core/src/state/compute_worker.rs new file mode 100644 index 0000000..2a45383 --- /dev/null +++ b/crates/core/src/state/compute_worker.rs @@ -0,0 +1,173 @@ +//! Worker-only execution for jobs declared by `compute`. + +use super::*; + +pub(super) fn run_job(job: Job) -> Done { + match job { + Job::Ilt { + generation, + dataset, + epoch, + token, + stack, + b_factors, + d_grid, + lambda, + params, + nucleus, + source, + } => { + let cancelled = || token.load(Ordering::Relaxed); + match ilt_map_cancellable(&*stack, &b_factors, &d_grid, lambda, &cancelled) { + Some(result) if !cancelled() => { + let Some(figure) = + build_ilt_figure_cancellable(&result, &nucleus, &source, &cancelled) + .map(Arc::new) + else { + return Done::Cancelled { + generation, + dataset, + kind: ComputeKind::Ilt, + }; + }; + Done::Ilt { + generation, + dataset, + epoch, + result, + params, + figure, + } + } + None | Some(_) => Done::Cancelled { + generation, + dataset, + kind: ComputeKind::Ilt, + }, + } + } + Job::Dosy { + generation, + dataset, + epoch, + token, + stack, + values, + meta, + nucleus, + source, + } => { + let cancelled = || token.load(Ordering::Relaxed); + match diffusion_map_cancellable(&*stack, &values, &meta, 0.05, &cancelled) { + Some(result) if !cancelled() => { + let Some(figure) = + build_dosy_figure_cancellable(&result, &nucleus, &source, &cancelled) + .map(Arc::new) + else { + return Done::Cancelled { + generation, + dataset, + kind: ComputeKind::Dosy, + }; + }; + Done::Dosy { + generation, + dataset, + epoch, + result, + figure, + } + } + None | Some(_) => Done::Cancelled { + generation, + dataset, + kind: ComputeKind::Dosy, + }, + } + } + Job::Process2D { + version, + dataset, + token, + input, + params, + fields, + } => { + let cancelled = || token.load(Ordering::Relaxed); + let (base, processed) = match input { + ProcessingInput::Full(data) => { + let Some(base) = process_2d_cancellable(&data, ¶ms, &cancelled) else { + return cancelled_done(version.0, dataset); + }; + let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { + return cancelled_done(version.0, dataset); + }; + (Some(base), processed) + } + ProcessingInput::Reapply(base) => { + let Some(processed) = reapply_2d_cancellable(&base, ¶ms, &cancelled) else { + return cancelled_done(version.0, dataset); + }; + (None, processed) + } + }; + if cancelled() { + return cancelled_done(version.0, dataset); + } + let fields = processed_field_artifacts(&processed, &fields); + Done::Processing2D { + version, + dataset, + base, + processed, + fields, + params, + } + } + Job::EstimateField { key, grid } => { + match compute_field::run_estimate_field(key.clone(), grid) { + Ok(result) => Done::EstimateField { key, result }, + Err(message) => Done::EstimateFieldFailed { key, message }, + } + } + Job::BuildContour { key, grid } => { + match compute_field::run_build_contour(key.clone(), grid) { + Ok(geometry) => Done::BuildContour { key, geometry }, + Err(message) => Done::BuildContourFailed { key, message }, + } + } + } +} + +fn cancelled_done(generation: u64, dataset: DatasetId) -> Done { + Done::Cancelled { + generation, + dataset, + kind: ComputeKind::Processing2D, + } +} + +fn processed_field_artifacts( + processed: &Processed2D, + fields: &[VersionedProcessingField], +) -> Vec { + fields + .iter() + .map(|field| { + let summary = match processed { + Processed2D::Ft(spectrum) => { + let values = match field.component { + ProcessedFieldComponent::Real => spectrum.real(), + ProcessedFieldComponent::Magnitude => spectrum.magnitude(), + }; + nmr_scalar_grid(spectrum, values).summary() + } + Processed2D::Stack(_) => None, + }; + ProcessedFieldArtifact { + source: field.source, + summary, + } + }) + .collect() +} diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 7713157..a895042 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -65,9 +65,11 @@ impl NmrDataset { let has_imaginary = data.domain == Domain::Time || data.points.iter().any(|v| v.im != 0.0); let base = fft::transform_base(&data, &pipeline, group_delay_correct); let spectrum = reapply(&base, &pipeline); + let mut field_catalog = nmr_field_catalog(); + field_catalog.attach_provenance(&data.source, None); let mut result = Self { resource_id: DatasetId::new(), - field_catalog: nmr_field_catalog(), + field_catalog, data, base, pipeline, @@ -208,9 +210,17 @@ impl Nmr2DDataset { let base = process_2d(&data, ¶ms); let processed = reapply_2d(&base, ¶ms); let processed_figure = Arc::new(build_processed_figure(&processed, preset)); + let mut field_catalog = nmr2d_field_catalog(); + field_catalog.attach_provenance( + &data.source, + Some(FieldAlgorithmProvenance { + algorithm: "process_2d".to_owned(), + version: 1, + }), + ); let mut result = Self { resource_id: DatasetId::new(), - field_catalog: nmr2d_field_catalog(), + field_catalog, data: Arc::new(data), base_params: params.clone(), base_stale: false, diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index 3ddba7d..a8aa392 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -1,20 +1,7 @@ use super::*; -use plotx_figure::{Axis, AxisFrame, HeatmapGrid, HeatmapSpec, SeriesEncoding}; - -#[cfg(test)] -thread_local! { - static SYNCHRONOUS_CONTOUR_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn reset_synchronous_contour_builds() { - SYNCHRONOUS_CONTOUR_BUILDS.with(|builds| builds.set(0)); -} - -#[cfg(test)] -pub(crate) fn synchronous_contour_builds() -> usize { - SYNCHRONOUS_CONTOUR_BUILDS.with(std::cell::Cell::get) -} +use plotx_figure::{ + Axis, AxisFrame, Contour, ContourStyle, HeatmapGrid, HeatmapSpec, SeriesEncoding, +}; impl Nmr2DDataset { pub fn figure(&self) -> Figure { @@ -62,15 +49,8 @@ impl Nmr2DDataset { return None; }; match (field.local_id.as_str(), encoding) { - ("nmr.real", SeriesEncoding::Contour(contour)) => { - let cached = default_contour_spec(&field.capabilities); - if *contour == cached { - Some((*self.processed_figure).clone()) - } else { - #[cfg(test)] - SYNCHRONOUS_CONTOUR_BUILDS.with(|builds| builds.set(builds.get() + 1)); - Some(crate::build_figure_2d(spectrum, self.preset, contour)) - } + ("nmr.real", SeriesEncoding::Contour(_)) => { + Some(nmr_contour_base(spectrum, self.preset, "Real")) } ("nmr.real", SeriesEncoding::Heatmap(heatmap)) => Some(nmr_scalar_heatmap( spectrum, @@ -84,12 +64,35 @@ impl Nmr2DDataset { "Magnitude", heatmap, )), - ("nmr.magnitude", SeriesEncoding::Contour(contour)) => { - Some(nmr_magnitude_contour(spectrum, contour)) + ("nmr.magnitude", SeriesEncoding::Contour(_)) => { + Some(nmr_contour_base(spectrum, self.preset, "Magnitude")) } _ => None, } } + + pub(crate) fn contour_figure_from_geometry( + &self, + field: FieldId, + geometry: &ContourGeometry, + style: &ContourStyle, + ) -> Option
{ + let Processed2D::Ft(spectrum) = &self.processed else { + return None; + }; + let name = if self.field_catalog.id_for_key("nmr.real") == Some(field) { + "Real" + } else if self.field_catalog.id_for_key("nmr.magnitude") == Some(field) { + "Magnitude" + } else { + return None; + }; + Some(apply_contour_geometry( + nmr_contour_base(spectrum, self.preset, name), + geometry, + style, + )) + } } fn nmr_axes(spectrum: &plotx_processing::Spectrum2D) -> (Axis, Axis) { @@ -149,60 +152,47 @@ fn nmr_scalar_heatmap( figure } -fn nmr_magnitude_contour( +fn nmr_contour_base( spectrum: &plotx_processing::Spectrum2D, - contour: &plotx_figure::ContourSpec, + preset: Preset2D, + field_name: &str, ) -> Figure { - let mut figure = nmr_scalar_heatmap( - spectrum, - spectrum.magnitude(), - "Magnitude", - &HeatmapSpec::default(), - ); - figure.heatmap = None; - figure.contours = crate::figures::scalar_contour_overlays( - &spectrum.magnitude(), - spectrum.f1_size, - spectrum.f2_size, - [ - spectrum.f2_ppm.first().copied().unwrap_or(0.0), - spectrum.f2_ppm.last().copied().unwrap_or(0.0), - spectrum.f1_ppm.first().copied().unwrap_or(0.0), - spectrum.f1_ppm.last().copied().unwrap_or(0.0), - ], - contour, - ); + let (x, y) = nmr_axes(spectrum); + let mut figure = Figure::new( + format!("{field_name} — {} — {}", preset.label(), spectrum.source), + x, + y, + ) + .with_axis_frame(AxisFrame::Box); + figure.lock_aspect = preset.homonuclear(); figure } -pub(crate) fn build_processed_figure(processed: &Processed2D, preset: Preset2D) -> Figure { - build_processed_figure_cancellable(processed, preset, &|| false) - .expect("non-cancelling processed figure") +pub(crate) fn apply_contour_geometry( + mut figure: Figure, + geometry: &ContourGeometry, + style: &ContourStyle, +) -> Figure { + if !geometry.positive.is_empty() { + figure.contours.push(Contour { + segments: geometry.positive.as_ref().to_vec(), + color: style.positive_color.resolve(), + width: style.width.get(), + }); + } + if !geometry.negative.is_empty() { + figure.contours.push(Contour { + segments: geometry.negative.as_ref().to_vec(), + color: style.negative_color.resolve(), + width: style.width.get(), + }); + } + figure } -pub(crate) fn build_processed_figure_cancellable( - processed: &Processed2D, - preset: Preset2D, - cancelled: &impl Fn() -> bool, -) -> Option
{ - if cancelled() { - return None; - } +pub(crate) fn build_processed_figure(processed: &Processed2D, preset: Preset2D) -> Figure { match processed { - Processed2D::Ft(spectrum) => { - let capabilities = scalar_grid_capabilities( - axis_is_linear(&spectrum.f1_ppm) && axis_is_linear(&spectrum.f2_ppm), - &[ - crate::automation::CAP_FIELD_SIGNED, - crate::automation::CAP_FIELD_NOISE_SCALE, - ], - ); - let contour = default_contour_spec(&capabilities); - build_figure_2d_cancellable(spectrum, preset, &contour, cancelled) - } - Processed2D::Stack(stack) => { - let figure = build_stack_figure(stack); - (!cancelled()).then_some(figure) - } + Processed2D::Ft(spectrum) => nmr_contour_base(spectrum, preset, "Real"), + Processed2D::Stack(stack) => build_stack_figure(stack), } } diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index d60f1c9..e5de8ca 100644 --- a/crates/core/src/state/electrophysiology.rs +++ b/crates/core/src/state/electrophysiology.rs @@ -122,9 +122,11 @@ impl ElectrophysiologyDataset { .fold(0.0, f64::max); let stimulus = stimulus.or_else(|| data.protocol.as_deref().and_then(suggested_stimulus)); let field_keys = crate::state::electrophysiology_channel_keys(&data); + let mut field_catalog = crate::state::electrophysiology_field_catalog_for_keys(&field_keys); + field_catalog.attach_provenance(&data.source, None); Self { resource_id: new_resource_id(), - field_catalog: crate::state::electrophysiology_field_catalog_for_keys(&field_keys), + field_catalog, data, field_keys: OnceLock::from(field_keys), name: None, diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 99f5529..4351f1e 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -1,4 +1,5 @@ -use super::{DatasetId, FieldCatalog, FieldId, electrophysiology_channel_key}; +use super::field_runtime::*; +use super::{FieldCatalog, FieldId, electrophysiology_channel_key}; use crate::automation::{ CAP_FIELD_AFM_MAP, CAP_FIELD_BOUNDED, CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, CAP_FIELD_FORCE_CURVE, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_NMR_CONTOUR, CAP_FIELD_NMR_SPECTRUM, @@ -11,23 +12,29 @@ use plotx_figure::{ UnitInterval, }; use std::collections::{BTreeMap, BTreeSet}; -use std::sync::Arc; impl super::Dataset { /// Describes the stable child fields a dataset currently exposes. This is a /// data adapter, not an encoding registry: callers decide applicability /// solely from the returned capabilities. pub fn field_descriptors(&self) -> Vec { - let curve = |extra: &[&str]| { + let capabilities = |id: FieldId, extra: &[&str]| { + // Capabilities are derived from the field's actual representation, + // never from its data domain — and via the cheap query, so a + // descriptor lookup on the UI thread costs O(rows + cols) rather + // than materializing the whole grid. + let intrinsic = self + .field_representation(id) + .map(FieldRepresentation::intrinsic_capabilities) + .unwrap_or_default(); FieldCapabilities::new( - std::iter::once(CapabilityId::new(CAP_FIELD_CURVE_1D)).chain( + intrinsic.iter().cloned().chain( extra .iter() .map(|capability| CapabilityId::new(*capability)), ), ) }; - let scalar = |regular, extra: &[&str]| scalar_grid_capabilities(regular, extra); let descriptor = |id, local_id: &str, name: &str, capabilities, dimensions, units, recommended: &str| { FieldDescriptor { @@ -53,7 +60,7 @@ impl super::Dataset { id, "nmr.real", "Real", - curve(&[CAP_FIELD_NMR_SPECTRUM]), + capabilities(id, &[CAP_FIELD_NMR_SPECTRUM]), vec![nmr.spectrum.values.len()], vec!["ppm".to_owned()], "line", @@ -66,8 +73,8 @@ impl super::Dataset { id, "nmr.real", "Real", - scalar( - nmr_grid_is_regular(nmr), + capabilities( + id, &[ CAP_FIELD_SIGNED, CAP_FIELD_NOISE_SCALE, @@ -84,7 +91,7 @@ impl super::Dataset { id, "nmr.magnitude", "Magnitude", - scalar(nmr_grid_is_regular(nmr), &[CAP_FIELD_BOUNDED]), + capabilities(id, &[CAP_FIELD_BOUNDED]), vec![nmr.data.rows, nmr.data.cols], vec!["ppm".to_owned(), "ppm".to_owned()], "heatmap", @@ -103,7 +110,7 @@ impl super::Dataset { id, "nmr.stack", "Stack", - curve(&[CAP_FIELD_NMR_STACK]), + capabilities(id, &[CAP_FIELD_NMR_STACK]), vec![nmr.data.cols], vec!["ppm".to_owned()], "line", @@ -125,7 +132,7 @@ impl super::Dataset { id, "table.default_series", "Default series", - curve(&[CAP_FIELD_TABLE]), + capabilities(id, &[CAP_FIELD_TABLE]), vec![row_count], Vec::new(), "line", @@ -145,7 +152,7 @@ impl super::Dataset { id, &key, &channel.name, - curve(&[CAP_FIELD_SWEEP_COLLECTION]), + capabilities(id, &[CAP_FIELD_SWEEP_COLLECTION]), vec![recording.data.sweeps.len()], vec![channel.unit.symbol.clone()], "line", @@ -164,8 +171,8 @@ impl super::Dataset { id, key, &channel.name, - scalar( - true, + capabilities( + id, &[ CAP_FIELD_LOCATION_SCALE, CAP_FIELD_BOUNDED, @@ -185,7 +192,7 @@ impl super::Dataset { id, "afm.force_curve", "Force curve", - curve(&[CAP_FIELD_FORCE_CURVE]), + capabilities(id, &[CAP_FIELD_FORCE_CURVE]), vec![forces.samples_per_curve], vec![forces.signal_scale.unit.clone()], "line", @@ -268,13 +275,29 @@ impl super::Dataset { }, Self::Afm(afm) => match encoding { SeriesEncoding::Line(_) => afm.force_figure(id), - SeriesEncoding::Contour(contour) => afm.contour_figure(id, contour), + SeriesEncoding::Contour(_) => afm.contour_base_figure(id), SeriesEncoding::Heatmap(heatmap) => afm.map_figure(id, heatmap.colormap), SeriesEncoding::Image(_) => None, }, } } + /// Assemble cached contour geometry with this series' style. Geometry has + /// already been resolved and built independently, so style-only edits never + /// invoke marching squares. + pub(crate) fn contour_figure_from_geometry( + &self, + id: FieldId, + geometry: &ContourGeometry, + style: &ContourStyle, + ) -> Option { + match self { + Self::Nmr2D(nmr) => nmr.contour_figure_from_geometry(id, geometry, style), + Self::Afm(afm) => afm.contour_figure_from_geometry(id, geometry, style), + Self::Nmr(_) | Self::Table(_) | Self::Electrophysiology(_) => None, + } + } + /// Validate that every key produced by the concrete provider has a unique, /// persisted field identity. Project decoding calls this before bindings are /// accepted, so a decoder change cannot silently retarget a series. @@ -283,7 +306,7 @@ impl super::Dataset { catalog.validate_for_keys(self.all_field_keys()) } - fn field_catalog(&self) -> &FieldCatalog { + pub(super) fn field_catalog(&self) -> &FieldCatalog { match self { Self::Nmr(dataset) => &dataset.field_catalog, Self::Nmr2D(dataset) => &dataset.field_catalog, @@ -339,158 +362,6 @@ pub fn scalar_grid_capabilities(regular: bool, extra: &[&str]) -> FieldCapabilit ) } -fn nmr_grid_is_regular(dataset: &super::Nmr2DDataset) -> bool { - let plotx_processing::Processed2D::Ft(spectrum) = &dataset.processed else { - return false; - }; - axis_is_linear(&spectrum.f1_ppm) && axis_is_linear(&spectrum.f2_ppm) -} - -pub(crate) fn axis_is_linear(values: &[f64]) -> bool { - let Some((&first, rest)) = values.split_first() else { - return false; - }; - if rest.is_empty() { - return true; - } - let last = *rest.last().unwrap_or(&first); - let step = (last - first) / (values.len() - 1) as f64; - values.iter().enumerate().all(|(index, value)| { - let expected = first + step * index as f64; - (*value - expected).abs() <= 1e-9 * expected.abs().max(1.0) - }) -} - -/// A reference to a field child resource. It is a data source, never a plot -/// component: contour properties remain addressed by the owning `SeriesId`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct FieldRef { - pub resource: DatasetId, - pub field: FieldId, -} - -/// Runtime-only revision of immutable field data. It is purposefully separate -/// from persisted field identity and is not part of the project format yet. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct FieldVersion(pub u64); - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct VersionedFieldRef { - pub field: FieldRef, - pub version: FieldVersion, -} - -#[derive(Clone, Debug)] -pub struct FieldSnapshot { - pub source: VersionedFieldRef, - pub payload: FieldPayload, - pub provenance: FieldProvenance, -} - -/// Field payloads stay arity- and representation-specific. In particular, a -/// colored raster has no scalar statistics and cannot reach contour resolution. -#[derive(Clone, Debug)] -pub enum FieldPayload { - ScalarGrid2D(ScalarGrid2D), - Curve1D(Curve1D), - ColoredRaster2D(ColoredRaster2D), -} - -impl FieldPayload { - pub fn scalar_grid(&self) -> Option<&ScalarGrid2D> { - match self { - Self::ScalarGrid2D(grid) => Some(grid), - Self::Curve1D(_) | Self::ColoredRaster2D(_) => None, - } - } - - pub fn summary(&self) -> Option { - self.scalar_grid().and_then(ScalarGrid2D::summary) - } - - /// Capabilities implied by the concrete payload representation. Providers - /// may add semantic capabilities (signed, noise scale, units), but must not - /// claim a regular scalar grid for an explicitly sampled one. - pub fn intrinsic_capabilities(&self) -> FieldCapabilities { - match self { - Self::ScalarGrid2D(grid) if grid.is_regular() => scalar_grid_capabilities(true, &[]), - Self::ScalarGrid2D(_) => scalar_grid_capabilities(false, &[]), - Self::Curve1D(_) => FieldCapabilities::new([CapabilityId::new(CAP_FIELD_CURVE_1D)]), - Self::ColoredRaster2D(_) => { - FieldCapabilities::new([CapabilityId::new(CAP_FIELD_COLORED_RASTER_2D)]) - } - } - } -} - -#[derive(Clone, Debug)] -pub struct ScalarGrid2D { - pub values: Arc<[f32]>, - pub rows: usize, - pub cols: usize, - pub x: AxisSampling, - pub y: AxisSampling, -} - -impl ScalarGrid2D { - pub fn is_regular(&self) -> bool { - matches!(self.x, AxisSampling::Linear { .. }) - && matches!(self.y, AxisSampling::Linear { .. }) - } - - pub fn summary(&self) -> Option { - let mut values = self - .values - .iter() - .copied() - .filter(|value| value.is_finite()); - let first = values.next()? as f64; - let (min, max) = values.fold((first, first), |(min, max), value| { - let value = value as f64; - (min.min(value), max.max(value)) - }); - Some(FieldSummary { min, max }) - } -} - -#[derive(Clone, Debug)] -pub enum AxisSampling { - Linear { start: f64, end: f64 }, - Explicit(Arc<[f64]>), -} - -#[derive(Clone, Debug)] -pub struct Curve1D { - pub x: Arc<[f64]>, - pub values: Arc<[f32]>, -} - -#[derive(Clone, Debug)] -pub struct ColoredRaster2D { - pub pixels: Arc<[u8]>, - pub rows: usize, - pub cols: usize, - pub format: RasterFormat, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RasterFormat { - Rgb8, - Rgba8, -} - -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct FieldProvenance { - pub source_fingerprint: Option, - pub metadata: BTreeMap, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct FieldSummary { - pub min: f64, - pub max: f64, -} - /// Stable child-resource metadata, including the capabilities used by encoding /// and chart applicability checks. #[derive(Clone, Debug, PartialEq, Eq)] @@ -633,19 +504,21 @@ pub fn default_encoding( } pub fn default_contour_spec(capabilities: &FieldCapabilities) -> ContourSpec { - let estimator = EstimatorSelection::Frozen { - estimator: "robust_difference_mad".to_owned(), - version: 1, - }; let base = if capabilities.contains(CAP_FIELD_NOISE_SCALE) { ContourBasePolicy::NoiseSigma { multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), - estimator, + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, } } else if capabilities.contains(CAP_FIELD_LOCATION_SCALE) { ContourBasePolicy::BackgroundScale { multiplier: PositiveFiniteF64::new(5.0).expect("literal multiplier is valid"), - estimator, + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), + version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, + }, } } else if capabilities.contains(CAP_FIELD_BOUNDED) { ContourBasePolicy::FractionOfRange( diff --git a/crates/core/src/state/field_cache.rs b/crates/core/src/state/field_cache.rs new file mode 100644 index 0000000..1e16405 --- /dev/null +++ b/crates/core/src/state/field_cache.rs @@ -0,0 +1,267 @@ +//! Bounded runtime caches for versioned field artifacts. +//! +//! These caches are session state: they hold no document meaning, are never +//! persisted, and never enter undo. Eviction therefore affects hit rate only — +//! an evicted key simply misses on the next resolve and is re-queued. +//! +//! There is deliberately **no** version-driven sweep. A promoted `FieldVersion` +//! makes every older key unreachable by construction, and reclaiming those +//! entries lazily is what keeps derived data free of invalidation fan-out. + +use super::{ + ContourGeometry, ContourGeometryCacheKey, ContourSegment, EstimateKey, EstimateResult, + FieldRef, FieldSummary, FieldVersion, VersionedFieldRef, +}; +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; +use std::sync::Arc; + +/// Contour geometry is bounded by bytes *and* by entry count on purpose. +/// A single entry ranges from zero segments to millions, so a count cap alone +/// cannot bound memory; conversely a byte budget alone would let millions of +/// tiny entries accumulate and make the eviction scan unbounded. Dragging a +/// contour threshold mints a fresh `ResolvedContourLevels` — hence a fresh key — +/// on every frame, so this cache is the one that actually grows with use. +const GEOMETRY_BYTE_BUDGET: usize = 64 * 1024 * 1024; +const GEOMETRY_ENTRY_LIMIT: usize = 256; +/// Summaries and estimates are fixed-size values, so an entry count is an +/// accurate memory bound. They still need one: every reprocessing run promotes +/// a new `FieldVersion` and therefore a new key. +const SUMMARY_ENTRY_LIMIT: usize = 1024; +const ESTIMATE_ENTRY_LIMIT: usize = 512; + +struct LruEntry { + value: V, + cost: usize, + used: u64, +} + +/// A least-recently-used map bounded by both an aggregate cost and an entry +/// count. `cost` is bytes for caches whose values vary wildly in size and `1` +/// for caches of uniformly small values. +struct LruMap { + entries: HashMap>, + clock: u64, + cost: usize, + cost_budget: usize, + entry_budget: usize, +} + +impl LruMap { + fn new(cost_budget: usize, entry_budget: usize) -> Self { + Self { + entries: HashMap::new(), + clock: 0, + cost: 0, + cost_budget, + entry_budget, + } + } + + fn get(&mut self, key: &K) -> Option<&V> { + self.clock = self.clock.wrapping_add(1); + let clock = self.clock; + let entry = self.entries.get_mut(key)?; + entry.used = clock; + Some(&entry.value) + } + + /// Insert `key`, then evict least-recently-used entries until both budgets + /// hold. `retain` protects keys that must never be dropped — in practice + /// the in-flight sets, whose completion would otherwise write into a slot + /// that was just evicted or re-queue work that is already running. + fn insert(&mut self, key: K, value: V, cost: usize, retain: impl Fn(&K) -> bool) { + self.clock = self.clock.wrapping_add(1); + let entry = LruEntry { + value, + cost, + used: self.clock, + }; + let fresh = key.clone(); + if let Some(previous) = self.entries.insert(key, entry) { + self.cost = self.cost.saturating_sub(previous.cost); + } + self.cost = self.cost.saturating_add(cost); + while self.cost > self.cost_budget || self.entries.len() > self.entry_budget { + // The entry budget keeps this scan bounded by a small constant. + // The entry just written is protected as well: a single value larger + // than the whole budget must be kept rather than dropped on arrival, + // which would rebuild it on every frame forever. + let Some(victim) = self + .entries + .iter() + .filter(|(key, _)| **key != fresh && !retain(key)) + .min_by_key(|(_, entry)| entry.used) + .map(|(key, _)| key.clone()) + else { + // Everything left is protected; exceeding the budget is + // preferable to dropping work that is already running. + break; + }; + if let Some(entry) = self.entries.remove(&victim) { + self.cost = self.cost.saturating_sub(entry.cost); + } + } + } +} + +fn geometry_cost(geometry: &ContourGeometry) -> usize { + geometry + .positive + .len() + .saturating_add(geometry.negative.len()) + .saturating_mul(size_of::()) + .saturating_add(size_of::()) +} + +/// Runtime owner for monotonic versions and content-addressed field caches. +pub(crate) struct FieldRuntime { + next_version: u64, + current: HashMap, + summaries: LruMap, + estimates: LruMap, + geometry: LruMap>, + estimates_in_flight: HashSet, + geometry_in_flight: HashSet, +} + +impl Default for FieldRuntime { + fn default() -> Self { + Self { + next_version: 0, + current: HashMap::new(), + summaries: LruMap::new(SUMMARY_ENTRY_LIMIT, SUMMARY_ENTRY_LIMIT), + estimates: LruMap::new(ESTIMATE_ENTRY_LIMIT, ESTIMATE_ENTRY_LIMIT), + geometry: LruMap::new(GEOMETRY_BYTE_BUDGET, GEOMETRY_ENTRY_LIMIT), + estimates_in_flight: HashSet::new(), + geometry_in_flight: HashSet::new(), + } + } +} + +impl FieldRuntime { + pub(crate) fn version_for(&mut self, field: FieldRef) -> Option { + if let Some(version) = self.current.get(&field) { + return Some(*version); + } + let version = self.reserve_version()?; + self.current.insert(field, version); + Some(version) + } + + pub(crate) fn reserve_version(&mut self) -> Option { + let next = self.next_version.checked_add(1)?; + self.next_version = next; + Some(FieldVersion(next)) + } + + pub(crate) fn current_version(&self, field: FieldRef) -> Option { + self.current.get(&field).copied() + } + + pub(crate) fn promote(&mut self, source: VersionedFieldRef, summary: Option) { + self.current.insert(source.field, source.version); + if let Some(summary) = summary { + self.remember_summary(source, summary); + } + } + + /// The cached summary for exactly this `(field, version)`. Callers consult + /// it *before* materializing a payload: a hit skips the full min/max scan + /// outright rather than discarding one that already ran. + pub(crate) fn summary(&mut self, source: VersionedFieldRef) -> Option { + self.summaries.get(&source).copied() + } + + pub(crate) fn remember_summary(&mut self, source: VersionedFieldRef, summary: FieldSummary) { + self.summaries.insert(source, summary, 1, |_| false); + } + + pub(crate) fn estimate(&mut self, key: &EstimateKey) -> Option<&EstimateResult> { + self.estimates.get(key) + } + + pub(crate) fn geometry( + &mut self, + key: &ContourGeometryCacheKey, + ) -> Option> { + self.geometry.get(key).cloned() + } + + pub(crate) fn begin_estimate(&mut self, key: EstimateKey) -> bool { + self.estimates_in_flight.insert(key) + } + + pub(crate) fn begin_geometry(&mut self, key: ContourGeometryCacheKey) -> bool { + self.geometry_in_flight.insert(key) + } + + pub(crate) fn finish_estimate_request(&mut self, key: &EstimateKey) { + self.estimates_in_flight.remove(key); + } + + pub(crate) fn finish_geometry_request(&mut self, key: &ContourGeometryCacheKey) { + self.geometry_in_flight.remove(key); + } + + pub(crate) fn install_estimate( + &mut self, + key: EstimateKey, + value: EstimateResult, + current: Option, + ) -> bool { + if current != Some(key.source.version) { + return false; + } + let in_flight = &self.estimates_in_flight; + self.estimates + .insert(key, value, 1, |candidate| in_flight.contains(candidate)); + true + } + + pub(crate) fn install_geometry( + &mut self, + key: ContourGeometryCacheKey, + value: ContourGeometry, + current: Option, + ) -> bool { + if current != Some(key.source.version) { + return false; + } + let cost = geometry_cost(&value); + let in_flight = &self.geometry_in_flight; + self.geometry + .insert(key, Arc::new(value), cost, |candidate| { + in_flight.contains(candidate) + }); + true + } + + pub(crate) fn has_in_flight(&self) -> bool { + !self.estimates_in_flight.is_empty() || !self.geometry_in_flight.is_empty() + } + + #[cfg(test)] + pub(crate) fn geometry_entry_limit() -> usize { + GEOMETRY_ENTRY_LIMIT + } + + #[cfg(test)] + pub(crate) fn estimate_entry_limit() -> usize { + ESTIMATE_ENTRY_LIMIT + } + + #[cfg(test)] + pub(crate) fn cached_geometry_count(&self) -> usize { + self.geometry.entries.len() + } + + #[cfg(test)] + pub(crate) fn cached_estimate_count(&self) -> usize { + self.estimates.entries.len() + } +} + +#[cfg(test)] +#[path = "field_cache_tests.rs"] +mod tests; diff --git a/crates/core/src/state/field_cache_tests.rs b/crates/core/src/state/field_cache_tests.rs new file mode 100644 index 0000000..d602b53 --- /dev/null +++ b/crates/core/src/state/field_cache_tests.rs @@ -0,0 +1,158 @@ +use super::*; +use crate::state::{ + DatasetId, EstimateKind, EstimateProvenance, EstimatedScale, FieldId, FiniteF64, + ResolvedContourLevels, ScaleEstimate, +}; +use plotx_figure::{EstimatorSelection, PositiveFiniteF64}; + +fn field(index: u128) -> FieldRef { + FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(index)), + field: FieldId::new(0), + } +} + +fn geometry_key(source: VersionedFieldRef, level: u64) -> ContourGeometryCacheKey { + ContourGeometryCacheKey { + source, + levels: ResolvedContourLevels { + positive: Arc::from([FiniteF64::new(level as f64 + 1.0).expect("finite level")]), + negative: Arc::from([]), + }, + } +} + +fn estimate_key(source: VersionedFieldRef, version: u32) -> EstimateKey { + EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: EstimatorSelection::Frozen { + estimator: "test.estimator".to_owned(), + version, + }, + } +} + +fn scale_estimate() -> EstimateResult { + EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::Positive(PositiveFiniteF64::new(1.0).expect("positive literal")), + provenance: EstimateProvenance { + estimator: "test.estimator".to_owned(), + version: 1, + }, + }) +} + +fn runtime_with_field(index: u128) -> (FieldRuntime, VersionedFieldRef) { + let mut runtime = FieldRuntime::default(); + let field = field(index); + let version = runtime.version_for(field).expect("a fresh version"); + (runtime, VersionedFieldRef { field, version }) +} + +#[test] +fn cost_budget_evicts_least_recently_used_entries_first() { + let mut cache = LruMap::::new(10, usize::MAX); + cache.insert(1, 1, 4, |_| false); + cache.insert(2, 2, 4, |_| false); + assert_eq!(cache.get(&1).copied(), Some(1)); + + // Re-reading key 1 made key 2 the least recently used one. + cache.insert(3, 3, 4, |_| false); + assert_eq!(cache.get(&2), None); + assert_eq!(cache.get(&1).copied(), Some(1)); + assert_eq!(cache.get(&3).copied(), Some(3)); +} + +#[test] +fn a_single_oversized_entry_does_not_wedge_the_cost_budget() { + let mut cache = LruMap::::new(10, usize::MAX); + cache.insert(1, 1, 4, |_| false); + cache.insert(2, 2, 64, |_| false); + + // Everything cheaper was evicted; the oversized entry stays usable rather + // than being dropped as soon as it is written. + assert_eq!(cache.get(&1), None); + assert_eq!(cache.get(&2).copied(), Some(2)); +} + +#[test] +fn geometry_cache_is_bounded_and_an_evicted_key_is_simply_rebuilt() { + let (mut runtime, source) = runtime_with_field(1); + let limit = FieldRuntime::geometry_entry_limit(); + let current = Some(source.version); + + for level in 0..(limit as u64 + 8) { + assert!(runtime.install_geometry( + geometry_key(source, level), + ContourGeometry::empty(), + current, + )); + } + + assert!(runtime.cached_geometry_count() <= limit); + let evicted = geometry_key(source, 0); + assert!( + runtime.geometry(&evicted).is_none(), + "the least recently used geometry is dropped once the cache is full" + ); + assert!( + runtime + .geometry(&geometry_key(source, limit as u64 + 7)) + .is_some(), + "the most recent geometry survives" + ); + + // Eviction may only cost hit rate: the key re-queues and resolves again. + assert!(runtime.begin_geometry(evicted.clone())); + assert!(runtime.install_geometry(evicted.clone(), ContourGeometry::empty(), current)); + runtime.finish_geometry_request(&evicted); + assert!(runtime.geometry(&evicted).is_some()); +} + +#[test] +fn an_in_flight_geometry_key_is_never_evicted() { + let (mut runtime, source) = runtime_with_field(2); + let limit = FieldRuntime::geometry_entry_limit(); + let current = Some(source.version); + + // A `BuildContour` result is installed while its request is still recorded + // as in flight. Evicting it there would leave `enqueue_contour` with + // neither a cached geometry nor a fresh request, i.e. a permanently empty + // contour rather than a slower one. + let in_flight = geometry_key(source, 0); + assert!(runtime.begin_geometry(in_flight.clone())); + assert!(runtime.install_geometry(in_flight.clone(), ContourGeometry::empty(), current)); + + for level in 1..(limit as u64 + 8) { + assert!(runtime.install_geometry( + geometry_key(source, level), + ContourGeometry::empty(), + current, + )); + } + + assert!( + runtime.geometry(&in_flight).is_some(), + "an in-flight key must survive eviction" + ); +} + +#[test] +fn estimate_cache_is_bounded_too() { + let (mut runtime, source) = runtime_with_field(3); + let limit = FieldRuntime::estimate_entry_limit(); + let current = Some(source.version); + + for version in 0..(limit as u32 + 8) { + assert!(runtime.install_estimate(estimate_key(source, version), scale_estimate(), current)); + } + + assert!(runtime.cached_estimate_count() <= limit); + assert!(runtime.estimate(&estimate_key(source, 0)).is_none()); + assert!( + runtime + .estimate(&estimate_key(source, limit as u32 + 7)) + .is_some() + ); +} diff --git a/crates/core/src/state/field_catalog.rs b/crates/core/src/state/field_catalog.rs index 89241c4..7c16199 100644 --- a/crates/core/src/state/field_catalog.rs +++ b/crates/core/src/state/field_catalog.rs @@ -1,5 +1,6 @@ -use super::FieldId; +use super::{FieldAlgorithmProvenance, FieldId, FieldProvenance}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -10,6 +11,9 @@ use std::sync::Arc; pub struct FieldCatalog { next_id: u64, key_to_id: BTreeMap, + /// Persisted source and algorithm provenance for each stable child field. + /// Runtime `FieldVersion` deliberately does not live here. + provenance: BTreeMap, } impl FieldCatalog { @@ -17,6 +21,7 @@ impl FieldCatalog { let mut catalog = Self { next_id: 0, key_to_id: BTreeMap::new(), + provenance: BTreeMap::new(), }; for key in keys { catalog.allocate(key); @@ -28,6 +33,39 @@ impl FieldCatalog { self.key_to_id.get(key).copied() } + pub(crate) fn provenance_for(&self, id: FieldId) -> Option<&FieldProvenance> { + self.provenance.get(&id) + } + + /// Record source identity and implementation identity independently of the + /// session-only runtime version. The catalog itself is already persisted in + /// each project's `plotx.fields` data extension. + pub(crate) fn attach_provenance( + &mut self, + source: &str, + algorithm: Option, + ) { + for id in self.key_to_id.values().copied() { + self.provenance + .insert(id, Self::make_provenance(source, id, algorithm.clone())); + } + } + + pub(crate) fn make_provenance( + source: &str, + id: FieldId, + algorithm: Option, + ) -> FieldProvenance { + let mut digest = Sha256::new(); + digest.update(source.as_bytes()); + digest.update(id.get().to_le_bytes()); + FieldProvenance { + source_fingerprint: Some(format!("{:x}", digest.finalize())), + algorithm, + metadata: BTreeMap::new(), + } + } + fn allocate(&mut self, key: String) { if self.key_to_id.contains_key(&key) { return; @@ -60,6 +98,11 @@ impl FieldCatalog { if self.next_id < minimum_next { return Err("field catalog allocator would reuse an existing identity".to_owned()); } + if self.provenance.len() != self.key_to_id.len() + || !ids.iter().all(|id| self.provenance.contains_key(id)) + { + return Err("field catalog does not carry provenance for every field".to_owned()); + } Ok(()) } } @@ -83,7 +126,9 @@ pub(crate) fn table_field_catalog() -> FieldCatalog { #[cfg(test)] pub(crate) fn afm_field_catalog(data: &plotx_io::AfmData) -> FieldCatalog { let image_keys = afm_channel_keys(data); - afm_field_catalog_for_keys(data, &image_keys) + let mut catalog = afm_field_catalog_for_keys(data, &image_keys); + catalog.attach_provenance(&data.source, None); + catalog } pub(crate) fn afm_channel_keys(data: &plotx_io::AfmData) -> Arc<[String]> { @@ -230,3 +275,33 @@ pub(crate) fn reset_afm_channel_key_computations() { pub(crate) fn afm_channel_key_computations() -> usize { AFM_CHANNEL_KEY_COMPUTATIONS.with(std::cell::Cell::get) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_provenance_round_trips_with_the_persisted_catalog() { + let mut catalog = FieldCatalog::for_keys(["grid".to_owned()]); + catalog.attach_provenance( + "source.raw", + Some(FieldAlgorithmProvenance { + algorithm: "process_2d".to_owned(), + version: 1, + }), + ); + let encoded = serde_json::to_value(&catalog).unwrap(); + let decoded: FieldCatalog = serde_json::from_value(encoded).unwrap(); + let provenance = decoded + .provenance_for(FieldId::new(0)) + .expect("a catalog field keeps provenance"); + assert!(provenance.source_fingerprint.is_some()); + assert_eq!( + provenance.algorithm, + Some(FieldAlgorithmProvenance { + algorithm: "process_2d".to_owned(), + version: 1, + }) + ); + } +} diff --git a/crates/core/src/state/field_payload.rs b/crates/core/src/state/field_payload.rs new file mode 100644 index 0000000..eb9a36f --- /dev/null +++ b/crates/core/src/state/field_payload.rs @@ -0,0 +1,362 @@ +//! Provider adapters that turn dataset internals into representation-neutral +//! field payloads, plus the cheap representation query capability derivation +//! uses. Renderers and derived-data workers only ever see the results. + +use super::field_runtime::*; +use super::{FieldCatalog, FieldId}; +use std::sync::Arc; + +impl super::Dataset { + /// Return an owned, representation-neutral payload for one concrete field. + /// The provider boundary is the only place that knows dataset internals. + /// + /// This materializes values and is therefore reserved for work that needs + /// them (worker snapshots, summaries). Capability and descriptor queries + /// use [`Self::field_representation`] instead. + pub fn field_payload(&self, id: FieldId) -> Option { + #[cfg(test)] + crate::contour_probe::record_field_payload(); + match self { + Self::Nmr(nmr) => (nmr.field_catalog.id_for_key("nmr.real") == Some(id)).then(|| { + FieldPayload::Curve1D(Curve1D { + x: Arc::from(nmr.spectrum.ppm.clone()), + values: Arc::from( + nmr.spectrum + .values + .iter() + .map(|value| value.re as f32) + .collect::>(), + ), + }) + }), + Self::Nmr2D(nmr) => nmr_field_payload(nmr, id), + Self::Table(table) => { + (table.field_catalog.id_for_key("table.default_series") == Some(id)).then(|| { + let figure = table.figure(); + let points = figure + .series + .first() + .map(|series| series.points.as_slice()) + .unwrap_or_default(); + FieldPayload::Curve1D(Curve1D { + x: Arc::from(points.iter().map(|point| point[0]).collect::>()), + values: Arc::from( + points + .iter() + .map(|point| point[1] as f32) + .collect::>(), + ), + }) + }) + } + Self::Electrophysiology(recording) => { + let channel = (0..recording.data.channels.len()).find(|&index| { + recording + .field_key(index) + .and_then(|key| recording.field_catalog.id_for_key(key)) + == Some(id) + })?; + let values = recording + .data + .sweeps + .first() + .and_then(|sweep| sweep.channels.get(channel)) + .cloned() + .unwrap_or_default(); + let dt = if recording.data.sample_rate_hz.is_finite() + && recording.data.sample_rate_hz > 0.0 + { + 1.0 / recording.data.sample_rate_hz + } else { + 1.0 + }; + Some(FieldPayload::Curve1D(Curve1D { + x: Arc::from( + (0..values.len()) + .map(|index| index as f64 * dt) + .collect::>(), + ), + values: Arc::from( + values + .into_iter() + .map(|value| value as f32) + .collect::>(), + ), + })) + } + Self::Afm(afm) => { + if let Some(channel) = afm + .data + .images + .iter() + .zip(afm.image_field_keys.iter()) + .find_map(|(channel, key)| { + (afm.field_catalog.id_for_key(key) == Some(id)).then_some(channel) + }) + { + return Some(FieldPayload::ScalarGrid2D(ScalarGrid2D { + values: Arc::from( + channel + .raw + .iter() + .map(|value| channel.scale.apply(*value) as f32) + .collect::>(), + ), + rows: channel.height, + cols: channel.width, + x: linear_or_explicit_axis(0.0, channel.scan_size_x, channel.width), + y: linear_or_explicit_axis(0.0, channel.scan_size_y, channel.height), + })); + } + (afm.field_catalog.id_for_key("afm.force_curve") == Some(id)).then(|| { + let values = afm + .data + .forces + .as_ref() + .and_then(|forces| { + forces.curve_raw(afm.selected_pixel[0], afm.selected_pixel[1]) + }) + .map(|curve| curve.iter().map(|value| *value as f32).collect::>()) + .unwrap_or_default(); + FieldPayload::Curve1D(Curve1D { + x: Arc::from( + (0..values.len()) + .map(|index| index as f64) + .collect::>(), + ), + values: Arc::from(values), + }) + }) + } + } + } + + /// The cheap counterpart of [`Self::field_payload`]: what the payload for + /// `id` *would* be, without materializing a single value. Capability + /// derivation runs on the UI thread for every descriptor lookup, so it must + /// never allocate an O(rows × cols) buffer just to learn that a grid is + /// regularly sampled. + /// + /// It must answer `Some` for exactly the ids [`Self::field_payload`] does, + /// and describe the same representation; + /// `cheap_representation_matches_the_materialized_payload` locks both in + /// for every dataset variant so a second, drifting source of capability + /// truth cannot reappear. + pub fn field_representation(&self, id: FieldId) -> Option { + match self { + Self::Nmr(nmr) => (nmr.field_catalog.id_for_key("nmr.real") == Some(id)) + .then_some(FieldRepresentation::Curve1D), + Self::Nmr2D(nmr) => nmr_field_representation(nmr, id), + Self::Table(table) => (table.field_catalog.id_for_key("table.default_series") + == Some(id)) + .then_some(FieldRepresentation::Curve1D), + Self::Electrophysiology(recording) => (0..recording.data.channels.len()) + .any(|index| { + recording + .field_key(index) + .and_then(|key| recording.field_catalog.id_for_key(key)) + == Some(id) + }) + .then_some(FieldRepresentation::Curve1D), + Self::Afm(afm) => { + if let Some(channel) = afm + .data + .images + .iter() + .zip(afm.image_field_keys.iter()) + .find_map(|(channel, key)| { + (afm.field_catalog.id_for_key(key) == Some(id)).then_some(channel) + }) + { + return Some(FieldRepresentation::ScalarGrid2D { + rows: channel.height, + cols: channel.width, + values: channel.raw.len(), + x_linear: spanned_axis_is_linear(0.0, channel.scan_size_x), + y_linear: spanned_axis_is_linear(0.0, channel.scan_size_y), + }); + } + (afm.field_catalog.id_for_key("afm.force_curve") == Some(id)) + .then_some(FieldRepresentation::Curve1D) + } + } + } + + /// Construct a worker-owned field snapshot from this dataset and a version + /// assigned by `ComputeService`. The version is intentionally supplied by + /// the runtime owner instead of being stored in the document. + /// + /// `cached_summary` is the runtime's summary for this exact + /// `(field, version)`; passing it skips a full min/max scan of the payload. + pub fn field_snapshot( + &self, + id: FieldId, + version: FieldVersion, + cached_summary: Option, + ) -> Option { + let payload = self.field_payload(id)?; + let field = FieldRef { + resource: self.resource_id(), + field: id, + }; + Some(FieldSnapshot::new( + VersionedFieldRef { field, version }, + payload, + self.field_provenance(id), + cached_summary, + )) + } + + fn field_provenance(&self, id: FieldId) -> FieldProvenance { + // Catalog provenance is populated at dataset construction/load and is + // serialized with the stable field identity map. A deterministic + // fallback keeps transient test fixtures that bypass constructors + // meaningful; ordinary project data is validated to contain every entry. + self.field_catalog() + .provenance_for(id) + .cloned() + .unwrap_or_else(|| { + let (source, algorithm) = match self { + Self::Nmr(dataset) => (dataset.data.source.as_str(), None), + Self::Nmr2D(dataset) => ( + dataset.data.source.as_str(), + Some(FieldAlgorithmProvenance { + algorithm: "process_2d".to_owned(), + version: 1, + }), + ), + Self::Table(dataset) => (dataset.name.as_deref().unwrap_or("table"), None), + Self::Electrophysiology(dataset) => (dataset.data.source.as_str(), None), + Self::Afm(dataset) => (dataset.data.source.as_str(), None), + }; + FieldCatalog::make_provenance(source, id, algorithm) + }) + } +} + +fn nmr_field_payload(dataset: &super::Nmr2DDataset, id: FieldId) -> Option { + match &dataset.processed { + plotx_processing::Processed2D::Ft(spectrum) => { + if dataset.field_catalog.id_for_key("nmr.real") == Some(id) { + return Some(FieldPayload::ScalarGrid2D(nmr_scalar_grid( + spectrum, + spectrum.real(), + ))); + } + if dataset.field_catalog.id_for_key("nmr.magnitude") == Some(id) { + return Some(FieldPayload::ScalarGrid2D(nmr_scalar_grid( + spectrum, + spectrum.magnitude(), + ))); + } + None + } + plotx_processing::Processed2D::Stack(stack) + if dataset.field_catalog.id_for_key("nmr.stack") == Some(id) => + { + let values = stack + .traces + .first() + .map(|trace| { + trace + .iter() + .map(|value| value.re as f32) + .collect::>() + }) + .unwrap_or_default(); + Some(FieldPayload::Curve1D(Curve1D { + x: Arc::from(stack.ppm.clone()), + values: Arc::from(values), + })) + } + plotx_processing::Processed2D::Stack(_) => None, + } +} + +fn nmr_field_representation( + dataset: &super::Nmr2DDataset, + id: FieldId, +) -> Option { + match &dataset.processed { + plotx_processing::Processed2D::Ft(spectrum) => { + (dataset.field_catalog.id_for_key("nmr.real") == Some(id) + || dataset.field_catalog.id_for_key("nmr.magnitude") == Some(id)) + .then(|| FieldRepresentation::ScalarGrid2D { + rows: spectrum.f1_size, + cols: spectrum.f2_size, + // `real()`/`magnitude()` map over `data`, so the buffer a payload + // would carry has exactly this length. + values: spectrum.data.len(), + x_linear: axis_is_linear(&spectrum.f2_ppm), + y_linear: axis_is_linear(&spectrum.f1_ppm), + }) + } + plotx_processing::Processed2D::Stack(_) + if dataset.field_catalog.id_for_key("nmr.stack") == Some(id) => + { + Some(FieldRepresentation::Curve1D) + } + plotx_processing::Processed2D::Stack(_) => None, + } +} + +pub(crate) fn nmr_scalar_grid( + spectrum: &plotx_processing::Spectrum2D, + values: Vec, +) -> ScalarGrid2D { + ScalarGrid2D { + values: Arc::from(values), + rows: spectrum.f1_size, + cols: spectrum.f2_size, + x: axis_sampling(&spectrum.f2_ppm), + y: axis_sampling(&spectrum.f1_ppm), + } +} + +fn axis_sampling(values: &[f64]) -> AxisSampling { + if axis_is_linear(values) { + let start = values.first().copied().unwrap_or(0.0); + let end = values.last().copied().unwrap_or(start); + AxisSampling::Linear { start, end } + } else { + AxisSampling::Explicit(Arc::from(values.to_vec())) + } +} + +fn linear_or_explicit_axis(start: f64, end: f64, count: usize) -> AxisSampling { + if spanned_axis_is_linear(start, end) { + AxisSampling::Linear { start, end } + } else { + AxisSampling::Explicit(Arc::from( + (0..count).map(|index| index as f64).collect::>(), + )) + } +} + +/// Shared by `linear_or_explicit_axis` and the cheap representation query so +/// the two can never disagree about an evenly spanned axis. +fn spanned_axis_is_linear(start: f64, end: f64) -> bool { + start.is_finite() && end.is_finite() +} + +fn axis_is_linear(values: &[f64]) -> bool { + let Some((&first, rest)) = values.split_first() else { + return false; + }; + if !first.is_finite() { + return false; + } + if rest.is_empty() { + return true; + } + let last = *rest.last().unwrap_or(&first); + if !last.is_finite() { + return false; + } + let step = (last - first) / (values.len() - 1) as f64; + step.is_finite() + && values.iter().enumerate().all(|(index, value)| { + let expected = first + step * index as f64; + value.is_finite() && (*value - expected).abs() <= 1e-9 * expected.abs().max(1.0) + }) +} diff --git a/crates/core/src/state/field_runtime.rs b/crates/core/src/state/field_runtime.rs new file mode 100644 index 0000000..3c4a0a4 --- /dev/null +++ b/crates/core/src/state/field_runtime.rs @@ -0,0 +1,647 @@ +//! Runtime field snapshots, versioned derived-data keys, and contour resolution. +//! +//! These types deliberately live below the document model. Field versions and +//! caches are session state: project persistence retains provenance and encoding +//! choices, never a stale runtime token or derived geometry. + +use super::{DatasetId, FieldCapabilities, FieldId, scalar_grid_capabilities}; +use crate::automation::{CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, CapabilityId}; +use plotx_figure::{ + ContourBasePolicy, ContourLevelSpec, ContourSpec, EstimatorSelection, PositiveFiniteF64, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +/// A finite floating-point value whose equality and hash use canonical IEEE +/// bits. `-0.0` is normalized to `0.0`, and NaN/infinity cannot enter a cache +/// key or field summary. +#[derive(Clone, Copy, Default)] +pub struct FiniteF64(u64); + +impl FiniteF64 { + pub fn new(value: f64) -> Option { + value + .is_finite() + .then(|| Self(if value == 0.0 { 0 } else { value.to_bits() })) + } + + pub const fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +impl fmt::Debug for FiniteF64 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FiniteF64") + .field(&self.get()) + .finish() + } +} + +impl PartialEq for FiniteF64 { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for FiniteF64 {} + +impl PartialOrd for FiniteF64 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for FiniteF64 { + fn cmp(&self, other: &Self) -> Ordering { + self.get().total_cmp(&other.get()) + } +} + +impl Hash for FiniteF64 { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +impl Serialize for FiniteF64 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_f64(self.get()) + } +} + +impl<'de> Deserialize<'de> for FiniteF64 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = f64::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| de::Error::custom("expected a finite floating-point value")) + } +} + +/// A reference to a field child resource. It is a data source, never a plot +/// component: contour properties remain addressed by the owning `SeriesId`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FieldRef { + pub resource: DatasetId, + pub field: FieldId, +} + +/// Runtime-only revision of immutable field data. It is deliberately separate +/// from persisted field identity and is not part of the project format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FieldVersion(pub u64); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VersionedFieldRef { + pub field: FieldRef, + pub version: FieldVersion, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FieldAlgorithmProvenance { + pub algorithm: String, + pub version: u32, +} + +/// Persistable provenance deliberately excludes the session-only field version. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct FieldProvenance { + pub source_fingerprint: Option, + pub algorithm: Option, + pub metadata: BTreeMap, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FieldSummary { + pub min: FiniteF64, + pub max: FiniteF64, +} + +/// A complete, owned snapshot suitable for a worker. Scalar summaries follow +/// scalar snapshots; colored rasters intentionally leave this as `None`. +#[derive(Clone, Debug)] +pub struct FieldSnapshot { + pub source: VersionedFieldRef, + pub payload: FieldPayload, + pub summary: Option, + pub provenance: FieldProvenance, +} + +impl FieldSnapshot { + /// `cached` is a summary the runtime already holds for exactly this + /// `(field, version)`; supplying it skips the full min/max scan. The scalar + /// invariant is enforced here rather than by callers: only a scalar grid + /// ever carries a summary, and a well-formed one always does. + pub fn new( + source: VersionedFieldRef, + payload: FieldPayload, + provenance: FieldProvenance, + cached: Option, + ) -> Self { + let summary = match payload.scalar_grid() { + Some(_) => cached.or_else(|| payload.summary()), + None => None, + }; + Self { + source, + payload, + summary, + provenance, + } + } +} + +/// Field payloads stay arity- and representation-specific. In particular, a +/// colored raster has no scalar statistics and cannot reach contour resolution. +#[derive(Clone, Debug)] +pub enum FieldPayload { + ScalarGrid2D(ScalarGrid2D), + Curve1D(Curve1D), + ColoredRaster2D(ColoredRaster2D), +} + +impl FieldPayload { + pub fn scalar_grid(&self) -> Option<&ScalarGrid2D> { + match self { + Self::ScalarGrid2D(grid) => Some(grid), + Self::Curve1D(_) | Self::ColoredRaster2D(_) => None, + } + } + + pub fn summary(&self) -> Option { + self.scalar_grid().and_then(ScalarGrid2D::summary) + } + + pub fn representation(&self) -> FieldRepresentation { + match self { + Self::ScalarGrid2D(grid) => grid.representation(), + Self::Curve1D(_) => FieldRepresentation::Curve1D, + Self::ColoredRaster2D(_) => FieldRepresentation::ColoredRaster2D, + } + } + + /// Capabilities implied by the concrete payload representation. Providers + /// may add semantic capabilities (signed, noise scale, units), but must not + /// claim a regular scalar grid for an explicitly sampled one. + /// + /// This delegates so that a materialized payload and the cheap + /// [`FieldRepresentation`] query a provider answers on the UI thread can + /// never disagree: there is one derivation, not two. + pub fn intrinsic_capabilities(&self) -> FieldCapabilities { + self.representation().intrinsic_capabilities() + } +} + +/// Everything capability derivation needs to know about a field, and nothing +/// that requires materializing its values. Providers answer this on the UI +/// thread on every descriptor lookup, so it must stay O(rows + cols) at worst. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldRepresentation { + ScalarGrid2D { + rows: usize, + cols: usize, + /// Length of the row-major buffer the provider would produce. Kept + /// separate from `rows * cols` so a malformed import is rejected here + /// rather than by indexing inside marching squares. + values: usize, + x_linear: bool, + y_linear: bool, + }, + Curve1D, + ColoredRaster2D, +} + +impl FieldRepresentation { + /// The single derivation of representation-implied capabilities. + pub fn intrinsic_capabilities(self) -> FieldCapabilities { + match self { + Self::ScalarGrid2D { .. } => scalar_grid_capabilities(self.is_regular(), &[]), + Self::Curve1D => FieldCapabilities::new([CapabilityId::new(CAP_FIELD_CURVE_1D)]), + Self::ColoredRaster2D => { + FieldCapabilities::new([CapabilityId::new(CAP_FIELD_COLORED_RASTER_2D)]) + } + } + } + + /// Marching squares accepts only a linearly sampled grid whose declared + /// shape matches its buffer. `AxisSampling::Explicit` is not regular. + pub fn is_regular(self) -> bool { + match self { + Self::ScalarGrid2D { + rows, + cols, + values, + x_linear, + y_linear, + } => { + rows >= 2 + && cols >= 2 + && rows + .checked_mul(cols) + .is_some_and(|expected| expected == values) + && x_linear + && y_linear + } + Self::Curve1D | Self::ColoredRaster2D => false, + } + } +} + +#[derive(Clone, Debug)] +pub struct ScalarGrid2D { + pub values: Arc<[f32]>, + pub rows: usize, + pub cols: usize, + pub x: AxisSampling, + pub y: AxisSampling, +} + +impl ScalarGrid2D { + /// A worker may only index a grid after this check. Provider adapters build + /// exact row-major buffers, but malformed imported dimensions must become a + /// recoverable field error rather than an indexing panic in marching squares. + pub fn has_valid_shape(&self) -> bool { + self.rows + .checked_mul(self.cols) + .is_some_and(|expected| expected == self.values.len()) + } + + pub fn representation(&self) -> FieldRepresentation { + FieldRepresentation::ScalarGrid2D { + rows: self.rows, + cols: self.cols, + values: self.values.len(), + x_linear: matches!(self.x, AxisSampling::Linear { .. }), + y_linear: matches!(self.y, AxisSampling::Linear { .. }), + } + } + + pub fn is_regular(&self) -> bool { + self.representation().is_regular() + } + + pub fn linear_bounds(&self) -> Option<[f64; 4]> { + let ( + AxisSampling::Linear { start: x0, end: x1 }, + AxisSampling::Linear { start: y0, end: y1 }, + ) = (&self.x, &self.y) + else { + return None; + }; + [*x0, *x1, *y0, *y1] + .iter() + .all(|value| value.is_finite()) + .then_some([*x0, *x1, *y0, *y1]) + } + + pub fn summary(&self) -> Option { + if !self.has_valid_shape() { + return None; + } + let mut values = self + .values + .iter() + .copied() + .filter(|value| value.is_finite()); + let first = f64::from(values.next()?); + let (min, max) = values.fold((first, first), |(min, max), value| { + let value = f64::from(value); + (min.min(value), max.max(value)) + }); + Some(FieldSummary { + min: FiniteF64::new(min)?, + max: FiniteF64::new(max)?, + }) + } +} + +#[derive(Clone, Debug)] +pub enum AxisSampling { + Linear { start: f64, end: f64 }, + Explicit(Arc<[f64]>), +} + +#[derive(Clone, Debug)] +pub struct Curve1D { + pub x: Arc<[f64]>, + pub values: Arc<[f32]>, +} + +#[derive(Clone, Debug)] +pub struct ColoredRaster2D { + pub pixels: Arc<[u8]>, + pub rows: usize, + pub cols: usize, + pub format: RasterFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RasterFormat { + Rgb8, + Rgba8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EstimateKind { + Noise, + Background, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct EstimateKey { + pub source: VersionedFieldRef, + pub kind: EstimateKind, + pub estimator: EstimatorSelection, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EstimateProvenance { + pub estimator: String, + pub version: u32, +} + +/// A scale an estimator successfully produced. +/// +/// A flat, constant or ideal synthetic field genuinely has no spread. That is a +/// valid, cacheable *result* — the estimator ran, and its provenance is real — +/// which is why it is spelled here rather than by widening +/// [`PositiveFiniteF64`]: that invariant guards renderer and cache keys and must +/// keep rejecting zero. An estimator that could not run at all never reaches +/// this type; it stays an error and reaches the user. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EstimatedScale { + Positive(PositiveFiniteF64), + /// The estimator ran and measured no spread whatsoever. + Degenerate, +} + +impl EstimatedScale { + /// Classify a raw estimator output. A finite zero is a degenerate result; + /// anything non-finite or negative means the estimator itself misbehaved, + /// so it must be reported rather than cached. + pub fn new(value: f64) -> Option { + if let Some(positive) = PositiveFiniteF64::new(value) { + return Some(Self::Positive(positive)); + } + (value.is_finite() && value == 0.0).then_some(Self::Degenerate) + } + + /// The measured magnitude; a degenerate estimate measures exactly zero. + pub const fn get(self) -> f64 { + match self { + Self::Positive(scale) => scale.get(), + Self::Degenerate => 0.0, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScaleEstimate { + pub scale: EstimatedScale, + pub provenance: EstimateProvenance, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LocationScaleEstimate { + pub location: FiniteF64, + pub scale: EstimatedScale, + pub provenance: EstimateProvenance, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum EstimateResult { + Scale(ScaleEstimate), + LocationScale(LocationScaleEstimate), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ResolvedContourLevels { + pub positive: Arc<[FiniteF64]>, + pub negative: Arc<[FiniteF64]>, +} + +impl ResolvedContourLevels { + pub fn empty() -> Self { + Self { + positive: Arc::from([]), + negative: Arc::from([]), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ContourGeometryCacheKey { + pub source: VersionedFieldRef, + pub levels: ResolvedContourLevels, +} + +pub type ContourSegment = [[f64; 2]; 2]; + +#[derive(Clone, Debug)] +pub struct ContourGeometry { + pub positive: Arc<[ContourSegment]>, + pub negative: Arc<[ContourSegment]>, + pub positive_levels: u16, + pub negative_levels: u16, +} + +impl ContourGeometry { + pub fn empty() -> Self { + Self { + positive: Arc::from([]), + negative: Arc::from([]), + positive_levels: 0, + negative_levels: 0, + } + } +} + +/// An explicit contour threshold the field never reaches, so its half draws +/// nothing at all. +/// +/// A mistyped magnitude — one extra zero — otherwise produces a blank plot with +/// no way to tell it apart from a field that simply has no signal. Resolution is +/// a pure function of its inputs and has no business writing status text, so it +/// reports the two numbers and lets the application layer word them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnreachableContourThreshold { + /// Which half fell out. Both values below are unsigned magnitudes, so for + /// the negative half `peak` is the magnitude of the most negative sample. + pub negative: bool, + /// The threshold the user set for this half. + pub threshold: FiniteF64, + /// This half's peak magnitude, always strictly below `threshold`. + pub peak: FiniteF64, +} + +/// Main-thread contour resolution outcome. Workers only ever receive the +/// `Ready` absolute levels, never a spec, summary, or estimate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContourResolution { + Ready { + levels: ResolvedContourLevels, + /// Explicit thresholds no level could be drawn from. Empty on every + /// ordinary resolution; a non-empty list means a half is deliberately + /// blank and the user must be told why. + unreachable: Vec, + }, + Pending(Vec), + Unavailable, +} + +pub fn resolve_contour_levels( + source: VersionedFieldRef, + spec: &ContourSpec, + summary: FieldSummary, + mut estimate: impl FnMut(&EstimateKey) -> Option, +) -> ContourResolution { + let mut pending = Vec::new(); + let mut unreachable = Vec::new(); + let positive = resolve_half( + source, + &spec.positive, + summary, + false, + &mut estimate, + &mut pending, + &mut unreachable, + ); + let negative = spec.negative.as_ref().map(|level| { + resolve_half( + source, + level, + summary, + true, + &mut estimate, + &mut pending, + &mut unreachable, + ) + }); + if !pending.is_empty() { + let mut unique = Vec::new(); + for key in pending { + if !unique.contains(&key) { + unique.push(key); + } + } + return ContourResolution::Pending(unique); + } + let (Some(positive), Some(negative)) = (positive, negative.unwrap_or(Some(Vec::new()))) else { + return ContourResolution::Unavailable; + }; + ContourResolution::Ready { + levels: ResolvedContourLevels { + positive: Arc::from(positive), + negative: Arc::from(negative), + }, + unreachable, + } +} + +/// Resolve one half. Pure: it reads only its arguments and the caller's +/// `estimate` lookup, and reports both an unmet estimate and an unreachable +/// threshold by appending to caller-owned buffers rather than touching session +/// state, which the caller owns and knows how to word. +fn resolve_half( + source: VersionedFieldRef, + level: &ContourLevelSpec, + summary: FieldSummary, + negative: bool, + estimate: &mut impl FnMut(&EstimateKey) -> Option, + pending: &mut Vec, + unreachable: &mut Vec, +) -> Option> { + let min = summary.min.get(); + let max = summary.max.get(); + let peak = if negative { + -min.min(0.0) + } else { + max.max(0.0) + }; + if peak <= 0.0 { + return Some(Vec::new()); + } + let base = match &level.base { + ContourBasePolicy::Absolute(value) => value.get(), + ContourBasePolicy::FractionOfRange(fraction) => min + fraction.get() * (max - min), + ContourBasePolicy::NoiseSigma { + multiplier, + estimator, + } => { + let key = EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: estimator.clone(), + }; + let Some(EstimateResult::Scale(result)) = estimate(&key) else { + pending.push(key); + return None; + }; + result.scale.get() * multiplier.get() + } + ContourBasePolicy::BackgroundScale { + multiplier, + estimator, + } => { + let key = EstimateKey { + source, + kind: EstimateKind::Background, + estimator: estimator.clone(), + }; + let Some(EstimateResult::LocationScale(result)) = estimate(&key) else { + pending.push(key); + return None; + }; + // Background fields carry a location as well as a spread. The + // contour policy expresses the physical level `location + k*scale`; + // a contour half later supplies its sign. + (result.location.get() + multiplier.get() * result.scale.get()).abs() + } + }; + // The ladder — including which policies may be rewritten when their base is + // unusable — is shared with the analysis-map path and speaks only in + // positive magnitudes; this half applies its own sign afterwards. Deciding + // there and reporting here keeps one policy: a half is blank for exactly the + // reason the ladder says it is. + let ladder = crate::contour_ladder::contour_level_ladder(base, peak, level); + if let Some(threshold) = ladder.threshold_above_peak + && let Some(threshold) = FiniteF64::new(threshold) + && let Some(peak) = FiniteF64::new(peak) + { + unreachable.push(UnreachableContourThreshold { + negative, + threshold, + peak, + }); + } + Some( + ladder + .levels + .into_iter() + .map(|value| if negative { -value } else { value }) + .filter_map(FiniteF64::new) + .collect(), + ) +} + +#[cfg(test)] +#[path = "field_runtime_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "field_runtime_degenerate_tests.rs"] +mod degenerate_tests; + +#[cfg(test)] +#[path = "field_runtime_threshold_tests.rs"] +mod threshold_tests; diff --git a/crates/core/src/state/field_runtime_degenerate_tests.rs b/crates/core/src/state/field_runtime_degenerate_tests.rs new file mode 100644 index 0000000..225bcf0 --- /dev/null +++ b/crates/core/src/state/field_runtime_degenerate_tests.rs @@ -0,0 +1,135 @@ +//! Degenerate estimate results versus genuinely unavailable estimators. +//! +//! A flat field has no noise to measure, and saying so is a *result*: it is +//! cached, keeps its provenance, and still draws contours. An estimator that +//! cannot run at all is an error: it reaches the status line and is never +//! cached, so the two can never be confused for one another. + +use super::tests::{grid_dataset, wait_for_app_compute}; +use crate::contour_probe; +use crate::state::{ChartSpec, DataBinding, DataDomain, PlotxApp, StackSpec}; +use plotx_figure::{ + ContourBasePolicy, ContourSpec, EstimatorSelection, PositiveFiniteF64, SeriesEncoding, +}; + +/// A tilted plane: every first difference is identical, so the robust MAD of +/// those differences is exactly zero — the same degenerate case an ideal +/// noiseless synthetic grid produces — while the field itself still spans a +/// range that marching squares can cut. +fn planar_values() -> Vec { + (0..4u8) + .flat_map(|row| (0..4u8).map(move |col| 1.0 + f32::from(row) + f32::from(col))) + .collect() +} + +fn contour_binding(app: &PlotxApp) -> DataBinding { + let binding = DataBinding::single(&app.doc.datasets[0]); + let SeriesEncoding::Contour(contour) = &binding.series[0].encoding else { + panic!("a true-2D NMR field defaults to a contour encoding"); + }; + assert!( + matches!(contour.positive.base, ContourBasePolicy::NoiseSigma { .. }), + "a noise-scale field defaults to a NoiseSigma base" + ); + binding +} + +fn rebuild(app: &mut PlotxApp, binding: &DataBinding) -> plotx_figure::Figure { + app.build_binding_figure( + binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ) +} + +#[test] +fn a_degenerate_scale_estimate_draws_contours_and_is_never_requeued() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(grid_dataset("flat", &planar_values())); + let binding = contour_binding(&app); + + contour_probe::reset(); + let pending = rebuild(&mut app, &binding); + assert!(pending.contours.is_empty(), "the estimate is still pending"); + assert_eq!(contour_probe::queued_estimates(), 1); + + // The estimate settles, then the geometry it unblocked. + wait_for_app_compute(&mut app); + let resolved = rebuild(&mut app, &binding); + assert_eq!( + contour_probe::queued_estimates(), + 1, + "a zero scale is a cached result, so the resolver never re-queues it" + ); + assert!(resolved.contours.is_empty(), "geometry is still pending"); + wait_for_app_compute(&mut app); + + let drawn = rebuild(&mut app, &binding); + assert!( + !drawn.contours.is_empty(), + "a field with no measurable noise still gets a visible ladder \ + instead of a permanently blank plot" + ); + assert_eq!( + contour_probe::queued_estimates(), + 1, + "repeated rebuilds must not re-queue an estimate that already answered" + ); + assert!( + !app.session.status.contains("Field estimate could not"), + "a degenerate measurement is not an error: {}", + app.session.status + ); +} + +#[test] +fn an_unavailable_estimator_reports_an_error_and_is_not_cached_as_degenerate() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(grid_dataset("flat", &planar_values())); + let mut binding = contour_binding(&app); + let SeriesEncoding::Contour(contour) = &mut binding.series[0].encoding else { + unreachable!("checked while building the binding"); + }; + *contour = ContourSpec { + positive: plotx_figure::ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + estimator: EstimatorSelection::Frozen { + estimator: "retired_estimator".to_owned(), + version: 99, + }, + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: contour.style.clone(), + }; + + contour_probe::reset(); + let figure = rebuild(&mut app, &binding); + assert!(figure.contours.is_empty()); + assert_eq!(contour_probe::queued_estimates(), 1); + + wait_for_app_compute(&mut app); + assert!( + app.session + .status + .contains("Field estimate could not be computed"), + "a frozen estimator that no longer exists must reach the user: {}", + app.session.status + ); + + let still_blank = rebuild(&mut app, &binding); + assert!(still_blank.contours.is_empty()); + assert_eq!( + contour_probe::queued_estimates(), + 2, + "an unavailable estimator produced no cacheable result, degenerate or otherwise" + ); +} diff --git a/crates/core/src/state/field_runtime_tests.rs b/crates/core/src/state/field_runtime_tests.rs new file mode 100644 index 0000000..b2381c8 --- /dev/null +++ b/crates/core/src/state/field_runtime_tests.rs @@ -0,0 +1,751 @@ +use super::*; +use crate::state::{ComputeService, DataBinding, Dataset, Nmr2DDataset, PlotxApp}; +use num_complex::Complex64; +use plotx_figure::{ + Color, ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, + EstimatorSelection, PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, +}; +use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; +use plotx_processing::Processed2D; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +pub(super) fn finite(value: f64) -> FiniteF64 { + FiniteF64::new(value).expect("test literals are finite") +} + +pub(super) fn source(resource: u128, field: u64, version: u64) -> VersionedFieldRef { + VersionedFieldRef { + field: FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(resource)), + field: FieldId::new(field), + }, + version: FieldVersion(version), + } +} + +fn levels(positive: &[f64], negative: &[f64]) -> ResolvedContourLevels { + ResolvedContourLevels { + positive: Arc::from(positive.iter().copied().map(finite).collect::>()), + negative: Arc::from(negative.iter().copied().map(finite).collect::>()), + } +} + +/// A signed field whose peak magnitude is exactly 10 in both directions. +pub(super) fn summary() -> FieldSummary { + FieldSummary { + min: finite(-10.0), + max: finite(10.0), + } +} + +fn noisy_grid() -> Arc { + Arc::new(ScalarGrid2D { + values: Arc::from(vec![ + -4.0, -1.0, 3.0, 1.0, 2.0, -3.0, 4.0, -2.0, 1.0, 5.0, -5.0, 3.0, -2.0, 4.0, 0.0, 6.0, + ]), + rows: 4, + cols: 4, + x: AxisSampling::Linear { + start: 0.0, + end: 3.0, + }, + y: AxisSampling::Linear { + start: 0.0, + end: 3.0, + }, + }) +} + +fn signed_dataset(label: &str) -> Dataset { + grid_dataset(label, &noisy_grid().values) +} + +/// A 4×4 true-2D NMR dataset whose real plane is exactly `values`. +pub(super) fn grid_dataset(label: &str, values: &[f32]) -> Dataset { + let dimension = |nucleus: &str| Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + let values = values + .iter() + .copied() + .map(|value| Complex64::new(f64::from(value), 0.0)) + .collect(); + Dataset::Nmr2D(Box::new(Nmr2DDataset::load(NmrData2D { + data: values, + rows: 4, + cols: 4, + domain: Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: label.to_owned(), + }))) +} + +fn absolute_signed_contour() -> ContourSpec { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(1.0).unwrap()), + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }; + ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + } +} + +pub(super) fn wait_for_app_compute(app: &mut PlotxApp) { + let deadline = Instant::now() + Duration::from_secs(2); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + assert!( + !app.compute_busy(), + "field job did not settle before deadline" + ); +} + +fn settle_estimates(service: &mut ComputeService) { + let deadline = Instant::now() + Duration::from_secs(2); + while service.is_busy() && Instant::now() < deadline { + for done in service.try_drain() { + match done { + crate::state::Done::EstimateField { key, result } => { + let current = service.current_field_version(key.source.field); + assert!(service.finish_estimate(key, result, current)); + } + crate::state::Done::EstimateFieldFailed { message, .. } => { + panic!("estimate unexpectedly failed: {message}"); + } + crate::state::Done::BuildContour { .. } + | crate::state::Done::BuildContourFailed { .. } + | crate::state::Done::Ilt { .. } + | crate::state::Done::Dosy { .. } + | crate::state::Done::Processing2D { .. } + | crate::state::Done::Cancelled { .. } + | crate::state::Done::Failed { .. } => { + panic!("unexpected non-estimate job while settling estimates"); + } + } + } + thread::sleep(Duration::from_millis(5)); + } + assert!( + !service.is_busy(), + "estimate job did not settle before deadline" + ); +} + +#[test] +fn finite_key_values_reject_invalid_numbers_and_normalize_negative_zero() { + assert!(FiniteF64::new(f64::NAN).is_none()); + assert!(FiniteF64::new(f64::INFINITY).is_none()); + assert_eq!(finite(-0.0), finite(0.0)); + + let negative_zero = ContourGeometryCacheKey { + source: source(1, 0, 1), + levels: levels(&[-0.0], &[]), + }; + let positive_zero = ContourGeometryCacheKey { + source: source(1, 0, 1), + levels: levels(&[0.0], &[]), + }; + assert_eq!(negative_zero, positive_zero); +} + +#[test] +fn loading_immutable_fields_allocates_runtime_versions_before_rendering() { + let dataset = signed_dataset("loaded-version"); + let fields = dataset.field_descriptors(); + let real = fields + .iter() + .find(|field| field.local_id == "nmr.real") + .expect("fixture has a real scalar field") + .id; + let magnitude = fields + .iter() + .find(|field| field.local_id == "nmr.magnitude") + .expect("fixture has a magnitude scalar field") + .id; + let mut service = ComputeService::new(); + + service.register_loaded_dataset_fields(&dataset).unwrap(); + + let real_ref = FieldRef { + resource: dataset.resource_id(), + field: real, + }; + let magnitude_ref = FieldRef { + resource: dataset.resource_id(), + field: magnitude, + }; + let real_version = service + .current_field_version(real_ref) + .expect("loading allocated a real-field version"); + let magnitude_version = service + .current_field_version(magnitude_ref) + .expect("loading allocated a magnitude-field version"); + assert_ne!(real_version, magnitude_version); + assert_eq!( + service.field_version_for(real_ref).unwrap(), + real_version, + "a later render reuses the import/load version instead of bumping it" + ); + let cached = service.cached_field_summary(VersionedFieldRef { + field: real_ref, + version: real_version, + }); + assert!( + cached.is_some(), + "scalar fields receive their cheap summary at the load boundary" + ); + let snapshot = dataset + .field_snapshot(real, real_version, cached) + .expect("loaded scalar field has an owned snapshot"); + assert_eq!( + snapshot.summary, cached, + "a later snapshot reuses the cached summary instead of rescanning" + ); + assert!(snapshot.provenance.source_fingerprint.is_some()); + assert_eq!( + snapshot.provenance.algorithm, + Some(FieldAlgorithmProvenance { + algorithm: "process_2d".to_owned(), + version: 1, + }), + "persisted provenance is separate from the runtime field version" + ); +} + +#[test] +fn a_cached_summary_replaces_the_snapshot_scan_and_never_reaches_a_raster() { + let dataset = signed_dataset("cached-summary"); + let field = dataset + .default_field_id() + .expect("the fixture exposes a scalar field"); + let source = VersionedFieldRef { + field: FieldRef { + resource: dataset.resource_id(), + field, + }, + version: FieldVersion(1), + }; + // A value the min/max scan could not possibly produce for this grid: if the + // snapshot carries it, the scan was skipped rather than run and discarded. + let sentinel = FieldSummary { + min: finite(-4321.0), + max: finite(8765.0), + }; + + let cached = dataset + .field_snapshot(field, source.version, Some(sentinel)) + .expect("scalar field has a snapshot"); + assert_eq!(cached.summary, Some(sentinel)); + + let scanned = dataset + .field_snapshot(field, source.version, None) + .expect("scalar field has a snapshot"); + assert_ne!(scanned.summary, Some(sentinel)); + assert!( + scanned.summary.is_some(), + "a scalar snapshot always has one" + ); + + // The invariant holds in the other direction too: a colored raster never + // gains scalar statistics, however insistently a caller offers them. + let raster = FieldSnapshot::new( + source, + FieldPayload::ColoredRaster2D(ColoredRaster2D { + pixels: Arc::from(vec![255, 0, 0]), + rows: 1, + cols: 1, + format: RasterFormat::Rgb8, + }), + FieldProvenance::default(), + Some(sentinel), + ); + assert!(raster.summary.is_none()); +} + +#[test] +fn equivalent_absolute_levels_share_the_same_geometry_key() { + let field = source(2, 7, 11); + let noise_spec = ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: ContourStyle::default(), + }; + let absolute_spec = ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(5.0).unwrap()), + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: ContourStyle { + positive_color: ColorSource::Explicit(Color::rgb(0x11, 0x22, 0x33)), + ..ContourStyle::default() + }, + }; + let estimate = EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::Positive(PositiveFiniteF64::new(1.0).unwrap()), + provenance: EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }); + let ContourResolution::Ready { + levels: noise_levels, + .. + } = resolve_contour_levels(field, &noise_spec, summary(), |_| Some(estimate.clone())) + else { + panic!("noise estimate should resolve a concrete level ladder"); + }; + let ContourResolution::Ready { + levels: absolute_levels, + .. + } = resolve_contour_levels(field, &absolute_spec, summary(), |_| None) + else { + panic!("absolute levels do not need an estimate"); + }; + assert_eq!(noise_levels, absolute_levels); + assert_eq!( + ContourGeometryCacheKey { + source: field, + levels: noise_levels, + }, + ContourGeometryCacheKey { + source: field, + levels: absolute_levels, + }, + "policy, estimate provenance, ratio source, and style are not geometry inputs" + ); +} + +#[test] +fn out_of_range_absolute_levels_are_truncated_not_rewritten() { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(20.0).unwrap()), + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }; + let spec = ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + }; + let ContourResolution::Ready { levels, .. } = + resolve_contour_levels(source(9, 3, 1), &spec, summary(), |_| None) + else { + panic!("an absolute contour needs no estimate"); + }; + // An explicit threshold is the strongest term of the value-resolution order. + // A field whose peak is 10 draws nothing at 20; rewriting it to a computed + // base would put contours at levels the user never asked for. + assert!(levels.positive.is_empty()); + assert!(levels.negative.is_empty()); +} + +#[test] +fn style_edits_reuse_cached_geometry_without_sync_marching_squares() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(signed_dataset("style-cache")); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + binding.series[0].encoding = SeriesEncoding::Contour(absolute_signed_contour()); + + crate::contour_probe::reset(); + let first = app.build_binding_figure( + &binding, + &crate::state::ChartSpec::default_for(crate::state::DataDomain::Nmr2d), + &crate::state::StackSpec::default(), + [120.0, 80.0], + ); + assert!(first.contours.is_empty()); + assert_eq!(crate::contour_probe::queued_contour_builds(), 1); + assert_eq!(crate::contour_probe::marching_squares_on_this_thread(), 0); + + wait_for_app_compute(&mut app); + let materialized = crate::contour_probe::field_payload_materializations(); + let cached = app.build_binding_figure( + &binding, + &crate::state::ChartSpec::default_for(crate::state::DataDomain::Nmr2d), + &crate::state::StackSpec::default(), + [120.0, 80.0], + ); + assert!(!cached.contours.is_empty()); + assert_eq!( + crate::contour_probe::field_payload_materializations(), + materialized, + "a warm geometry cache must not allocate an O(rows x cols) buffer: \ + capabilities come from the cheap representation query and the summary \ + from the runtime cache" + ); + + let changed_negative = Color::rgb(0x2a, 0xa1, 0x55); + let mut styled = binding.clone(); + let SeriesEncoding::Contour(contour) = &mut styled.series[0].encoding else { + panic!("test binding has a contour encoding"); + }; + contour.style.negative_color = ColorSource::Explicit(changed_negative); + contour.style.width = PositiveFiniteF32::new(2.0).unwrap(); + let restyled = app.build_binding_figure( + &styled, + &crate::state::ChartSpec::default_for(crate::state::DataDomain::Nmr2d), + &crate::state::StackSpec::default(), + [120.0, 80.0], + ); + assert_eq!(crate::contour_probe::queued_contour_builds(), 1); + assert_eq!(crate::contour_probe::marching_squares_on_this_thread(), 0); + assert_eq!( + crate::contour_probe::field_payload_materializations(), + materialized, + "a style-only edit re-resolves the same key and stays on the warm path" + ); + assert!(restyled.contours.iter().all(|contour| contour.width == 2.0)); + assert!( + restyled + .contours + .iter() + .any(|contour| contour.color == changed_negative), + "negative colour is applied only while assembling the Figure" + ); +} + +#[test] +fn new_field_versions_naturally_miss_old_geometry_and_reject_stale_done() { + let mut service = ComputeService::new(); + let field = FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(3)), + field: FieldId::new(4), + }; + let first_version = service.field_version_for(field).unwrap(); + let first_source = VersionedFieldRef { + field, + version: first_version, + }; + service.promote_field_version(first_source, Some(summary())); + let first_key = ContourGeometryCacheKey { + source: first_source, + levels: levels(&[1.0, 1.5], &[-1.0, -1.5]), + }; + assert!(service.finish_contour( + first_key.clone(), + ContourGeometry::empty(), + service.current_field_version(field), + )); + assert!(service.geometry_for(&first_key).is_some()); + + let second_source = VersionedFieldRef { + field, + version: service.reserve_field_version().unwrap(), + }; + service.promote_field_version(second_source, Some(summary())); + let second_key = ContourGeometryCacheKey { + source: second_source, + levels: first_key.levels.clone(), + }; + assert!(service.geometry_for(&second_key).is_none()); + let current = service.current_field_version(field); + assert!( + !service.finish_contour(first_key, ContourGeometry::empty(), current), + "a late BuildContour Done cannot overwrite the newer field version" + ); +} + +#[test] +fn estimates_are_demand_driven_and_coexist_per_estimator_selection() { + let mut service = ComputeService::new(); + let field = FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(4)), + field: FieldId::new(0), + }; + let source = VersionedFieldRef { + field, + version: service.field_version_for(field).unwrap(), + }; + let frozen_noise = EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }; + let background = EstimateKey { + source, + kind: EstimateKind::Background, + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), + version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, + }, + }; + let noise_only = ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + estimator: frozen_noise.estimator.clone(), + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: ContourStyle::default(), + }; + assert_eq!( + resolve_contour_levels(source, &noise_only, summary(), |_| None), + ContourResolution::Pending(vec![frozen_noise.clone()]), + "a NoiseSigma contour does not request an unrelated background fit" + ); + let background_only = ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::BackgroundScale { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + estimator: background.estimator.clone(), + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: ContourStyle::default(), + }; + assert_eq!( + resolve_contour_levels(source, &background_only, summary(), |_| None), + ContourResolution::Pending(vec![background.clone()]), + "the expensive background fit is requested only by BackgroundScale" + ); + assert!( + service + .enqueue_estimate(frozen_noise.clone(), noisy_grid()) + .unwrap() + ); + settle_estimates(&mut service); + let Some(EstimateResult::Scale(noise)) = service.estimate_for(&frozen_noise) else { + panic!("expected the frozen noise estimate"); + }; + assert_eq!( + noise.provenance, + EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + } + ); + assert!( + service.estimate_for(&background).is_none(), + "a NoiseSigma contour never schedules an unrelated background fit" + ); + + let latest_noise = EstimateKey { + source, + kind: EstimateKind::Noise, + estimator: EstimatorSelection::FollowLatest, + }; + assert!( + service + .enqueue_estimate(latest_noise.clone(), noisy_grid()) + .unwrap() + ); + settle_estimates(&mut service); + assert!(service.estimate_for(&frozen_noise).is_some()); + assert!(service.estimate_for(&latest_noise).is_some()); + + assert!( + service + .enqueue_estimate(background.clone(), noisy_grid()) + .unwrap() + ); + settle_estimates(&mut service); + let Some(EstimateResult::LocationScale(background_result)) = service.estimate_for(&background) + else { + panic!("expected the background location/scale estimate"); + }; + assert_eq!( + background_result.provenance, + EstimateProvenance { + estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), + version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, + } + ); +} + +#[test] +fn descriptor_capabilities_follow_actual_sampling_not_a_domain_heuristic() { + let mut dataset = signed_dataset("explicit-axis"); + let Dataset::Nmr2D(nmr) = &mut dataset else { + panic!("test dataset is NMR 2D"); + }; + let Processed2D::Ft(spectrum) = &mut nmr.processed else { + panic!("frequency-domain input produces a scalar grid"); + }; + Arc::make_mut(spectrum).f1_ppm[2] += 0.25; + + let real = dataset + .field_descriptors() + .into_iter() + .find(|field| field.local_id == "nmr.real") + .unwrap(); + assert!( + !real + .capabilities + .contains(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + assert!( + !dataset.supports_encoding(real.id, &SeriesEncoding::Contour(absolute_signed_contour())) + ); + assert!(matches!( + dataset.field_payload(real.id), + Some(FieldPayload::ScalarGrid2D(ScalarGrid2D { + y: AxisSampling::Explicit(_), + .. + })) + )); +} + +#[test] +fn field_identity_survives_reorder_and_deleted_sources_drop_worker_results() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(signed_dataset("first")); + app.doc.datasets.push(signed_dataset("second")); + let field = app.doc.datasets[0].default_field_id().unwrap(); + let resource = app.doc.datasets[0].resource_id(); + let source = FieldRef { resource, field }; + let version = app.session.compute.field_version_for(source).unwrap(); + let snapshot = app.doc.datasets[0] + .field_snapshot(field, version, None) + .unwrap(); + let grid = Arc::new(snapshot.payload.scalar_grid().unwrap().clone()); + let key = ContourGeometryCacheKey { + source: snapshot.source, + levels: levels(&[1.0], &[]), + }; + assert!( + app.session + .compute + .enqueue_contour(key.clone(), grid) + .unwrap() + ); + + app.doc.datasets.swap(0, 1); + assert_eq!(app.doc.dataset_index(resource), Some(1)); + let other_field = FieldRef { + resource, + field: FieldId::new(field.get() + 1), + }; + assert_ne!( + ContourGeometryCacheKey { + source: VersionedFieldRef { + field: source, + version, + }, + levels: key.levels.clone(), + }, + ContourGeometryCacheKey { + source: VersionedFieldRef { + field: other_field, + version, + }, + levels: key.levels.clone(), + } + ); + + app.doc + .datasets + .retain(|dataset| dataset.resource_id() != resource); + wait_for_app_compute(&mut app); + assert!( + app.session.compute.geometry_for(&key).is_none(), + "a result whose dataset has been deleted is discarded instead of cached" + ); +} + +#[test] +fn colored_rasters_have_import_versions_but_no_scalar_summary_or_contour_path() { + let mut service = ComputeService::new(); + let field = FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(5)), + field: FieldId::new(9), + }; + let snapshot = service + .register_imported_field( + field, + FieldPayload::ColoredRaster2D(ColoredRaster2D { + pixels: Arc::from(vec![255, 0, 0, 0, 255, 0]), + rows: 1, + cols: 2, + format: RasterFormat::Rgb8, + }), + FieldProvenance { + source_fingerprint: Some("import-fingerprint".to_owned()), + algorithm: None, + metadata: Default::default(), + }, + ) + .expect("a pipeline-free raster receives a runtime version at import"); + assert_eq!( + service.current_field_version(field), + Some(snapshot.source.version) + ); + assert!(snapshot.summary.is_none()); + assert!(snapshot.payload.scalar_grid().is_none()); + let capabilities = snapshot.payload.intrinsic_capabilities(); + assert!(capabilities.contains(crate::automation::CAP_FIELD_COLORED_RASTER_2D)); + assert!(!capabilities.contains(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR)); + assert!( + serde_json::to_value(&snapshot.provenance) + .unwrap() + .get("source_fingerprint") + .is_some(), + "persistable provenance deliberately carries no FieldVersion" + ); +} + +#[test] +fn imported_grayscale_fields_keep_scalar_summary_and_regular_capability() { + let mut service = ComputeService::new(); + let field = FieldRef { + resource: DatasetId::from_uuid(uuid::Uuid::from_u128(6)), + field: FieldId::new(1), + }; + let snapshot = service + .register_imported_field( + field, + FieldPayload::ScalarGrid2D((*noisy_grid()).clone()), + FieldProvenance { + source_fingerprint: Some("gray-import-fingerprint".to_owned()), + algorithm: None, + metadata: Default::default(), + }, + ) + .expect("a grayscale import receives a runtime version"); + assert!(snapshot.summary.is_some()); + assert!(snapshot.payload.scalar_grid().is_some()); + assert!( + snapshot + .payload + .intrinsic_capabilities() + .contains(crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); +} diff --git a/crates/core/src/state/field_runtime_threshold_tests.rs b/crates/core/src/state/field_runtime_threshold_tests.rs new file mode 100644 index 0000000..841f4ee --- /dev/null +++ b/crates/core/src/state/field_runtime_threshold_tests.rs @@ -0,0 +1,184 @@ +//! Explicit contour thresholds the field never reaches. +//! +//! An `Absolute` base is the user's own input, so it is obeyed literally: a +//! threshold above the peak draws nothing rather than being rewritten into a +//! ladder nobody asked for. Drawing nothing *silently* is the failure this +//! module exists to prevent — the resolution reports the threshold and the peak +//! together, so a mistyped magnitude is legible instead of looking like a bug. + +use super::tests::{finite, grid_dataset, source, summary}; +use crate::state::{ + ChartSpec, ContourResolution, DataBinding, DataDomain, EstimateProvenance, EstimateResult, + EstimatedScale, FieldSummary, FieldVersion, PlotxApp, ScaleEstimate, StackSpec, + resolve_contour_levels, +}; +use plotx_figure::{ + ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, EstimatorSelection, + PositiveFiniteF64, SeriesEncoding, +}; + +fn absolute_spec(threshold: f64, signed: bool) -> ContourSpec { + let level = ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(threshold).unwrap()), + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }; + ContourSpec { + positive: level.clone(), + negative: signed.then_some(level), + style: ContourStyle::default(), + } +} + +fn ready(spec: &ContourSpec, summary: FieldSummary) -> ContourResolution { + resolve_contour_levels(source(31, 1, 1), spec, summary, |_| None) +} + +#[test] +fn an_unreachable_absolute_threshold_reports_the_threshold_and_the_peak() { + let ContourResolution::Ready { + levels, + unreachable, + } = ready(&absolute_spec(20.0, true), summary()) + else { + panic!("an absolute contour needs no estimate"); + }; + assert!(levels.positive.is_empty()); + assert!(levels.negative.is_empty()); + + // Both halves of a signed field are covered, and each carries the pair of + // numbers that makes an extra typed zero visible. + assert_eq!(unreachable.len(), 2, "{unreachable:?}"); + let positive = unreachable + .iter() + .find(|report| !report.negative) + .expect("the positive half is unreachable"); + assert_eq!(positive.threshold, finite(20.0)); + assert_eq!(positive.peak, finite(10.0)); + let negative = unreachable + .iter() + .find(|report| report.negative) + .expect("the negative half is unreachable"); + // The negative half compares magnitudes: the field bottoms out at -10. + assert_eq!(negative.threshold, finite(20.0)); + assert_eq!(negative.peak, finite(10.0)); +} + +#[test] +fn a_reachable_absolute_threshold_reports_nothing() { + let ContourResolution::Ready { + levels, + unreachable, + } = ready(&absolute_spec(2.0, true), summary()) + else { + panic!("an absolute contour needs no estimate"); + }; + assert!(!levels.positive.is_empty()); + assert!(!levels.negative.is_empty()); + assert!(unreachable.is_empty(), "{unreachable:?}"); +} + +#[test] +fn a_half_with_no_signal_of_its_sign_is_not_a_threshold_problem() { + // An all-positive field has no negative lobe at all. That is the shape of + // the data, not a mistyped threshold, so the negative half stays empty and + // silent. + let all_positive = FieldSummary { + min: finite(1.0), + max: finite(10.0), + }; + let ContourResolution::Ready { + levels, + unreachable, + } = ready(&absolute_spec(2.0, true), all_positive) + else { + panic!("an absolute contour needs no estimate"); + }; + assert!(!levels.positive.is_empty()); + assert!(levels.negative.is_empty()); + assert!(unreachable.is_empty(), "{unreachable:?}"); +} + +#[test] +fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { + // A degenerate (zero) noise estimate is not something the user typed, so it + // still falls back to the ladder the spec selected — and that fallback is a + // successful resolution, never a threshold report. + let spec = ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::NoiseSigma { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + estimator: EstimatorSelection::FollowLatest, + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }, + negative: None, + style: ContourStyle::default(), + }; + let ContourResolution::Ready { + levels, + unreachable, + } = resolve_contour_levels(source(32, 1, 1), &spec, summary(), |_| { + Some(EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::Degenerate, + provenance: EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + })) + }) + else { + panic!("the estimate is supplied, so resolution is not pending"); + }; + assert_eq!(levels.positive.len(), 3); + assert!((levels.positive[0].get() - 10.0 / 1.5f64.powi(2)).abs() < 1e-9); + assert!( + unreachable.is_empty(), + "a policy that recovered drew levels; there is nothing to explain: {unreachable:?}" + ); +} + +/// A 4×4 plane whose real values run 0..=10 and never go negative: the positive +/// peak is exactly 10, and there is no negative lobe to report on. +fn peaked_values() -> Vec { + let mut values = vec![0.0f32; 16]; + values[5] = 10.0; + values[6] = 4.0; + values[9] = 6.0; + values +} + +#[test] +fn a_mistyped_threshold_reaches_the_status_line_with_both_numbers() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc + .datasets + .push(grid_dataset("typo", &peaked_values())); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + let field = binding.series[0].source.field; + let peak = app.doc.datasets[0] + .field_snapshot(field, FieldVersion(1), None) + .and_then(|snapshot| snapshot.summary) + .expect("a scalar field carries a summary"); + assert_eq!(peak.max.get(), 10.0, "fixture's positive peak"); + assert_eq!(peak.min.get(), 0.0, "fixture has no negative lobe"); + + // 2.0 typed with one zero too many. + binding.series[0].encoding = SeriesEncoding::Contour(absolute_spec(20.0, true)); + let figure = app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ); + + assert!(figure.contours.is_empty()); + assert_eq!( + app.session.status, + "The positive contour threshold 20 is above this field's positive peak 10, \ + so no positive contours are drawn. Lower the threshold below 10.", + "the threshold and the peak must sit side by side, and only the half \ + that actually has signal is reported" + ); +} diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs index c0e0110..9ee12f6 100644 --- a/crates/core/src/state/field_tests.rs +++ b/crates/core/src/state/field_tests.rs @@ -1,5 +1,223 @@ use super::*; use crate::state::{AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset}; +use std::sync::Arc; + +/// Every field of every dataset variant must derive the same capabilities from +/// the cheap `field_representation` query as from a fully materialized payload. +/// +/// This is the guard against the debt this design exists to remove: a second, +/// cheaper-but-separate capability criterion that silently drifts from the one +/// the workers actually see. If a provider gains a field, it must answer both +/// queries or this fails. +fn assert_representation_matches_payload(dataset: &Dataset, label: &str) { + let mut ids = dataset + .field_descriptors() + .iter() + .map(|field| field.id) + .collect::>(); + assert!(!ids.is_empty(), "{label} exposes no field to compare"); + // Allocated-but-inactive ids (`nmr.stack` on a true-2D dataset, say) must + // agree as well: a cheap query that answered for a field the payload + // refuses would advertise a capability no worker can ever satisfy. + ids.extend((0..6).map(FieldId::new)); + ids.sort_unstable(); + ids.dedup(); + + for id in ids { + let cheap = dataset.field_representation(id); + let payload = dataset.field_payload(id); + assert_eq!( + cheap.is_some(), + payload.is_some(), + "{label}: {id:?} is answered by only one of the two queries" + ); + let (Some(cheap), Some(payload)) = (cheap, payload) else { + continue; + }; + assert_eq!( + cheap, + payload.representation(), + "{label}: {id:?} representation drifted" + ); + assert_eq!( + cheap.intrinsic_capabilities(), + payload.intrinsic_capabilities(), + "{label}: {id:?} capabilities drifted" + ); + } +} + +fn nmr2d_data(source: &str, pseudo: Option) -> plotx_io::NmrData2D { + let dimension = |nucleus: &str| plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.5); 16], + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension("1H"), + indirect: dimension("13C"), + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: pseudo, + diffusion: None, + nus: None, + source: source.to_owned(), + } +} + +fn afm_dataset(scan_size_x: f64, raw: Vec, forces: bool) -> Dataset { + let channel = plotx_io::AfmImageChannel { + name: "Height".to_owned(), + width: 2, + height: 2, + scan_size_x, + scan_size_y: 3.0, + lateral_unit: "nm".to_owned(), + scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "nm".to_owned(), + }, + raw: Arc::from(raw), + frame_direction: plotx_io::AfmFrameDirection::Trace, + }; + Dataset::Afm(Box::new(AfmDataset::load(plotx_io::AfmData { + images: vec![channel], + forces: forces.then(|| plotx_io::AfmForceSet { + grid_width: 1, + grid_height: 1, + samples_per_curve: 2, + raw: Arc::from(vec![0, 1]), + signal_scale: plotx_io::AfmScale { + multiplier: 1.0, + offset: 0.0, + unit: "V".to_owned(), + }, + sample_period_s: None, + z_positions: None, + display_order: Arc::from(vec![0, 1]), + approach_samples: 1, + deflection_sensitivity_m_per_v: None, + spring_constant_n_per_m: None, + }), + source: "representation test".to_owned(), + import_warnings: Vec::new(), + }))) +} + +#[test] +fn cheap_representation_matches_the_materialized_payload() { + let nmr_1d = Dataset::Nmr(Box::new(crate::state::NmrDataset::load( + plotx_io::NmrData { + points: vec![num_complex::Complex64::new(1.0, 0.0); 8], + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".to_owned(), + source: "representation test".to_owned(), + group_delay: 0.0, + }, + ))); + assert_representation_matches_payload(&nmr_1d, "nmr 1d"); + + let nmr_2d = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data("true 2d", None)))); + assert_representation_matches_payload(&nmr_2d, "nmr 2d"); + + let mut irregular = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data("explicit", None)))); + let Dataset::Nmr2D(nmr) = &mut irregular else { + panic!("fixture is NMR 2D"); + }; + let plotx_processing::Processed2D::Ft(spectrum) = &mut nmr.processed else { + panic!("frequency-domain input produces a scalar grid"); + }; + Arc::make_mut(spectrum).f1_ppm[2] += 0.25; + assert_representation_matches_payload(&irregular, "nmr 2d, explicitly sampled"); + + let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(nmr2d_data( + "pseudo 2d", + Some(plotx_io::PseudoAxis { + name: "delay".to_owned(), + kind: plotx_io::PseudoKind::Delay, + values: vec![0.1, 0.2, 0.3, 0.4], + unit: "s".to_owned(), + source: plotx_io::AxisSource::EmbeddedList, + }), + )))); + assert!( + !matches!(&pseudo, Dataset::Nmr2D(nmr) if nmr.is_true_2d()), + "the pseudo-2D fixture must exercise the stack branch" + ); + assert_representation_matches_payload(&pseudo, "nmr pseudo 2d"); + + let table = Dataset::Table(Box::new( + crate::state::materialized_float_series_table( + ("x".into(), "".into(), vec![Some(0.0), Some(1.0)]), + Vec::new(), + "plotx.test.representation-table.v1", + ) + .expect("fixture table materializes"), + )); + assert_representation_matches_payload(&table, "table"); + + let recording = Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load( + plotx_io::ElectrophysiologyData { + abf_version: "2.0".to_owned(), + sample_rate_hz: 10_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "Response".to_owned(), + unit: plotx_io::ElectricalUnit { + symbol: "mV".to_owned(), + quantity: plotx_io::ElectricalQuantity::Voltage, + }, + }], + sweeps: vec![plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![1.0, 2.0]], + commands: Vec::new(), + }], + protocol: None, + source: "representation test".to_owned(), + import_warnings: Vec::new(), + }, + ))); + assert_representation_matches_payload(&recording, "electrophysiology"); + + let afm = afm_dataset(2.0, vec![1, 2, 3, 4], true); + assert_representation_matches_payload(&afm, "afm"); + assert!( + afm.field_descriptors()[0] + .capabilities + .contains(CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + + // A non-finite scan size falls back to explicit sampling, and a buffer that + // does not match the declared shape is not a regular grid either. Both must + // be visible without materializing values. + let explicit_afm = afm_dataset(f64::NAN, vec![1, 2, 3, 4], false); + assert_representation_matches_payload(&explicit_afm, "afm, explicitly sampled"); + assert!( + !explicit_afm.field_descriptors()[0] + .capabilities + .contains(CAP_FIELD_SCALAR_GRID_2D_REGULAR) + ); + + let malformed = afm_dataset(2.0, vec![1, 2, 3], false); + assert_representation_matches_payload(&malformed, "afm, malformed shape"); + assert!( + !malformed.field_descriptors()[0] + .capabilities + .contains(CAP_FIELD_SCALAR_GRID_2D_REGULAR), + "a buffer that does not match rows x cols is never regular" + ); +} #[test] fn colored_raster_cannot_supply_scalar_statistics() { @@ -356,7 +574,7 @@ fn magnitude_field_renders_magnitude_instead_of_falling_back_to_real() { } #[test] -fn default_nmr_contour_uses_the_processed_figure_cache() { +fn default_nmr_contour_never_builds_geometry_inline() { let dimension = |nucleus: &str| plotx_io::Dim { spectral_width_hz: 4_000.0, observe_freq_mhz: 400.0, @@ -384,7 +602,7 @@ fn default_nmr_contour_uses_the_processed_figure_cache() { .into_iter() .find(|field| field.local_id == "nmr.real") .unwrap(); - crate::state::datasets_2d_figure::reset_synchronous_contour_builds(); + crate::contour_probe::reset(); dataset .encoded_field_figure( real.id, @@ -397,8 +615,8 @@ fn default_nmr_contour_uses_the_processed_figure_cache() { ) .unwrap(); assert_eq!( - crate::state::datasets_2d_figure::synchronous_contour_builds(), + crate::contour_probe::marching_squares_on_this_thread(), 0, - "the default contour must clone the background-built processed figure" + "the default contour base must not run marching squares on its caller" ); } diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 92ae75b..0672958 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -5,8 +5,7 @@ use crate::actions::{ use crate::export::{ExportDialogState, ExportFormat, ExportSettings}; use crate::{ DosyMethod, IltParams, Integral2D, IntegralResult, PseudoDisplay, apply_peak_labels, - build_dosy_figure, build_figure, build_figure_2d_cancellable, build_ilt_figure, - build_stack_figure, extract_region_series, + build_dosy_figure, build_figure, build_ilt_figure, build_stack_figure, extract_region_series, }; use plotx_analysis::diffusion::{DiffusionMap, diffusion_map}; use plotx_analysis::ilt::{IltResult, ilt_map, log_grid}; @@ -52,7 +51,10 @@ mod document; mod document_identity; mod electrophysiology; mod field; +mod field_cache; mod field_catalog; +mod field_payload; +mod field_runtime; mod fit_selection; mod identity; mod interaction; @@ -96,10 +98,11 @@ pub use board::*; pub use charts::*; pub use compute::*; pub use datasets::*; -pub(crate) use datasets_2d_figure::{build_processed_figure, build_processed_figure_cancellable}; +pub(crate) use datasets_2d_figure::build_processed_figure; pub use document::*; pub use electrophysiology::*; pub use field::*; +pub(crate) use field_cache::FieldRuntime; pub use field_catalog::FieldCatalog; #[cfg(test)] pub(crate) use field_catalog::{ @@ -111,6 +114,8 @@ pub(crate) use field_catalog::{ electrophysiology_channel_keys, electrophysiology_field_catalog_for_keys, nmr_field_catalog, nmr2d_field_catalog, table_field_catalog, }; +pub(crate) use field_payload::nmr_scalar_grid; +pub use field_runtime::*; pub use identity::*; pub use interaction::*; pub use lineage::*; diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 4bb5098..1e83e10 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -28,7 +28,7 @@ impl PlotxApp { /// [`StackKind`] and the stack `mode`: Line kinds overlay/offset traces; the /// Field kind overlays each dataset's 2D contour in a distinct colour. pub fn build_stacked_figure( - &self, + &mut self, binding: &DataBinding, stack: &StackSpec, size_mm: [f32; 2], @@ -48,7 +48,7 @@ impl PlotxApp { /// registry (its domain's line chart); `stack` controls per-trace scale, /// visibility, normalization and vertical/horizontal offset. fn build_line_stack( - &self, + &mut self, binding: &DataBinding, stack: &StackSpec, size_mm: [f32; 2], @@ -170,7 +170,7 @@ impl PlotxApp { /// contour on one canvas, each recoloured from the palette (or its per-series /// override), merging the datasets' x/y ranges. The primary supplies the axis /// labels and orientation; hidden series are skipped. - fn build_contour_overlay(&self, binding: &DataBinding, size_mm: [f32; 2]) -> Figure { + fn build_contour_overlay(&mut self, binding: &DataBinding, size_mm: [f32; 2]) -> Figure { let chart = ChartSpec::default_for(DataDomain::Nmr2d); let primary = binding .primary_dataset() diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index 04705bc..0ab1736 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -225,9 +225,11 @@ pub struct TableSeriesBinding { impl TableDataset { /// Construct a generic typed table with no implicit x/y assumptions. pub fn from_typed(typed_state: TypedTableState) -> Self { + let mut field_catalog = crate::state::table_field_catalog(); + field_catalog.attach_provenance("table", None); Self { resource_id: new_resource_id(), - field_catalog: crate::state::table_field_catalog(), + field_catalog, provenance: None, meta: TableMeta::default(), curve_fit_analyses: Vec::new(), diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 1819835..57f4d19 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -11,12 +11,13 @@ use crate::export::{ use crate::state::{ AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, DEFAULT_CANVAS_SIZE_MM, DataBinding, Dataset, MM_TO_PT, Nmr2DDataset, NmrDataset, - ObjectFrame, ObjectId, PanelMeta, PlotObject, StackSpec, default_chart_type, + ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, StackSpec, default_chart_type, }; use plotx_figure::{Axis, Figure}; use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Provenance}; use serde::Serialize; use std::path::{Path, PathBuf}; +use std::time::Duration; pub const INSPECTION_SCHEMA: &str = "plotx.inspect.v1"; @@ -122,6 +123,8 @@ pub enum WorkflowError { Integration(#[from] plotx_analysis::integrate_2d::IntegrateError), #[error("default figure is unavailable for {0}")] FigureUnavailable(&'static str), + #[error("field runtime setup failed: {0}")] + FieldRuntime(String), #[error("export failed: {0}")] Export(#[from] ExportError), } @@ -150,7 +153,31 @@ pub fn process_file( ) -> Result { let mut loaded = load_dataset(input)?; loaded.apply_scheme_file(scheme)?; - let canvas = loaded.default_canvas(); + // Headless exports use the same worker-only contour path as the desktop + // app. This waits for queued jobs rather than reintroducing a synchronous + // marching-squares shortcut in the export caller. + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.session + .compute + .register_loaded_dataset_fields(&loaded.dataset) + .map_err(|error| { + WorkflowError::FieldRuntime(match error { + crate::state::FieldEnqueueError::WorkersUnavailable => { + "background workers are unavailable".to_owned() + } + crate::state::FieldEnqueueError::VersionExhausted => { + "FieldVersion allocator is exhausted".to_owned() + } + }) + })?; + app.doc.datasets.push(loaded.dataset); + let canvas = build_default_canvas(&app.doc.datasets[0], &loaded.source); + app.doc.canvases.push(canvas); + app.rebuild_canvases_for(0); + while app.compute_busy() { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } let settings = ExportSettings { format, scope: ExportPageScope::Current, @@ -158,7 +185,7 @@ pub fn process_file( target_width_mm: None, trim_to_visible_content: false, }; - let output_paths = export_canvases(&[canvas], Some(0), &settings, output)?; + let output_paths = export_canvases(&app.doc.canvases, Some(0), &settings, output)?; Ok(ProcessResult { inspection: loaded.inspection, output_paths, diff --git a/crates/core/tests/afm_canvas.rs b/crates/core/tests/afm_canvas.rs index a0080f0..6da0a2a 100644 --- a/crates/core/tests/afm_canvas.rs +++ b/crates/core/tests/afm_canvas.rs @@ -134,20 +134,33 @@ fn map_and_force_gui_insertion_builds_side_by_side_plots() { #[test] fn afm_scalar_field_can_render_a_contour_without_a_domain_chart_branch() { - let app = insert(afm_dataset(true)); - let CanvasObjectKind::Plot(map) = &app.doc.canvases[0].objects[0].kind else { - panic!("expected AFM map plot"); + let mut app = insert(afm_dataset(true)); + let (mut binding, chart, size) = { + let CanvasObjectKind::Plot(map) = &app.doc.canvases[0].objects[0].kind else { + panic!("expected AFM map plot"); + }; + ( + map.binding.clone(), + map.chart.clone(), + app.doc.canvases[0].size_mm, + ) }; - let mut binding = map.binding.clone(); binding.series[0].encoding = SeriesEncoding::Contour( ContourSpec::absolute(1.5, false).expect("positive literal contour base"), ); - let figure = app.build_binding_figure( - &binding, - &map.chart, - &StackSpec::default(), - app.doc.canvases[0].size_mm, + let initial = app.build_binding_figure(&binding, &chart, &StackSpec::default(), size); + assert!( + initial.contours.is_empty(), + "geometry is queued, never built inline" ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); + let figure = app.build_binding_figure(&binding, &chart, &StackSpec::default(), size); assert!(!figure.contours.is_empty()); assert!(!figure.contours[0].segments.is_empty()); } diff --git a/crates/core/tests/slice2d.rs b/crates/core/tests/slice2d.rs index e70f3e4..a889d8f 100644 --- a/crates/core/tests/slice2d.rs +++ b/crates/core/tests/slice2d.rs @@ -2,10 +2,13 @@ //! SVG export. No files needed. use num_complex::Complex64; -use plotx_core::{build_figure_2d, build_stack_figure}; +use plotx_core::build_stack_figure; +use plotx_core::state::{CanvasDocument, Dataset, Nmr2DDataset, ObjectFrame, PlotxApp}; use plotx_io::{Dim, Domain, NmrData2D, QuadMode}; use plotx_processing::{Layout2D, Params2D, Preset2D, Processed2D, process_2d, recommend_preset}; use std::f64::consts::TAU; +use std::thread; +use std::time::{Duration, Instant}; fn dim(sw: f64, obs: f64, nucleus: &str) -> Dim { Dim { @@ -90,15 +93,33 @@ fn contour_slice_places_peak_and_exports_svg() { spec.f1_ppm[br] ); - let capabilities = plotx_core::state::FieldCapabilities::new([ - plotx_core::automation::CapabilityId::new( - plotx_core::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR, - ), - plotx_core::automation::CapabilityId::new(plotx_core::automation::CAP_FIELD_SIGNED), - plotx_core::automation::CapabilityId::new(plotx_core::automation::CAP_FIELD_NOISE_SCALE), - ]); - let contour = plotx_core::state::default_contour_spec(&capabilities); - let fig = build_figure_2d(&spec, preset, &contour); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(data)))); + let mut canvas = CanvasDocument::new("contour".to_owned(), [120.0, 80.0]); + let [width, height] = canvas.size_pt(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, width, height), + canvas.allocate_object_id(), + "Contour".to_owned(), + ); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + + let deadline = Instant::now() + Duration::from_secs(2); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + thread::sleep(Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy(), "contour jobs did not settle"); + let fig = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .clone(); assert!(!fig.contours.is_empty()); assert!(!fig.contours[0].segments.is_empty()); diff --git a/crates/figure/src/encoding.rs b/crates/figure/src/encoding.rs index 6e23072..79834cc 100644 --- a/crates/figure/src/encoding.rs +++ b/crates/figure/src/encoding.rs @@ -97,7 +97,7 @@ impl ColorSource { /// An estimator identity selected by an encoding. The concrete estimate and its /// provenance are deliberately owned by the field provider rather than here. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "mode")] pub enum EstimatorSelection { FollowLatest,