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