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