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