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,