Skip to main content

runmat_runtime/builtins/plotting/ops/
hist.rs

1//! MATLAB-compatible `hist` builtin.
2
3use glam::{Vec3, Vec4};
4use log::warn;
5use runmat_accelerate_api::{self, GpuTensorHandle, ProviderPrecision};
6use runmat_builtins::{NumericDType, Tensor, Value};
7use runmat_macros::runtime_builtin;
8use runmat_plot::core::BoundingBox;
9use runmat_plot::gpu::bar::{BarGpuInputs, BarGpuParams, BarLayoutMode, BarOrientation};
10use runmat_plot::gpu::histogram::{
11    HistogramGpuInputs, HistogramGpuOutput, HistogramGpuParams, HistogramGpuWeights,
12    HistogramNormalizationMode,
13};
14use runmat_plot::gpu::ScalarType;
15use runmat_plot::plots::BarChart;
16
17use crate::builtins::common::spec::{
18    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19    ReductionNaN, ResidencyPolicy, ShapeRequirements,
20};
21
22use super::bar::apply_bar_style;
23use super::common::{numeric_vector, value_as_f64};
24use super::plotting_error;
25use super::state::{render_active_plot, PlotRenderOptions};
26use super::style::{parse_bar_style_args, BarStyle, BarStyleDefaults};
27use crate::builtins::plotting::gpu_helpers::{axis_bounds_async, gather_tensor_from_gpu_async};
28use crate::builtins::plotting::type_resolvers::hist_type;
29use crate::{BuiltinResult, RuntimeError};
30
31#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::hist")]
32pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
33    name: "hist",
34    op_kind: GpuOpKind::PlotRender,
35    supported_precisions: &[],
36    broadcast: BroadcastSemantics::None,
37    provider_hooks: &[],
38    constant_strategy: ConstantStrategy::InlineLiteral,
39    // Plotting is a sink, but can consume gpuArray inputs zero-copy when a shared WGPU context exists.
40    residency: ResidencyPolicy::InheritInputs,
41    nan_mode: ReductionNaN::Include,
42    two_pass_threshold: None,
43    workgroup_size: None,
44    accepts_nan_mode: false,
45    notes: "Histogram rendering terminates fusion graphs; gpuArray inputs may remain on device when shared plotting context is installed.",
46};
47
48#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::plotting::hist")]
49pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
50    name: "hist",
51    shape: ShapeRequirements::Any,
52    constant_strategy: ConstantStrategy::InlineLiteral,
53    elementwise: None,
54    reduction: None,
55    emits_nan: false,
56    notes: "hist terminates fusion graphs and produces I/O.",
57};
58
59const BUILTIN_NAME: &str = "hist";
60const HIST_BAR_WIDTH: f32 = 0.95;
61const HIST_DEFAULT_COLOR: Vec4 = Vec4::new(0.15, 0.5, 0.8, 0.95);
62const HIST_DEFAULT_LABEL: &str = "Frequency";
63
64fn hist_err(message: impl Into<String>) -> RuntimeError {
65    plotting_error(BUILTIN_NAME, message)
66}
67
68struct HistComputation {
69    counts: Vec<f64>,
70    centers: Vec<f64>,
71    chart: BarChart,
72}
73
74/// Captures the evaluated histogram so both the renderer and MATLAB outputs share the same data.
75pub struct HistEvaluation {
76    counts: Tensor,
77    #[allow(dead_code)]
78    centers: Tensor,
79    chart: BarChart,
80    normalization: HistNormalization,
81}
82
83impl HistEvaluation {
84    fn new(
85        counts: Vec<f64>,
86        centers: Vec<f64>,
87        chart: BarChart,
88        normalization: HistNormalization,
89    ) -> BuiltinResult<Self> {
90        if counts.len() != centers.len() {
91            return Err(hist_err("hist: mismatch between counts and bin centers"));
92        }
93        let cols = counts.len();
94        let shape = vec![1, cols];
95        let counts_tensor = Tensor::new(counts, shape.clone())?;
96        let centers_tensor = Tensor::new(centers, shape)?;
97        Ok(Self {
98            counts: counts_tensor,
99            centers: centers_tensor,
100            chart,
101            normalization,
102        })
103    }
104
105    pub fn counts_value(&self) -> Value {
106        Value::Tensor(self.counts.clone())
107    }
108
109    #[allow(dead_code)]
110    pub fn centers_value(&self) -> Value {
111        Value::Tensor(self.centers.clone())
112    }
113
114    pub fn render_plot(&self) -> BuiltinResult<()> {
115        let y_label = match self.normalization {
116            HistNormalization::Count => "Count",
117            HistNormalization::Probability => "Probability",
118            HistNormalization::Pdf => "PDF",
119        };
120        let mut chart_opt = Some(self.chart.clone());
121        let opts = PlotRenderOptions {
122            title: "Histogram",
123            x_label: "Bin",
124            y_label,
125            ..Default::default()
126        };
127        render_active_plot(BUILTIN_NAME, opts, move |figure, axes| {
128            let chart = chart_opt
129                .take()
130                .expect("hist chart consumed exactly once at render time");
131            figure.add_bar_chart_on_axes(chart, axes);
132            Ok(())
133        })?;
134        Ok(())
135    }
136}
137
138impl HistComputation {
139    fn into_evaluation(self, normalization: HistNormalization) -> BuiltinResult<HistEvaluation> {
140        HistEvaluation::new(self.counts, self.centers, self.chart, normalization)
141    }
142}
143
144#[derive(Clone)]
145enum HistBinSpec {
146    Auto,
147    Count(usize),
148    Centers(Vec<f64>),
149    Edges(Vec<f64>),
150}
151
152#[derive(Clone)]
153struct HistBinOptions {
154    spec: HistBinSpec,
155    bin_width: Option<f64>,
156    bin_limits: Option<(f64, f64)>,
157    bin_method: Option<HistBinMethod>,
158}
159
160impl HistBinOptions {
161    fn new(spec: HistBinSpec) -> Self {
162        Self {
163            spec,
164            bin_width: None,
165            bin_limits: None,
166            bin_method: None,
167        }
168    }
169
170    fn is_uniform(&self) -> bool {
171        match &self.spec {
172            HistBinSpec::Edges(edges) => uniform_edge_width(edges).is_some(),
173            _ => true,
174        }
175    }
176}
177
178#[derive(Clone, Copy)]
179enum HistBinMethod {
180    Sqrt,
181    Sturges,
182    Integers,
183}
184
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
186enum HistNormalization {
187    #[default]
188    Count,
189    Probability,
190    Pdf,
191}
192
193#[derive(Clone)]
194enum HistWeightsInput {
195    None,
196    Host(Tensor),
197    Gpu(GpuTensorHandle),
198}
199
200impl HistWeightsInput {
201    fn from_value(value: Value, expected_len: usize) -> BuiltinResult<Self> {
202        match value {
203            Value::GpuTensor(handle) => {
204                let len: usize = handle.shape.iter().product();
205                if len != expected_len {
206                    return Err(hist_err(format!(
207                        "hist: Weights must contain {expected_len} elements (got {len})"
208                    )));
209                }
210                Ok(HistWeightsInput::Gpu(handle))
211            }
212            other => {
213                let tensor =
214                    Tensor::try_from(&other).map_err(|e| hist_err(format!("hist: Weights {e}")))?;
215                if tensor.data.len() != expected_len {
216                    return Err(hist_err(format!(
217                        "hist: Weights must contain {expected_len} elements (got {})",
218                        tensor.data.len()
219                    )));
220                }
221                Ok(HistWeightsInput::Host(tensor))
222            }
223        }
224    }
225
226    async fn resolve_for_cpu_async(
227        &self,
228        context: &'static str,
229        sample_len: usize,
230    ) -> BuiltinResult<(Option<Vec<f64>>, f64)> {
231        match self {
232            HistWeightsInput::None => Ok((None, sample_len as f64)),
233            HistWeightsInput::Host(tensor) => {
234                let values = numeric_vector(tensor.clone());
235                let total = values.iter().copied().sum::<f64>();
236                Ok((Some(values), total))
237            }
238            HistWeightsInput::Gpu(handle) => {
239                let tensor = gather_tensor_from_gpu_async(handle.clone(), context).await?;
240                let values = numeric_vector(tensor);
241                let total = values.iter().copied().sum::<f64>();
242                Ok((Some(values), total))
243            }
244        }
245    }
246
247    fn total_weight_hint(&self, sample_len: usize) -> Option<f64> {
248        match self {
249            HistWeightsInput::None => Some(sample_len as f64),
250            HistWeightsInput::Host(tensor) => {
251                let values = numeric_vector(tensor.clone());
252                Some(values.iter().copied().sum::<f64>())
253            }
254            HistWeightsInput::Gpu(_) => None,
255        }
256    }
257
258    fn to_gpu_weights(&self, sample_len: usize) -> BuiltinResult<HistogramGpuWeights> {
259        match self {
260            HistWeightsInput::None => Ok(HistogramGpuWeights::Uniform {
261                total_weight: sample_len as f32,
262            }),
263            HistWeightsInput::Host(tensor) => {
264                let values = numeric_vector(tensor.clone());
265                let total = values.iter().copied().sum::<f64>() as f32;
266                match tensor.dtype {
267                    NumericDType::F32 => {
268                        let data: Vec<f32> = values.iter().map(|v| *v as f32).collect();
269                        Ok(HistogramGpuWeights::HostF32 {
270                            data,
271                            total_weight: total,
272                        })
273                    }
274                    NumericDType::F64 => Ok(HistogramGpuWeights::HostF64 {
275                        data: values,
276                        total_weight: total,
277                    }),
278                    NumericDType::U8 | NumericDType::U16 => Ok(HistogramGpuWeights::HostF64 {
279                        data: values,
280                        total_weight: total,
281                    }),
282                }
283            }
284            HistWeightsInput::Gpu(handle) => {
285                let exported = runmat_accelerate_api::export_wgpu_buffer(handle)
286                    .ok_or_else(|| hist_err("hist: unable to export GPU weights"))?;
287                match exported.precision {
288                    ProviderPrecision::F32 => Ok(HistogramGpuWeights::GpuF32 {
289                        buffer: exported.buffer.clone(),
290                    }),
291                    ProviderPrecision::F64 => Ok(HistogramGpuWeights::GpuF64 {
292                        buffer: exported.buffer.clone(),
293                    }),
294                }
295            }
296        }
297    }
298}
299
300#[runtime_builtin(
301    name = "hist",
302    category = "plotting",
303    summary = "Plot a histogram with MATLAB-compatible defaults.",
304    keywords = "hist,histogram,frequency",
305    sink = true,
306    suppress_auto_output = true,
307    type_resolver(hist_type),
308    builtin_path = "crate::builtins::plotting::hist"
309)]
310pub async fn hist_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
311    let evaluation = evaluate_async(data, &rest).await?;
312    evaluation.render_plot()?;
313    Ok(evaluation.counts_value())
314}
315
316/// Evaluate the histogram inputs once so renderers and MATLAB outputs share the same data.
317pub async fn evaluate_async(data: Value, rest: &[Value]) -> BuiltinResult<HistEvaluation> {
318    let mut input = Some(HistInput::from_value(data)?);
319    let sample_len = input.as_ref().map(|value| value.len()).unwrap_or(0);
320    let (bin_options, normalization, style_args, weights_value) =
321        parse_hist_arguments(sample_len, rest)?;
322    let defaults = BarStyleDefaults::new(HIST_DEFAULT_COLOR, HIST_BAR_WIDTH);
323    let bar_style = parse_bar_style_args("hist", &style_args, defaults)?;
324    let weights_input = if let Some(value) = weights_value {
325        HistWeightsInput::from_value(value, sample_len)?
326    } else {
327        HistWeightsInput::None
328    };
329
330    let computation = if !bar_style.requires_cpu_path() {
331        if let Some(handle) = input.as_ref().and_then(|value| value.gpu_handle()) {
332            if bin_options.is_uniform() {
333                match build_histogram_gpu_chart_async(
334                    handle,
335                    &bin_options,
336                    sample_len,
337                    normalization,
338                    &bar_style,
339                    &weights_input,
340                )
341                .await
342                {
343                    Ok(chart) => Some(chart),
344                    Err(err) => {
345                        warn!("hist GPU path unavailable: {err}");
346                        None
347                    }
348                }
349            } else {
350                None
351            }
352        } else {
353            None
354        }
355    } else {
356        None
357    };
358
359    let computation = match computation {
360        Some(chart) => chart,
361        None => {
362            let data_arg = input.take().expect("hist input consumed once");
363            let tensor = match data_arg {
364                HistInput::Host(tensor) => tensor,
365                HistInput::Gpu(handle) => gather_tensor_from_gpu_async(handle, "hist").await?,
366            };
367            let samples = numeric_vector(tensor);
368            let (weight_values, total_weight) = weights_input
369                .resolve_for_cpu_async("hist weights", sample_len)
370                .await?;
371            build_histogram_chart(
372                samples,
373                &bin_options,
374                normalization,
375                weight_values.as_deref(),
376                total_weight,
377            )?
378        }
379    };
380
381    let mut evaluation = computation.into_evaluation(normalization)?;
382    apply_bar_style(&mut evaluation.chart, &bar_style, HIST_DEFAULT_LABEL);
383    Ok(evaluation)
384}
385
386fn parse_hist_arguments(
387    sample_len: usize,
388    args: &[Value],
389) -> BuiltinResult<(HistBinOptions, HistNormalization, Vec<Value>, Option<Value>)> {
390    let mut idx = 0usize;
391    let mut bin_options = HistBinOptions::new(HistBinSpec::Auto);
392    let mut bin_set = false;
393    let mut normalization = HistNormalization::Count;
394    let mut norm_set = false;
395    let mut style_args = Vec::new();
396    let mut weights_value: Option<Value> = None;
397
398    while idx < args.len() {
399        let arg = &args[idx];
400        if !bin_set && is_bin_candidate(arg) {
401            let spec = parse_hist_bins(Some(arg.clone()), sample_len)?;
402            ensure_spec_compatible(&spec, &bin_options, "bin argument")?;
403            bin_options.spec = spec;
404            bin_set = true;
405            idx += 1;
406            continue;
407        }
408
409        if !norm_set {
410            if let Some(result) = try_parse_norm_literal(arg) {
411                normalization = result?;
412                norm_set = true;
413                idx += 1;
414                continue;
415            }
416        }
417
418        let Some(key) = value_as_string(arg) else {
419            style_args.extend_from_slice(&args[idx..]);
420            break;
421        };
422        if idx + 1 >= args.len() {
423            return Err(hist_err(format!("hist: missing value for '{key}' option")));
424        }
425        let value = args[idx + 1].clone();
426        let lower = key.trim().to_ascii_lowercase();
427        match lower.as_str() {
428            "normalization" => {
429                normalization = parse_hist_normalization(Some(value))?;
430                norm_set = true;
431            }
432            "binedges" => {
433                if bin_set {
434                    return Err(hist_err(
435                        "hist: specify either bins argument or 'BinEdges', not both",
436                    ));
437                }
438                let edges = parse_bin_edges_value(value)?;
439                ensure_spec_compatible(
440                    &HistBinSpec::Edges(edges.clone()),
441                    &bin_options,
442                    "BinEdges",
443                )?;
444                bin_options.spec = HistBinSpec::Edges(edges);
445                bin_set = true;
446            }
447            "numbins" => {
448                if bin_set {
449                    return Err(hist_err(
450                        "hist: NumBins cannot be combined with explicit bins",
451                    ));
452                }
453                let count = parse_num_bins_value(&value)?;
454                ensure_spec_compatible(&HistBinSpec::Count(count), &bin_options, "NumBins")?;
455                bin_options.spec = HistBinSpec::Count(count);
456                bin_set = true;
457            }
458            "binwidth" => {
459                if bin_set {
460                    return Err(hist_err(
461                        "hist: BinWidth cannot be combined with explicit bins",
462                    ));
463                }
464                ensure_no_explicit_bins(&bin_options, "BinWidth")?;
465                if bin_options.bin_width.is_some() {
466                    return Err(hist_err("hist: BinWidth specified more than once"));
467                }
468                let width = parse_positive_scalar(
469                    &value,
470                    "hist: BinWidth must be a positive finite scalar",
471                )?;
472                bin_options.bin_width = Some(width);
473            }
474            "binlimits" => {
475                ensure_no_explicit_bins(&bin_options, "BinLimits")?;
476                if bin_options.bin_limits.is_some() {
477                    return Err(hist_err("hist: BinLimits specified more than once"));
478                }
479                let limits = parse_bin_limits_value(value)?;
480                bin_options.bin_limits = Some(limits);
481            }
482            "binmethod" => {
483                if bin_options.bin_width.is_some() {
484                    return Err(hist_err("hist: BinMethod cannot be combined with BinWidth"));
485                }
486                ensure_no_explicit_bins(&bin_options, "BinMethod")?;
487                if bin_options.bin_method.is_some() {
488                    return Err(hist_err("hist: BinMethod specified more than once"));
489                }
490                let method = parse_hist_bin_method(&value)?;
491                bin_options.bin_method = Some(method);
492            }
493            "weights" => {
494                if weights_value.is_some() {
495                    return Err(hist_err("hist: Weights specified more than once"));
496                }
497                weights_value = Some(value);
498            }
499            _ => {
500                style_args.push(arg.clone());
501                style_args.push(value);
502            }
503        }
504        idx += 2;
505    }
506
507    Ok((bin_options, normalization, style_args, weights_value))
508}
509
510fn parse_hist_bins(arg: Option<Value>, sample_len: usize) -> BuiltinResult<HistBinSpec> {
511    let spec = match arg {
512        None => HistBinSpec::Auto,
513        Some(Value::Tensor(tensor)) => parse_center_vector(tensor)?,
514        Some(Value::GpuTensor(_)) => {
515            return Err(hist_err("hist: bin definitions must reside on the host"))
516        }
517        Some(other) => {
518            if let Some(numeric) = value_as_f64(&other) {
519                parse_bin_count_value(numeric)?
520            } else {
521                return Err(hist_err(
522                    "hist: bin argument must be a scalar count or a vector of centers",
523                ));
524            }
525        }
526    };
527    Ok(match spec {
528        HistBinSpec::Count(0) => HistBinSpec::Count(default_bin_count(sample_len)),
529        other => other,
530    })
531}
532
533#[derive(Clone, Copy)]
534struct HistDataStats {
535    min: Option<f64>,
536    max: Option<f64>,
537}
538
539impl HistDataStats {
540    fn from_samples(samples: &[f64]) -> Self {
541        let mut min: Option<f64> = None;
542        let mut max: Option<f64> = None;
543        for &value in samples {
544            if value.is_nan() {
545                continue;
546            }
547            min = Some(match min {
548                Some(current) => current.min(value),
549                None => value,
550            });
551            max = Some(match max {
552                Some(current) => current.max(value),
553                None => value,
554            });
555        }
556        Self { min, max }
557    }
558}
559
560struct RealizedBins {
561    edges: Vec<f64>,
562    widths: Vec<f64>,
563    labels: Vec<String>,
564    centers: Vec<f64>,
565    uniform_width: Option<f64>,
566}
567
568impl RealizedBins {
569    fn from_edges(edges: Vec<f64>) -> BuiltinResult<Self> {
570        if edges.len() < 2 {
571            return Err(hist_err(
572                "hist: bin definitions must contain at least two edges",
573            ));
574        }
575        let widths = widths_from_edges(&edges);
576        let labels = histogram_labels_from_edges(&edges);
577        let centers = centers_from_edges(&edges);
578        let uniform_width = if widths.iter().all(|w| approx_equal(*w, widths[0])) {
579            Some(widths[0])
580        } else {
581            None
582        };
583        Ok(Self {
584            edges,
585            widths,
586            labels,
587            centers,
588            uniform_width,
589        })
590    }
591
592    fn bin_count(&self) -> usize {
593        self.widths.len()
594    }
595}
596
597fn realize_bins(
598    options: &HistBinOptions,
599    sample_len: usize,
600    stats: Option<&HistDataStats>,
601    fallback_value: Option<f64>,
602) -> BuiltinResult<RealizedBins> {
603    match &options.spec {
604        HistBinSpec::Centers(centers) => {
605            let edges = edges_from_centers(centers)?;
606            RealizedBins::from_edges(edges)
607        }
608        HistBinSpec::Edges(edges) => RealizedBins::from_edges(edges.clone()),
609        _ => {
610            if matches!(options.bin_method, Some(HistBinMethod::Integers)) {
611                let edges = integer_edges(options, stats, fallback_value)?;
612                return RealizedBins::from_edges(edges);
613            }
614            let edges = uniform_edges_from_options(options, sample_len, stats, fallback_value)?;
615            RealizedBins::from_edges(edges)
616        }
617    }
618}
619
620fn integer_edges(
621    options: &HistBinOptions,
622    stats: Option<&HistDataStats>,
623    fallback_value: Option<f64>,
624) -> BuiltinResult<Vec<f64>> {
625    let (lower, upper) = determine_limits(options, stats, fallback_value)?;
626    let start = lower.floor();
627    let mut end = upper.ceil();
628    if approx_equal(start, end) {
629        end = start + 1.0;
630    }
631    if end <= start {
632        end = start + 1.0;
633    }
634    let mut edges = Vec::new();
635    let mut current = start;
636    while current <= end {
637        edges.push(current);
638        current += 1.0;
639    }
640    if edges.len() < 2 {
641        edges.push(edges[0] + 1.0);
642    }
643    Ok(edges)
644}
645
646fn uniform_edges_from_options(
647    options: &HistBinOptions,
648    sample_len: usize,
649    stats: Option<&HistDataStats>,
650    fallback_value: Option<f64>,
651) -> BuiltinResult<Vec<f64>> {
652    let (mut lower, mut upper) = determine_limits(options, stats, fallback_value)?;
653    if !lower.is_finite() || !upper.is_finite() {
654        lower = -0.5;
655        upper = 0.5;
656    }
657    if approx_equal(lower, upper) {
658        upper = lower + 1.0;
659    }
660    if let Some(width) = options.bin_width {
661        let bins = ((upper - lower) / width).ceil().max(1.0) as usize;
662        let mut edges = Vec::with_capacity(bins + 1);
663        for i in 0..=bins {
664            edges.push(lower + width * i as f64);
665        }
666        if let Some(last) = edges.last_mut() {
667            *last = upper;
668        }
669        return Ok(edges);
670    }
671    let span = (upper - lower).abs();
672    let bin_count = determine_bin_count(options, sample_len)?;
673    let mut edges = Vec::with_capacity(bin_count + 1);
674    let step = if bin_count == 0 {
675        1.0
676    } else {
677        span / bin_count as f64
678    };
679    for i in 0..=bin_count {
680        edges.push(lower + step * i as f64);
681    }
682    if let Some(last) = edges.last_mut() {
683        *last = upper;
684    }
685    Ok(edges)
686}
687
688fn widths_from_edges(edges: &[f64]) -> Vec<f64> {
689    edges
690        .windows(2)
691        .map(|pair| (pair[1] - pair[0]).max(f64::MIN_POSITIVE))
692        .collect()
693}
694
695fn determine_limits(
696    options: &HistBinOptions,
697    stats: Option<&HistDataStats>,
698    fallback_value: Option<f64>,
699) -> BuiltinResult<(f64, f64)> {
700    if let Some((lo, hi)) = options.bin_limits {
701        if hi <= lo {
702            return Err(hist_err("hist: BinLimits must be increasing"));
703        }
704        return Ok((lo, hi));
705    }
706    if let Some(stats) = stats {
707        if let (Some(min), Some(max)) = (stats.min, stats.max) {
708            if approx_equal(min, max) {
709                let span = options.bin_width.unwrap_or(1.0);
710                return Ok((min - span * 0.5, min + span * 0.5));
711            }
712            return Ok((min, max));
713        }
714    }
715    let center = fallback_value.unwrap_or(0.0);
716    let span = options.bin_width.unwrap_or(1.0);
717    Ok((center - span * 0.5, center + span * 0.5))
718}
719
720fn determine_bin_count(options: &HistBinOptions, sample_len: usize) -> BuiltinResult<usize> {
721    if let HistBinSpec::Count(count) = options.spec {
722        return Ok(count.max(1));
723    }
724    if let Some(method) = options.bin_method {
725        return Ok(match method {
726            HistBinMethod::Sqrt => sqrt_bin_count(sample_len),
727            HistBinMethod::Sturges => sturges_bin_count(sample_len),
728            HistBinMethod::Integers => {
729                return Err(hist_err("hist: internal integer bin method misuse"))
730            }
731        });
732    }
733    Ok(default_bin_count(sample_len))
734}
735
736fn sqrt_bin_count(sample_len: usize) -> usize {
737    ((sample_len as f64).sqrt().ceil() as usize).max(1)
738}
739
740fn sturges_bin_count(sample_len: usize) -> usize {
741    let n = sample_len.max(1) as f64;
742    ((n.log2().ceil() + 1.0) as usize).max(1)
743}
744
745fn approx_equal(a: f64, b: f64) -> bool {
746    (a - b).abs() <= 1e-9
747}
748
749fn ensure_spec_compatible(
750    new_spec: &HistBinSpec,
751    options: &HistBinOptions,
752    source: &str,
753) -> BuiltinResult<()> {
754    if matches!(new_spec, HistBinSpec::Centers(_) | HistBinSpec::Edges(_))
755        && (options.bin_width.is_some()
756            || options.bin_method.is_some()
757            || options.bin_limits.is_some())
758    {
759        return Err(hist_err(format!(
760            "hist: {source} cannot be combined with BinWidth, BinLimits, or BinMethod"
761        )));
762    }
763    Ok(())
764}
765
766fn ensure_no_explicit_bins(options: &HistBinOptions, source: &str) -> BuiltinResult<()> {
767    if matches!(
768        options.spec,
769        HistBinSpec::Centers(_) | HistBinSpec::Edges(_)
770    ) {
771        return Err(hist_err(format!(
772            "hist: {source} cannot be combined with explicit bin centers or edges"
773        )));
774    }
775    Ok(())
776}
777
778fn parse_num_bins_value(value: &Value) -> BuiltinResult<usize> {
779    let Some(scalar) = value_as_f64(value) else {
780        return Err(hist_err("hist: NumBins must be a numeric scalar"));
781    };
782    if !scalar.is_finite() || scalar <= 0.0 {
783        return Err(hist_err("hist: NumBins must be a positive finite scalar"));
784    }
785    let rounded = scalar.round();
786    if (scalar - rounded).abs() > 1e-9 {
787        return Err(hist_err("hist: NumBins must be an integer"));
788    }
789    Ok(rounded as usize)
790}
791
792fn parse_positive_scalar(value: &Value, err: &str) -> BuiltinResult<f64> {
793    let Some(scalar) = value_as_f64(value) else {
794        return Err(hist_err(err));
795    };
796    if !scalar.is_finite() || scalar <= 0.0 {
797        return Err(hist_err(err));
798    }
799
800    Ok(scalar)
801}
802
803fn parse_bin_limits_value(value: Value) -> BuiltinResult<(f64, f64)> {
804    let tensor = Tensor::try_from(&value)
805        .map_err(|_| hist_err("hist: BinLimits must be provided as a numeric vector"))?;
806    let values = numeric_vector(tensor);
807    if values.len() != 2 {
808        return Err(hist_err(
809            "hist: BinLimits must contain exactly two elements",
810        ));
811    }
812    let lo = values[0];
813    let hi = values[1];
814    if !lo.is_finite() || !hi.is_finite() {
815        return Err(hist_err("hist: BinLimits must be finite"));
816    }
817    if hi <= lo {
818        return Err(hist_err("hist: BinLimits must be increasing"));
819    }
820    Ok((lo, hi))
821}
822
823fn parse_hist_bin_method(value: &Value) -> BuiltinResult<HistBinMethod> {
824    let Some(text) = value_as_string(value) else {
825        return Err(hist_err("hist: BinMethod must be a string"));
826    };
827    match text.trim().to_ascii_lowercase().as_str() {
828        "sqrt" => Ok(HistBinMethod::Sqrt),
829        "sturges" => Ok(HistBinMethod::Sturges),
830        "integers" => Ok(HistBinMethod::Integers),
831        other => Err(hist_err(format!(
832            "hist: BinMethod '{other}' is not supported yet (supported: 'sqrt', 'sturges', 'integers')"
833        ))),
834    }
835}
836
837fn parse_center_vector(tensor: Tensor) -> BuiltinResult<HistBinSpec> {
838    let values = numeric_vector(tensor);
839    if values.is_empty() {
840        return Err(hist_err("hist: bin center array cannot be empty"));
841    }
842    if values.len() == 1 {
843        return parse_bin_count_value(values[0]);
844    }
845    validate_monotonic(&values)?;
846    ensure_uniform_spacing(&values)?;
847    Ok(HistBinSpec::Centers(values))
848}
849
850fn parse_bin_count_value(value: f64) -> BuiltinResult<HistBinSpec> {
851    if value.is_finite() && value > 0.0 {
852        Ok(HistBinSpec::Count(value.round() as usize))
853    } else {
854        Err(hist_err("hist: bin count must be positive"))
855    }
856}
857
858fn is_bin_candidate(value: &Value) -> bool {
859    matches!(
860        value,
861        Value::Tensor(_) | Value::Num(_) | Value::Int(_) | Value::Bool(_)
862    )
863}
864
865fn try_parse_norm_literal(value: &Value) -> Option<BuiltinResult<HistNormalization>> {
866    match value {
867        Value::String(_) | Value::CharArray(_) => {
868            let cloned = value.clone();
869            match parse_hist_normalization(Some(cloned)) {
870                Ok(norm) => Some(Ok(norm)),
871                Err(_) => None,
872            }
873        }
874        _ => None,
875    }
876}
877
878fn parse_bin_edges_value(value: Value) -> BuiltinResult<Vec<f64>> {
879    match value {
880        Value::Tensor(tensor) => {
881            let edges = numeric_vector(tensor);
882            if edges.len() < 2 {
883                return Err(hist_err(
884                    "hist: 'BinEdges' must contain at least two elements",
885                ));
886            }
887            validate_monotonic(&edges)?;
888            Ok(edges)
889        }
890        Value::GpuTensor(_) => Err(hist_err("hist: 'BinEdges' must be provided on the host")),
891        _ => Err(hist_err("hist: 'BinEdges' expects a numeric vector")),
892    }
893}
894
895fn ensure_uniform_spacing(values: &[f64]) -> BuiltinResult<()> {
896    if values.len() <= 2 {
897        return Ok(());
898    }
899    let mut diffs = values.windows(2).map(|pair| pair[1] - pair[0]);
900    let first = diffs.next().unwrap();
901    if first <= 0.0 || !first.is_finite() {
902        return Err(hist_err("hist: bin centers must be strictly increasing"));
903    }
904    let tol = first.abs().max(1.0) * 1e-6;
905    for diff in diffs {
906        if (diff - first).abs() > tol {
907            return Err(hist_err("hist: bin centers must be evenly spaced"));
908        }
909    }
910    Ok(())
911}
912
913fn uniform_edge_width(edges: &[f64]) -> Option<f64> {
914    if edges.len() < 2 {
915        return None;
916    }
917    let mut diffs = edges.windows(2).map(|pair| pair[1] - pair[0]);
918    let first = diffs.next().unwrap();
919    if first <= 0.0 || !first.is_finite() {
920        return None;
921    }
922    let tol = first.abs().max(1.0) * 1e-5;
923    for diff in diffs {
924        if diff <= 0.0 || !diff.is_finite() {
925            return None;
926        }
927        if (diff - first).abs() > tol {
928            return None;
929        }
930    }
931    Some(first)
932}
933
934fn parse_hist_normalization(arg: Option<Value>) -> BuiltinResult<HistNormalization> {
935    match arg {
936        None => Ok(HistNormalization::Count),
937        Some(Value::String(s)) => parse_norm_string(&s),
938        Some(Value::CharArray(chars)) => {
939            let text: String = chars.data.iter().collect();
940            parse_norm_string(&text)
941        }
942        Some(value) => {
943            if let Some(text) = value_as_string(&value) {
944                parse_norm_string(&text)
945            } else {
946                Err(hist_err(
947                    "hist: normalization must be 'count', 'probability', or 'pdf'",
948                ))
949            }
950        }
951    }
952}
953
954fn parse_norm_string(text: &str) -> BuiltinResult<HistNormalization> {
955    match text.trim().to_ascii_lowercase().as_str() {
956        "count" | "counts" => Ok(HistNormalization::Count),
957        "probability" | "prob" => Ok(HistNormalization::Probability),
958        "pdf" => Ok(HistNormalization::Pdf),
959        other => Err(hist_err(format!(
960            "hist: unsupported normalization '{other}' (expected 'count', 'probability', or 'pdf')"
961        ))),
962    }
963}
964
965fn value_as_string(value: &Value) -> Option<String> {
966    match value {
967        Value::String(s) => Some(s.clone()),
968        Value::CharArray(chars) => Some(chars.data.iter().collect()),
969        _ => None,
970    }
971}
972
973fn default_bin_count(sample_len: usize) -> usize {
974    ((sample_len as f64).sqrt().floor() as usize).max(1)
975}
976
977fn build_histogram_chart(
978    data: Vec<f64>,
979    bin_options: &HistBinOptions,
980    normalization: HistNormalization,
981    weights: Option<&[f64]>,
982    total_weight: f64,
983) -> BuiltinResult<HistComputation> {
984    let sample_len = data.len();
985    if sample_len == 0 {
986        return build_empty_histogram_chart(bin_options, normalization, 0, total_weight);
987    }
988    let stats = HistDataStats::from_samples(&data);
989    let fallback = data.first().copied();
990    let bins = realize_bins(bin_options, sample_len, Some(&stats), fallback)?;
991    let weight_for_sample = |sample_idx: usize| -> f64 {
992        weights
993            .and_then(|slice| slice.get(sample_idx).copied())
994            .unwrap_or(1.0)
995    };
996    let mut counts = vec![0f64; bins.bin_count()];
997    for (sample_idx, value) in data.iter().enumerate() {
998        let bin_idx = find_bin_index(&bins.edges, *value);
999        counts[bin_idx] += weight_for_sample(sample_idx);
1000    }
1001    apply_normalization(&mut counts, &bins.widths, normalization, total_weight);
1002    build_hist_cpu_result(&bins, counts)
1003}
1004
1005fn build_empty_histogram_chart(
1006    bin_options: &HistBinOptions,
1007    _normalization: HistNormalization,
1008    sample_len: usize,
1009    _total_weight: f64,
1010) -> BuiltinResult<HistComputation> {
1011    let bins = realize_bins(bin_options, sample_len, None, None)?;
1012    let counts = vec![0.0; bins.bin_count()];
1013    build_hist_cpu_result(&bins, counts)
1014}
1015
1016fn build_hist_cpu_result(bins: &RealizedBins, counts: Vec<f64>) -> BuiltinResult<HistComputation> {
1017    let mut bar = BarChart::new(bins.labels.clone(), counts.clone())
1018        .map_err(|err| hist_err(format!("hist: {err}")))?;
1019    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
1020    Ok(HistComputation {
1021        counts,
1022        centers: bins.centers.clone(),
1023        chart: bar,
1024    })
1025}
1026
1027fn validate_monotonic(values: &[f64]) -> BuiltinResult<()> {
1028    if values.windows(2).all(|w| w[0] < w[1]) {
1029        Ok(())
1030    } else {
1031        Err(hist_err("hist: values must be strictly increasing"))
1032    }
1033}
1034
1035fn find_bin_index(edges: &[f64], value: f64) -> usize {
1036    if value <= edges[0] {
1037        return 0;
1038    }
1039    let last = edges.len() - 2;
1040    for i in 0..=last {
1041        if value < edges[i + 1] || i == last {
1042            return i;
1043        }
1044    }
1045    last
1046}
1047
1048fn edges_from_centers(centers: &[f64]) -> BuiltinResult<Vec<f64>> {
1049    if centers.is_empty() {
1050        return Err(hist_err(
1051            "hist: bin centers must contain at least one element",
1052        ));
1053    }
1054    if centers.len() == 1 {
1055        let half = 0.5;
1056        return Ok(vec![centers[0] - half, centers[0] + half]);
1057    }
1058    validate_monotonic(centers)?;
1059    let mut edges = Vec::with_capacity(centers.len() + 1);
1060    edges.push(centers[0] - (centers[1] - centers[0]) * 0.5);
1061    for pair in centers.windows(2) {
1062        edges.push((pair[0] + pair[1]) * 0.5);
1063    }
1064    edges.push(
1065        centers[centers.len() - 1]
1066            + (centers[centers.len() - 1] - centers[centers.len() - 2]) * 0.5,
1067    );
1068    Ok(edges)
1069}
1070
1071fn histogram_labels_from_edges(edges: &[f64]) -> Vec<String> {
1072    edges
1073        .windows(2)
1074        .map(|pair| {
1075            let start = pair[0];
1076            let end = pair[1];
1077            format!("[{start:.3}, {end:.3})")
1078        })
1079        .collect()
1080}
1081
1082fn centers_from_edges(edges: &[f64]) -> Vec<f64> {
1083    edges
1084        .windows(2)
1085        .map(|pair| (pair[0] + pair[1]) * 0.5)
1086        .collect()
1087}
1088
1089fn apply_normalization(
1090    counts: &mut [f64],
1091    widths: &[f64],
1092    normalization: HistNormalization,
1093    total_weight: f64,
1094) {
1095    match normalization {
1096        HistNormalization::Count => {}
1097        HistNormalization::Probability => {
1098            let total = total_weight.max(f64::EPSILON);
1099            for count in counts {
1100                *count /= total;
1101            }
1102        }
1103        HistNormalization::Pdf => {
1104            let total = total_weight.max(f64::EPSILON);
1105            for (count, width) in counts.iter_mut().zip(widths.iter()) {
1106                let w = width.max(f64::MIN_POSITIVE);
1107                *count /= total * w;
1108            }
1109        }
1110    }
1111}
1112
1113async fn build_histogram_gpu_chart_async(
1114    values: &GpuTensorHandle,
1115    bin_options: &HistBinOptions,
1116    sample_len: usize,
1117    normalization: HistNormalization,
1118    style: &BarStyle,
1119    weights: &HistWeightsInput,
1120) -> BuiltinResult<HistComputation> {
1121    let context = crate::builtins::plotting::gpu_helpers::ensure_shared_wgpu_context(BUILTIN_NAME)?;
1122    let exported = runmat_accelerate_api::export_wgpu_buffer(values)
1123        .ok_or_else(|| hist_err("hist: unable to export GPU data"))?;
1124    if exported.len == 0 {
1125        let total_hint = weights
1126            .total_weight_hint(sample_len)
1127            .unwrap_or(sample_len as f64);
1128        return build_empty_histogram_chart(bin_options, normalization, sample_len, total_hint);
1129    }
1130
1131    let sample_count_u32 = u32::try_from(exported.len)
1132        .map_err(|_| hist_err("hist: sample count exceeds supported range"))?;
1133    let gpu_weights = weights.to_gpu_weights(sample_len)?;
1134    let (min_value_f32, max_value_f32) = axis_bounds_async(values, "hist").await?;
1135    let stats = HistDataStats {
1136        min: Some(min_value_f32 as f64),
1137        max: Some(max_value_f32 as f64),
1138    };
1139    let bins = realize_bins(
1140        bin_options,
1141        sample_len,
1142        Some(&stats),
1143        Some(min_value_f32 as f64),
1144    )?;
1145    let Some(uniform_width_f64) = bins.uniform_width else {
1146        return Err(hist_err(
1147            "hist: GPU rendering currently requires uniform bin edges",
1148        ));
1149    };
1150    let uniform_width = uniform_width_f64 as f32;
1151    let bin_count_u32 = u32::try_from(bins.bin_count())
1152        .map_err(|_| hist_err("hist: bin count exceeds supported range for GPU execution"))?;
1153
1154    let histogram_inputs = HistogramGpuInputs {
1155        samples: exported.buffer.clone(),
1156        sample_count: sample_count_u32,
1157        scalar: ScalarType::from_is_f64(exported.precision == ProviderPrecision::F64),
1158        weights: gpu_weights,
1159    };
1160    let histogram_params = HistogramGpuParams {
1161        min_value: bins.edges[0] as f32,
1162        inv_bin_width: 1.0 / uniform_width,
1163        bin_count: bin_count_u32,
1164    };
1165    let normalization_mode = match normalization {
1166        HistNormalization::Count => HistogramNormalizationMode::Count,
1167        HistNormalization::Probability => HistogramNormalizationMode::Probability,
1168        HistNormalization::Pdf => HistogramNormalizationMode::Pdf {
1169            bin_width: uniform_width.max(f32::MIN_POSITIVE),
1170        },
1171    };
1172
1173    let histogram_output = runmat_plot::gpu::histogram::histogram_values_buffer(
1174        &context.device,
1175        &context.queue,
1176        histogram_inputs,
1177        &histogram_params,
1178        normalization_mode,
1179    )
1180    .await
1181    .map_err(|e| hist_err(format!("hist: failed to build GPU histogram counts: {e}")))?;
1182
1183    let HistogramGpuOutput {
1184        values_buffer,
1185        total_weight,
1186    } = histogram_output;
1187
1188    let bar_inputs = BarGpuInputs {
1189        values_buffer,
1190        row_count: bin_count_u32,
1191        scalar: ScalarType::F32,
1192    };
1193    let bar_params = BarGpuParams {
1194        color: style.face_rgba(),
1195        bar_width: style.bar_width,
1196        series_index: 0,
1197        series_count: 1,
1198        group_index: 0,
1199        group_count: 1,
1200        orientation: BarOrientation::Vertical,
1201        layout: BarLayoutMode::Grouped,
1202    };
1203
1204    let gpu_vertices = runmat_plot::gpu::bar::pack_vertices_from_values(
1205        &context.device,
1206        &context.queue,
1207        &bar_inputs,
1208        &bar_params,
1209    )
1210    .map_err(|e| hist_err(format!("hist: failed to build GPU vertices: {e}")))?;
1211
1212    let bin_count = bins.bin_count();
1213    let normalization_scale = match normalization {
1214        HistNormalization::Count => 1.0,
1215        HistNormalization::Probability => {
1216            if total_weight <= f32::EPSILON {
1217                0.0
1218            } else {
1219                1.0 / total_weight
1220            }
1221        }
1222        HistNormalization::Pdf => {
1223            if total_weight <= f32::EPSILON {
1224                0.0
1225            } else {
1226                1.0 / (total_weight * uniform_width)
1227            }
1228        }
1229    };
1230    let bounds = histogram_bar_bounds(
1231        bin_count,
1232        total_weight,
1233        normalization_scale,
1234        style.bar_width,
1235    );
1236    let vertex_count = gpu_vertices.vertex_count;
1237    let mut bar = BarChart::from_gpu_buffer(
1238        bins.labels.clone(),
1239        bin_count,
1240        gpu_vertices,
1241        vertex_count,
1242        bounds,
1243        style.face_rgba(),
1244        style.bar_width,
1245    );
1246    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
1247    let counts_f32 = runmat_plot::gpu::util::readback_f32_buffer(
1248        &context.device,
1249        bar_inputs.values_buffer.as_ref(),
1250        bin_count,
1251    )
1252    .await
1253    .map_err(|e| hist_err(format!("hist: failed to read GPU histogram counts: {e}")))?;
1254    let counts: Vec<f64> = counts_f32.iter().map(|v| *v as f64).collect();
1255
1256    Ok(HistComputation {
1257        counts,
1258        centers: bins.centers.clone(),
1259        chart: bar,
1260    })
1261}
1262
1263fn histogram_bar_bounds(
1264    bins: usize,
1265    total_weight: f32,
1266    normalization_scale: f32,
1267    bar_width: f32,
1268) -> BoundingBox {
1269    let min_x = 1.0 - bar_width * 0.5;
1270    let max_x = bins as f32 + bar_width * 0.5;
1271    let max_y = total_weight * normalization_scale;
1272    let max_y = if max_y.is_finite() && max_y > 0.0 {
1273        max_y
1274    } else {
1275        1.0
1276    };
1277    BoundingBox::new(Vec3::new(min_x, 0.0, 0.0), Vec3::new(max_x, max_y, 0.0))
1278}
1279
1280enum HistInput {
1281    Host(Tensor),
1282    Gpu(GpuTensorHandle),
1283}
1284
1285impl HistInput {
1286    fn from_value(value: Value) -> BuiltinResult<Self> {
1287        match value {
1288            Value::GpuTensor(handle) => Ok(Self::Gpu(handle)),
1289            other => {
1290                let tensor =
1291                    Tensor::try_from(&other).map_err(|e| hist_err(format!("hist: {e}")))?;
1292                Ok(Self::Host(tensor))
1293            }
1294        }
1295    }
1296
1297    fn gpu_handle(&self) -> Option<&GpuTensorHandle> {
1298        match self {
1299            Self::Gpu(handle) => Some(handle),
1300            Self::Host(_) => None,
1301        }
1302    }
1303
1304    fn len(&self) -> usize {
1305        match self {
1306            Self::Host(tensor) => tensor.data.len(),
1307            Self::Gpu(handle) => handle.shape.iter().product(),
1308        }
1309    }
1310}
1311
1312#[cfg(test)]
1313pub(crate) mod tests {
1314    use super::*;
1315    use crate::builtins::array::type_resolvers::row_vector_type;
1316    use crate::builtins::plotting::tests::ensure_plot_test_env;
1317    use crate::RuntimeError;
1318    use futures::executor::block_on;
1319    use runmat_builtins::{ResolveContext, Type};
1320
1321    fn setup_plot_tests() {
1322        ensure_plot_test_env();
1323    }
1324
1325    fn tensor_from(data: &[f64]) -> Tensor {
1326        Tensor {
1327            data: data.to_vec(),
1328            shape: vec![data.len()],
1329            rows: data.len(),
1330            cols: 1,
1331            dtype: runmat_builtins::NumericDType::F64,
1332        }
1333    }
1334
1335    fn assert_plotting_unavailable(err: &RuntimeError) {
1336        let lower = err.to_string().to_lowercase();
1337        assert!(
1338            lower.contains("plotting is unavailable") || lower.contains("non-main thread"),
1339            "unexpected error: {err}"
1340        );
1341    }
1342
1343    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1344    #[test]
1345    fn hist_respects_bin_argument() {
1346        setup_plot_tests();
1347        let data = Value::Tensor(tensor_from(&[1.0, 2.0, 3.0, 4.0]));
1348        let bins = vec![Value::from(2.0)];
1349        let result = block_on(hist_builtin(data, bins));
1350        if let Err(flow) = result {
1351            assert_plotting_unavailable(&flow);
1352        }
1353    }
1354
1355    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1356    #[test]
1357    fn hist_accepts_bin_centers_vector() {
1358        setup_plot_tests();
1359        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
1360        let centers = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0]));
1361        let result = block_on(hist_builtin(data, vec![centers]));
1362        if let Err(flow) = result {
1363            assert_plotting_unavailable(&flow);
1364        }
1365    }
1366
1367    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1368    #[test]
1369    fn hist_accepts_probability_normalization() {
1370        setup_plot_tests();
1371        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
1372        let result = block_on(hist_builtin(
1373            data,
1374            vec![Value::from(3.0), Value::String("probability".into())],
1375        ));
1376        if let Err(flow) = result {
1377            assert_plotting_unavailable(&flow);
1378        }
1379    }
1380
1381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1382    #[test]
1383    fn hist_accepts_string_only_normalization() {
1384        setup_plot_tests();
1385        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
1386        let result = block_on(hist_builtin(data, vec![Value::String("pdf".into())]));
1387        if let Err(flow) = result {
1388            assert_plotting_unavailable(&flow);
1389        }
1390    }
1391
1392    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1393    #[test]
1394    fn hist_accepts_normalization_name_value_pair() {
1395        setup_plot_tests();
1396        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
1397        let result = block_on(hist_builtin(
1398            data,
1399            vec![
1400                Value::String("Normalization".into()),
1401                Value::String("probability".into()),
1402            ],
1403        ));
1404        if let Err(flow) = result {
1405            assert_plotting_unavailable(&flow);
1406        }
1407    }
1408
1409    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1410    #[test]
1411    fn hist_accepts_bin_edges_option() {
1412        setup_plot_tests();
1413        let data = Value::Tensor(tensor_from(&[0.1, 0.4, 0.7]));
1414        let edges = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
1415        let result = block_on(hist_builtin(
1416            data,
1417            vec![Value::String("BinEdges".into()), edges],
1418        ));
1419        if let Err(flow) = result {
1420            assert_plotting_unavailable(&flow);
1421        }
1422    }
1423
1424    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1425    #[test]
1426    fn hist_evaluate_returns_counts_and_centers() {
1427        setup_plot_tests();
1428        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.8, 1.0]));
1429        let eval = block_on(evaluate_async(data, &[])).expect("hist evaluate");
1430        let counts = match eval.counts_value() {
1431            Value::Tensor(tensor) => tensor.data,
1432            other => panic!("unexpected value: {other:?}"),
1433        };
1434        assert_eq!(counts.len(), 2);
1435        let centers = match eval.centers_value() {
1436            Value::Tensor(tensor) => tensor.data,
1437            other => panic!("unexpected centers: {other:?}"),
1438        };
1439        assert_eq!(centers.len(), 2);
1440    }
1441
1442    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1443    #[test]
1444    fn hist_supports_numbins_option() {
1445        setup_plot_tests();
1446        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
1447        let args = vec![Value::String("NumBins".into()), Value::Num(4.0)];
1448        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
1449        let centers = match eval.centers_value() {
1450            Value::Tensor(tensor) => tensor.data,
1451            other => panic!("unexpected centers: {other:?}"),
1452        };
1453        assert_eq!(centers.len(), 4);
1454    }
1455
1456    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1457    #[test]
1458    fn hist_supports_binwidth_and_limits() {
1459        setup_plot_tests();
1460        let data = Value::Tensor(tensor_from(&[0.1, 0.2, 0.6, 0.8]));
1461        let args = vec![
1462            Value::String("BinWidth".into()),
1463            Value::Num(0.5),
1464            Value::String("BinLimits".into()),
1465            Value::Tensor(tensor_from(&[0.0, 1.0])),
1466        ];
1467        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
1468        let centers = match eval.centers_value() {
1469            Value::Tensor(tensor) => tensor.data,
1470            other => panic!("unexpected centers: {other:?}"),
1471        };
1472        assert_eq!(centers.len(), 2);
1473        assert!((centers[0] - 0.25).abs() < 1e-9);
1474    }
1475
1476    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1477    #[test]
1478    fn hist_supports_sqrt_binmethod() {
1479        setup_plot_tests();
1480        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.4, 0.6, 0.8]));
1481        let args = vec![
1482            Value::String("BinMethod".into()),
1483            Value::String("sqrt".into()),
1484        ];
1485        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
1486        let centers = match eval.centers_value() {
1487            Value::Tensor(tensor) => tensor.data,
1488            other => panic!("unexpected centers: {other:?}"),
1489        };
1490        assert!(centers.len() >= 2);
1491    }
1492
1493    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1494    #[test]
1495    fn apply_normalization_handles_weighted_probability() {
1496        setup_plot_tests();
1497        let mut counts = vec![2.0, 4.0];
1498        let widths = vec![1.0, 1.0];
1499        apply_normalization(&mut counts, &widths, HistNormalization::Probability, 6.0);
1500        assert!((counts[0] - 2.0 / 6.0).abs() < 1e-12);
1501        assert!((counts[1] - 4.0 / 6.0).abs() < 1e-12);
1502    }
1503
1504    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1505    #[test]
1506    fn apply_normalization_handles_weighted_pdf() {
1507        setup_plot_tests();
1508        let mut counts = vec![5.0];
1509        let widths = vec![0.5];
1510        apply_normalization(&mut counts, &widths, HistNormalization::Pdf, 10.0);
1511        // PDF height = weight / (total_weight * bin_width) = 5 / (10 * 0.5) = 1
1512        assert!((counts[0] - 1.0).abs() < 1e-12);
1513    }
1514
1515    #[test]
1516    fn hist_type_defaults_to_row_vector() {
1517        let ctx = ResolveContext::new(Vec::new());
1518        assert_eq!(hist_type(&[Type::tensor()], &ctx), row_vector_type(&ctx));
1519    }
1520
1521    #[test]
1522    fn hist_type_uses_bin_centers_length() {
1523        let ctx = ResolveContext::new(Vec::new());
1524        let out = hist_type(
1525            &[
1526                Type::tensor(),
1527                Type::Tensor {
1528                    shape: Some(vec![Some(1), Some(5)]),
1529                },
1530            ],
1531            &ctx,
1532        );
1533        assert_eq!(
1534            out,
1535            Type::Tensor {
1536                shape: Some(vec![Some(1), Some(5)])
1537            }
1538        );
1539    }
1540}