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, BuiltinExtensionDescriptor,
8    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
9    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
10    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
11    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
12    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
13};
14use runmat_macros::runtime_builtin;
15use runmat_plot::core::BoundingBox;
16use runmat_plot::gpu::bar::{BarGpuInputs, BarGpuParams, BarLayoutMode, BarOrientation};
17use runmat_plot::gpu::histogram::{
18    HistogramGpuInputs, HistogramGpuOutput, HistogramGpuParams, HistogramGpuWeights,
19    HistogramNormalizationMode,
20};
21use runmat_plot::gpu::ScalarType;
22use runmat_plot::plots::BarChart;
23use runmat_value::{IntValue, NumericDType, Tensor, Value};
24
25use crate::builtins::common::spec::{
26    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
27    ReductionNaN, ResidencyPolicy, ShapeRequirements,
28};
29use crate::builtins::common::tensor as tensor_utils;
30
31use super::bar::apply_bar_style;
32use super::common::{numeric_vector, value_as_f64};
33use super::state::{render_active_plot, PlotRenderOptions};
34use super::style::{parse_bar_style_args, BarStyle, BarStyleDefaults};
35use crate::builtins::plotting::gpu_helpers::{axis_bounds_async, gather_tensor_from_gpu_async};
36use crate::builtins::plotting::type_resolvers::hist_type;
37use crate::{build_runtime_error, BuiltinResult, RuntimeError};
38
39#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::hist")]
40pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
41    name: "hist",
42    op_kind: GpuOpKind::PlotRender,
43    supported_precisions: &[],
44    broadcast: BroadcastSemantics::None,
45    provider_hooks: &[],
46    constant_strategy: ConstantStrategy::InlineLiteral,
47    // Plotting is a sink, but can consume gpuArray inputs zero-copy when a shared WGPU context exists.
48    residency: ResidencyPolicy::InheritInputs,
49    nan_mode: ReductionNaN::Include,
50    two_pass_threshold: None,
51    workgroup_size: None,
52    accepts_nan_mode: false,
53    notes: "Histogram rendering terminates fusion graphs; gpuArray inputs may remain on device when shared plotting context is installed.",
54};
55
56#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::plotting::hist")]
57pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
58    name: "hist",
59    shape: ShapeRequirements::Any,
60    constant_strategy: ConstantStrategy::InlineLiteral,
61    elementwise: None,
62    reduction: None,
63    emits_nan: false,
64    notes: "hist terminates fusion graphs and produces I/O.",
65};
66
67const BUILTIN_NAME: &str = "hist";
68const HIST_BAR_WIDTH: f32 = 0.95;
69const HIST_DEFAULT_COLOR: Vec4 = Vec4::new(0.15, 0.5, 0.8, 0.95);
70const HIST_DEFAULT_LABEL: &str = "Frequency";
71
72const HIST_OUTPUT_COUNTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
73    name: "N",
74    ty: BuiltinParamType::NumericArray,
75    arity: BuiltinParamArity::Required,
76    default: None,
77    description: "Histogram bin counts.",
78}];
79const HIST_OUTPUT_COUNTS_CENTERS: [BuiltinParamDescriptor; 2] = [
80    BuiltinParamDescriptor {
81        name: "N",
82        ty: BuiltinParamType::NumericArray,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Histogram bin counts.",
86    },
87    BuiltinParamDescriptor {
88        name: "centers",
89        ty: BuiltinParamType::NumericArray,
90        arity: BuiltinParamArity::Required,
91        default: None,
92        description: "Histogram bin centers.",
93    },
94];
95
96const HIST_INPUTS_X: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
97    name: "X",
98    ty: BuiltinParamType::Any,
99    arity: BuiltinParamArity::Required,
100    default: None,
101    description: "Input sample data.",
102}];
103
104const HIST_INPUTS_X_BINS: [BuiltinParamDescriptor; 2] = [
105    BuiltinParamDescriptor {
106        name: "X",
107        ty: BuiltinParamType::Any,
108        arity: BuiltinParamArity::Required,
109        default: None,
110        description: "Input sample data.",
111    },
112    BuiltinParamDescriptor {
113        name: "bins",
114        ty: BuiltinParamType::Any,
115        arity: BuiltinParamArity::Required,
116        default: None,
117        description: "Bin count scalar or explicit center vector.",
118    },
119];
120
121const HIST_INPUTS_X_NORMALIZATION: [BuiltinParamDescriptor; 2] = [
122    BuiltinParamDescriptor {
123        name: "X",
124        ty: BuiltinParamType::Any,
125        arity: BuiltinParamArity::Required,
126        default: None,
127        description: "Input sample data.",
128    },
129    BuiltinParamDescriptor {
130        name: "normalization",
131        ty: BuiltinParamType::StringScalar,
132        arity: BuiltinParamArity::Required,
133        default: Some("count"),
134        description: "Normalization mode: count, probability, or pdf.",
135    },
136];
137
138const HIST_INPUTS_X_NAMEVALUE: [BuiltinParamDescriptor; 2] = [
139    BuiltinParamDescriptor {
140        name: "X",
141        ty: BuiltinParamType::Any,
142        arity: BuiltinParamArity::Required,
143        default: None,
144        description: "Input sample data.",
145    },
146    BuiltinParamDescriptor {
147        name: "name_value",
148        ty: BuiltinParamType::Any,
149        arity: BuiltinParamArity::Variadic,
150        default: None,
151        description: "Name/value options and style properties.",
152    },
153];
154
155const HIST_INPUTS_X_BINS_NAMEVALUE: [BuiltinParamDescriptor; 3] = [
156    BuiltinParamDescriptor {
157        name: "X",
158        ty: BuiltinParamType::Any,
159        arity: BuiltinParamArity::Required,
160        default: None,
161        description: "Input sample data.",
162    },
163    BuiltinParamDescriptor {
164        name: "bins",
165        ty: BuiltinParamType::Any,
166        arity: BuiltinParamArity::Required,
167        default: None,
168        description: "Bin count scalar or explicit center vector.",
169    },
170    BuiltinParamDescriptor {
171        name: "name_value",
172        ty: BuiltinParamType::Any,
173        arity: BuiltinParamArity::Variadic,
174        default: None,
175        description: "Additional name/value options and style properties.",
176    },
177];
178
179const HIST_SIGNATURES: [BuiltinSignatureDescriptor; 7] = [
180    BuiltinSignatureDescriptor {
181        label: "N = hist(X)",
182        inputs: &HIST_INPUTS_X,
183        outputs: &HIST_OUTPUT_COUNTS,
184    },
185    BuiltinSignatureDescriptor {
186        label: "N = hist(X, bins)",
187        inputs: &HIST_INPUTS_X_BINS,
188        outputs: &HIST_OUTPUT_COUNTS,
189    },
190    BuiltinSignatureDescriptor {
191        label: "N = hist(X, normalization)",
192        inputs: &HIST_INPUTS_X_NORMALIZATION,
193        outputs: &HIST_OUTPUT_COUNTS,
194    },
195    BuiltinSignatureDescriptor {
196        label: "N = hist(X, Name, Value, ...)",
197        inputs: &HIST_INPUTS_X_NAMEVALUE,
198        outputs: &HIST_OUTPUT_COUNTS,
199    },
200    BuiltinSignatureDescriptor {
201        label: "N = hist(X, bins, Name, Value, ...)",
202        inputs: &HIST_INPUTS_X_BINS_NAMEVALUE,
203        outputs: &HIST_OUTPUT_COUNTS,
204    },
205    BuiltinSignatureDescriptor {
206        label: "[counts, centers] = hist(X)",
207        inputs: &HIST_INPUTS_X,
208        outputs: &HIST_OUTPUT_COUNTS_CENTERS,
209    },
210    BuiltinSignatureDescriptor {
211        label: "[counts, centers] = hist(X, bins)",
212        inputs: &HIST_INPUTS_X_BINS,
213        outputs: &HIST_OUTPUT_COUNTS_CENTERS,
214    },
215];
216
217const HIST_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
218    code: "RM.HIST.INVALID_ARGUMENT",
219    identifier: Some("RunMat:hist:InvalidArgument"),
220    when: "Histogram inputs, bins, normalization, weights, or style arguments are invalid.",
221    message: "hist: invalid argument",
222};
223
224const HIST_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
225    code: "RM.HIST.INTERNAL",
226    identifier: Some("RunMat:hist:Internal"),
227    when: "Internal histogram rendering or device conversion fails.",
228    message: "hist: internal operation failed",
229};
230
231const HIST_ERRORS: [BuiltinErrorDescriptor; 2] = [HIST_ERROR_INVALID_ARGUMENT, HIST_ERROR_INTERNAL];
232
233pub const HIST_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
234    signatures: &HIST_SIGNATURES,
235    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
236    completion_policy: BuiltinCompletionPolicy::Public,
237    errors: &HIST_ERRORS,
238};
239
240const HIST_INTEGER_DATA_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
241    id: "hist-integer-data",
242    mode: BuiltinExtensionMode::RunMatOnly,
243    description: "hist with integer sample data is a RunMat extension",
244    error_identifier: Some("RunMat:compatibility:HistIntegerDataExtension"),
245};
246
247const HIST_INTEGER_CENTERS_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
248    id: "hist-integer-centers",
249    mode: BuiltinExtensionMode::RunMatOnly,
250    description: "hist with integer bin centers is a RunMat extension",
251    error_identifier: Some("RunMat:compatibility:HistIntegerCentersExtension"),
252};
253const HIST_MODERN_OPTIONS_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
254    id: "hist-modern-options",
255    mode: BuiltinExtensionMode::RunMatOnly,
256    description:
257        "hist with histogram-style normalization or name-value options is a RunMat extension",
258    error_identifier: Some("RunMat:compatibility:HistModernOptionsExtension"),
259};
260
261pub const HIST_EXTENSIONS: [BuiltinExtensionDescriptor; 3] = [
262    HIST_INTEGER_DATA_EXTENSION,
263    HIST_INTEGER_CENTERS_EXTENSION,
264    HIST_MODERN_OPTIONS_EXTENSION,
265];
266
267const HIST_INTEGER_DATA_INPUT: [BuiltinIntegerInputCapability; 1] =
268    [BuiltinIntegerInputCapability {
269        name: "X",
270        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
271        availability: BuiltinIntegerInputAvailability::RunMatOnly,
272        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
273        notes: "MATLAB documents single, double, logical, and categorical sample data for legacy hist; integer samples are compatibility-gated.",
274    }];
275const HIST_INTEGER_BIN_INPUT: [BuiltinIntegerInputCapability; 1] =
276    [BuiltinIntegerInputCapability {
277        name: "nbins",
278        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
279        availability: BuiltinIntegerInputAvailability::Documented,
280        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
281        notes: "All integer classes are documented for scalar nbins.",
282    }];
283const HIST_INTEGER_CENTERS_INPUT: [BuiltinIntegerInputCapability; 1] =
284    [BuiltinIntegerInputCapability {
285        name: "xbins",
286        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
287        availability: BuiltinIntegerInputAvailability::RunMatOnly,
288        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
289        notes: "MATLAB documents single or double numeric center vectors; typed integer centers are compatibility-gated.",
290    }];
291pub const HIST_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 3] = [
292    BuiltinIntegerCapabilityDescriptor {
293        form: "[counts, centers] = hist(integer_X, ...)",
294        inputs: &HIST_INTEGER_DATA_INPUT,
295        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
296        output_class: BuiltinIntegerOutputClassRule::Double,
297        overflow: BuiltinIntegerOverflowRule::NotApplicable,
298        backend: BuiltinIntegerBackendRule::GatherFallback,
299        overload: BuiltinIntegerOverloadKind::Multiple,
300        notes: "A gated RunMat extension; legacy hist outputs double counts and centers.",
301    },
302    BuiltinIntegerCapabilityDescriptor {
303        form: "[counts, centers] = hist(X, integer_nbins)",
304        inputs: &HIST_INTEGER_BIN_INPUT,
305        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
306        output_class: BuiltinIntegerOutputClassRule::Double,
307        overflow: BuiltinIntegerOverflowRule::NotApplicable,
308        backend: BuiltinIntegerBackendRule::HostOnly,
309        overload: BuiltinIntegerOverloadKind::StructuralParameter,
310        notes: "Scalar integer nbins is documented and parsed exactly before conversion to a platform bin count.",
311    },
312    BuiltinIntegerCapabilityDescriptor {
313        form: "[counts, centers] = hist(X, integer_xbins)",
314        inputs: &HIST_INTEGER_CENTERS_INPUT,
315        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
316        output_class: BuiltinIntegerOutputClassRule::Double,
317        overflow: BuiltinIntegerOverflowRule::NotApplicable,
318        backend: BuiltinIntegerBackendRule::HostOnly,
319        overload: BuiltinIntegerOverloadKind::StructuralParameter,
320        notes: "Integer center vectors are a gated RunMat extension and cross to the floating histogram domain after the gate.",
321    },
322];
323
324fn hist_descriptor_error(
325    error: &'static BuiltinErrorDescriptor,
326    detail: Option<impl AsRef<str>>,
327) -> RuntimeError {
328    let message = match detail {
329        Some(detail) => {
330            let raw = detail.as_ref().trim();
331            let normalized = raw.strip_prefix("hist:").map(str::trim).unwrap_or(raw);
332            if normalized.is_empty() {
333                error.message.to_string()
334            } else {
335                format!("{}: {}", error.message, normalized)
336            }
337        }
338        None => error.message.to_string(),
339    };
340    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
341    if let Some(identifier) = error.identifier {
342        builder = builder.with_identifier(identifier);
343    }
344    builder.build()
345}
346
347fn hist_invalid_argument(detail: impl AsRef<str>) -> RuntimeError {
348    hist_descriptor_error(&HIST_ERROR_INVALID_ARGUMENT, Some(detail))
349}
350
351fn hist_internal(detail: impl AsRef<str>) -> RuntimeError {
352    hist_descriptor_error(&HIST_ERROR_INTERNAL, Some(detail))
353}
354
355fn hist_err(message: impl Into<String>) -> RuntimeError {
356    hist_invalid_argument(message.into())
357}
358
359struct HistComputation {
360    counts: Vec<f64>,
361    centers: Vec<f64>,
362    output_shape: Vec<usize>,
363    charts: Vec<BarChart>,
364}
365
366/// Captures the evaluated histogram so both the renderer and MATLAB outputs share the same data.
367pub struct HistEvaluation {
368    counts: Tensor,
369    #[allow(dead_code)]
370    centers: Tensor,
371    charts: Vec<BarChart>,
372    normalization: HistNormalization,
373}
374
375impl HistEvaluation {
376    fn new(
377        counts: Vec<f64>,
378        centers: Vec<f64>,
379        output_shape: Vec<usize>,
380        charts: Vec<BarChart>,
381        normalization: HistNormalization,
382    ) -> BuiltinResult<Self> {
383        if counts.len() != centers.len() {
384            return Err(hist_internal("mismatch between counts and bin centers"));
385        }
386        let counts_tensor = Tensor::new(counts, output_shape.clone())?;
387        let centers_tensor = Tensor::new(centers, output_shape)?;
388        Ok(Self {
389            counts: counts_tensor,
390            centers: centers_tensor,
391            charts,
392            normalization,
393        })
394    }
395
396    pub fn counts_value(&self) -> Value {
397        Value::Tensor(self.counts.clone())
398    }
399
400    #[allow(dead_code)]
401    pub fn centers_value(&self) -> Value {
402        Value::Tensor(self.centers.clone())
403    }
404
405    pub fn render_plot(&self) -> BuiltinResult<()> {
406        let y_label = match self.normalization {
407            HistNormalization::Count => "Count",
408            HistNormalization::Probability => "Probability",
409            HistNormalization::Pdf => "PDF",
410        };
411        let mut charts = Some(self.charts.clone());
412        let opts = PlotRenderOptions {
413            title: "Histogram",
414            x_label: "Bin",
415            y_label,
416            ..Default::default()
417        };
418        render_active_plot(BUILTIN_NAME, opts, move |figure, axes| {
419            let charts = charts
420                .take()
421                .expect("hist charts consumed exactly once at render time");
422            for chart in charts {
423                figure.add_bar_chart_on_axes(chart, axes);
424            }
425            Ok(())
426        })?;
427        Ok(())
428    }
429}
430
431impl HistComputation {
432    fn into_evaluation(self, normalization: HistNormalization) -> BuiltinResult<HistEvaluation> {
433        HistEvaluation::new(
434            self.counts,
435            self.centers,
436            self.output_shape,
437            self.charts,
438            normalization,
439        )
440    }
441}
442
443#[derive(Clone)]
444enum HistBinSpec {
445    Auto,
446    Count(usize),
447    Centers(Vec<f64>),
448    Edges(Vec<f64>),
449}
450
451#[derive(Clone)]
452struct HistBinOptions {
453    spec: HistBinSpec,
454    bin_width: Option<f64>,
455    bin_limits: Option<(f64, f64)>,
456    bin_method: Option<HistBinMethod>,
457}
458
459impl HistBinOptions {
460    fn new(spec: HistBinSpec) -> Self {
461        Self {
462            spec,
463            bin_width: None,
464            bin_limits: None,
465            bin_method: None,
466        }
467    }
468
469    fn is_uniform(&self) -> bool {
470        match &self.spec {
471            HistBinSpec::Edges(edges) => uniform_edge_width(edges).is_some(),
472            _ => true,
473        }
474    }
475}
476
477#[derive(Clone, Copy)]
478enum HistBinMethod {
479    Sqrt,
480    Sturges,
481    Integers,
482}
483
484#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
485enum HistNormalization {
486    #[default]
487    Count,
488    Probability,
489    Pdf,
490}
491
492#[derive(Clone)]
493enum HistWeightsInput {
494    None,
495    Host(Tensor),
496    Gpu(GpuTensorHandle),
497}
498
499impl HistWeightsInput {
500    fn gpu_fast_path_eligible(&self, samples: &GpuTensorHandle) -> bool {
501        match self {
502            Self::None | Self::Host(_) => true,
503            Self::Gpu(handle) => {
504                let same_provider = match (
505                    runmat_accelerate_api::provider_for_handle(handle),
506                    runmat_accelerate_api::provider_for_handle(samples),
507                ) {
508                    (Some(weights_provider), Some(samples_provider)) => {
509                        std::ptr::eq(weights_provider, samples_provider)
510                    }
511                    _ => false,
512                };
513                handle.device_id == samples.device_id
514                    && same_provider
515                    && runmat_accelerate_api::handle_integer_type(handle).is_none()
516                    && !runmat_accelerate_api::handle_is_logical(handle)
517            }
518        }
519    }
520
521    fn from_value(value: Value, expected_len: usize) -> BuiltinResult<Self> {
522        match value {
523            Value::GpuTensor(handle) => {
524                let len: usize = handle.shape.iter().product();
525                if len != expected_len {
526                    return Err(hist_err(format!(
527                        "hist: Weights must contain {expected_len} elements (got {len})"
528                    )));
529                }
530                Ok(HistWeightsInput::Gpu(handle))
531            }
532            other => {
533                let tensor = tensor_utils::value_into_tensor_for("hist Weights", other)
534                    .map_err(|e| hist_err(format!("hist: Weights {e}")))?;
535                let len = tensor_utils::tensor_element_len(&tensor);
536                if len != expected_len {
537                    return Err(hist_err(format!(
538                        "hist: Weights must contain {expected_len} elements (got {})",
539                        len
540                    )));
541                }
542                Ok(HistWeightsInput::Host(tensor))
543            }
544        }
545    }
546
547    async fn resolve_for_cpu_async(
548        &self,
549        context: &'static str,
550        sample_len: usize,
551    ) -> BuiltinResult<(Option<Vec<f64>>, f64)> {
552        match self {
553            HistWeightsInput::None => Ok((None, sample_len as f64)),
554            HistWeightsInput::Host(tensor) => {
555                let values = numeric_vector(tensor.clone());
556                let total = values.iter().copied().sum::<f64>();
557                Ok((Some(values), total))
558            }
559            HistWeightsInput::Gpu(handle) => {
560                let tensor = gather_tensor_from_gpu_async(handle.clone(), context).await?;
561                let values = numeric_vector(tensor);
562                let total = values.iter().copied().sum::<f64>();
563                Ok((Some(values), total))
564            }
565        }
566    }
567
568    fn total_weight_hint(&self, sample_len: usize) -> Option<f64> {
569        match self {
570            HistWeightsInput::None => Some(sample_len as f64),
571            HistWeightsInput::Host(tensor) => {
572                let values = numeric_vector(tensor.clone());
573                Some(values.iter().copied().sum::<f64>())
574            }
575            HistWeightsInput::Gpu(_) => None,
576        }
577    }
578
579    fn to_gpu_weights(&self, sample_len: usize) -> BuiltinResult<HistogramGpuWeights> {
580        match self {
581            HistWeightsInput::None => Ok(HistogramGpuWeights::Uniform {
582                total_weight: sample_len as f32,
583            }),
584            HistWeightsInput::Host(tensor) => {
585                let values = numeric_vector(tensor.clone());
586                let total = values.iter().copied().sum::<f64>() as f32;
587                match tensor.numeric_dtype() {
588                    NumericDType::F32 => {
589                        let data: Vec<f32> = values.iter().map(|v| *v as f32).collect();
590                        Ok(HistogramGpuWeights::HostF32 {
591                            data,
592                            total_weight: total,
593                        })
594                    }
595                    NumericDType::F64 => Ok(HistogramGpuWeights::HostF64 {
596                        data: values,
597                        total_weight: total,
598                    }),
599                    NumericDType::I8
600                    | NumericDType::I16
601                    | NumericDType::I32
602                    | NumericDType::I64
603                    | NumericDType::U8
604                    | NumericDType::U16
605                    | NumericDType::U32
606                    | NumericDType::U64 => Ok(HistogramGpuWeights::HostF64 {
607                        data: values,
608                        total_weight: total,
609                    }),
610                }
611            }
612            HistWeightsInput::Gpu(handle) => {
613                let exported = runmat_accelerate_api::export_wgpu_buffer(handle)
614                    .ok_or_else(|| hist_internal("unable to export GPU weights"))?;
615                match exported.precision {
616                    ProviderPrecision::F32 => Ok(HistogramGpuWeights::GpuF32 {
617                        buffer: exported.buffer.clone(),
618                    }),
619                    ProviderPrecision::F64 => Ok(HistogramGpuWeights::GpuF64 {
620                        buffer: exported.buffer.clone(),
621                    }),
622                }
623            }
624        }
625    }
626}
627
628#[runtime_builtin(
629    name = "hist",
630    category = "plotting",
631    summary = "Create legacy center-based histograms.",
632    keywords = "hist,histogram,frequency",
633    sink = true,
634    suppress_auto_output = true,
635    type_resolver(hist_type),
636    descriptor(crate::builtins::plotting::hist::HIST_DESCRIPTOR),
637    extensions(crate::builtins::plotting::hist::HIST_EXTENSIONS),
638    integer_capabilities(crate::builtins::plotting::hist::HIST_INTEGER_CAPABILITIES),
639    builtin_path = "crate::builtins::plotting::hist"
640)]
641pub async fn hist_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
642    if value_has_integer_storage(&data) {
643        crate::compatibility::ensure_builtin_extension_enabled(
644            &HIST_INTEGER_DATA_EXTENSION,
645            BUILTIN_NAME,
646        )?;
647    }
648    if rest
649        .iter()
650        .any(|value| matches!(value, Value::String(_) | Value::CharArray(_)))
651    {
652        crate::compatibility::ensure_builtin_extension_enabled(
653            &HIST_MODERN_OPTIONS_EXTENSION,
654            BUILTIN_NAME,
655        )?;
656    }
657    let evaluation = evaluate_async(data, &rest).await?;
658    evaluation.render_plot()?;
659    match crate::output_count::current_output_count() {
660        Some(0) => Ok(Value::OutputList(Vec::new())),
661        Some(1) => Ok(Value::OutputList(vec![evaluation.counts_value()])),
662        Some(2) => Ok(Value::OutputList(vec![
663            evaluation.counts_value(),
664            evaluation.centers_value(),
665        ])),
666        Some(_) => Err(hist_err("hist: too many output arguments")),
667        None => Ok(evaluation.counts_value()),
668    }
669}
670
671fn value_has_integer_storage(value: &Value) -> bool {
672    matches!(value, Value::Int(_))
673        || matches!(value, Value::Tensor(tensor) if tensor.integer_storage().is_some())
674        || matches!(value, Value::GpuTensor(handle) if runmat_accelerate_api::handle_integer_type(handle).is_some())
675}
676
677/// Evaluate the histogram inputs once so renderers and MATLAB outputs share the same data.
678pub async fn evaluate_async(data: Value, rest: &[Value]) -> BuiltinResult<HistEvaluation> {
679    let mut input = Some(HistInput::from_value(data)?);
680    let sample_len = input.as_ref().map(|value| value.len()).unwrap_or(0);
681    let (bin_options, normalization, style_args, weights_value) =
682        parse_hist_arguments(sample_len, rest)?;
683    let defaults = BarStyleDefaults::new(HIST_DEFAULT_COLOR, HIST_BAR_WIDTH);
684    let bar_style = parse_bar_style_args("hist", &style_args, defaults)?;
685    let weights_input = if let Some(value) = weights_value {
686        HistWeightsInput::from_value(value, sample_len)?
687    } else {
688        HistWeightsInput::None
689    };
690
691    let computation = if !bar_style.requires_cpu_path() {
692        if let Some(handle) = input.as_ref().and_then(|value| value.vector_gpu_handle()) {
693            if bin_options.is_uniform() && weights_input.gpu_fast_path_eligible(handle) {
694                match build_histogram_gpu_chart_async(
695                    handle,
696                    &bin_options,
697                    sample_len,
698                    normalization,
699                    &bar_style,
700                    &weights_input,
701                )
702                .await
703                {
704                    Ok(chart) => Some(chart),
705                    Err(err) => {
706                        warn!("hist GPU path unavailable: {err}");
707                        None
708                    }
709                }
710            } else {
711                None
712            }
713        } else {
714            None
715        }
716    } else {
717        None
718    };
719
720    let computation = match computation {
721        Some(chart) => chart,
722        None => {
723            let data_arg = input.take().expect("hist input consumed once");
724            let tensor = match data_arg {
725                HistInput::Host(tensor) => tensor,
726                HistInput::Gpu(handle) => gather_tensor_from_gpu_async(handle, "hist").await?,
727            };
728            let tensor_shape = tensor.shape.clone();
729            let samples = numeric_vector(tensor);
730            let (weight_values, total_weight) = weights_input
731                .resolve_for_cpu_async("hist weights", sample_len)
732                .await?;
733            build_histogram_charts(
734                samples,
735                &tensor_shape,
736                &bin_options,
737                normalization,
738                weight_values.as_deref(),
739                total_weight,
740            )?
741        }
742    };
743
744    let mut evaluation = computation.into_evaluation(normalization)?;
745    let chart_count = evaluation.charts.len();
746    for (index, chart) in evaluation.charts.iter_mut().enumerate() {
747        apply_bar_style(chart, &bar_style, HIST_DEFAULT_LABEL);
748        if chart_count > 1 {
749            chart.group_index = index;
750            chart.group_count = chart_count;
751        }
752    }
753    Ok(evaluation)
754}
755
756fn parse_hist_arguments(
757    sample_len: usize,
758    args: &[Value],
759) -> BuiltinResult<(HistBinOptions, HistNormalization, Vec<Value>, Option<Value>)> {
760    let mut idx = 0usize;
761    let mut bin_options = HistBinOptions::new(HistBinSpec::Auto);
762    let mut bin_set = false;
763    let mut normalization = HistNormalization::Count;
764    let mut norm_set = false;
765    let mut style_args = Vec::new();
766    let mut weights_value: Option<Value> = None;
767
768    while idx < args.len() {
769        let arg = &args[idx];
770        if !bin_set && is_bin_candidate(arg) {
771            let spec = parse_hist_bins(Some(arg.clone()), sample_len)?;
772            ensure_spec_compatible(&spec, &bin_options, "bin argument")?;
773            bin_options.spec = spec;
774            bin_set = true;
775            idx += 1;
776            continue;
777        }
778
779        if !norm_set {
780            if let Some(result) = try_parse_norm_literal(arg) {
781                normalization = result?;
782                norm_set = true;
783                idx += 1;
784                continue;
785            }
786        }
787
788        let Some(key) = value_as_string(arg) else {
789            style_args.extend_from_slice(&args[idx..]);
790            break;
791        };
792        if idx + 1 >= args.len() {
793            return Err(hist_err(format!("hist: missing value for '{key}' option")));
794        }
795        let value = args[idx + 1].clone();
796        let lower = key.trim().to_ascii_lowercase();
797        match lower.as_str() {
798            "normalization" => {
799                normalization = parse_hist_normalization(Some(value))?;
800                norm_set = true;
801            }
802            "binedges" => {
803                if bin_set {
804                    return Err(hist_err(
805                        "hist: specify either bins argument or 'BinEdges', not both",
806                    ));
807                }
808                let edges = parse_bin_edges_value(value)?;
809                ensure_spec_compatible(
810                    &HistBinSpec::Edges(edges.clone()),
811                    &bin_options,
812                    "BinEdges",
813                )?;
814                bin_options.spec = HistBinSpec::Edges(edges);
815                bin_set = true;
816            }
817            "numbins" => {
818                if bin_set {
819                    return Err(hist_err(
820                        "hist: NumBins cannot be combined with explicit bins",
821                    ));
822                }
823                let count = parse_num_bins_value(&value)?;
824                ensure_spec_compatible(&HistBinSpec::Count(count), &bin_options, "NumBins")?;
825                bin_options.spec = HistBinSpec::Count(count);
826                bin_set = true;
827            }
828            "binwidth" => {
829                if bin_set {
830                    return Err(hist_err(
831                        "hist: BinWidth cannot be combined with explicit bins",
832                    ));
833                }
834                ensure_no_explicit_bins(&bin_options, "BinWidth")?;
835                if bin_options.bin_width.is_some() {
836                    return Err(hist_err("hist: BinWidth specified more than once"));
837                }
838                let width = parse_positive_scalar(
839                    &value,
840                    "hist: BinWidth must be a positive finite scalar",
841                )?;
842                bin_options.bin_width = Some(width);
843            }
844            "binlimits" => {
845                ensure_no_explicit_bins(&bin_options, "BinLimits")?;
846                if bin_options.bin_limits.is_some() {
847                    return Err(hist_err("hist: BinLimits specified more than once"));
848                }
849                let limits = parse_bin_limits_value(value)?;
850                bin_options.bin_limits = Some(limits);
851            }
852            "binmethod" => {
853                if bin_options.bin_width.is_some() {
854                    return Err(hist_err("hist: BinMethod cannot be combined with BinWidth"));
855                }
856                ensure_no_explicit_bins(&bin_options, "BinMethod")?;
857                if bin_options.bin_method.is_some() {
858                    return Err(hist_err("hist: BinMethod specified more than once"));
859                }
860                let method = parse_hist_bin_method(&value)?;
861                bin_options.bin_method = Some(method);
862            }
863            "weights" => {
864                if weights_value.is_some() {
865                    return Err(hist_err("hist: Weights specified more than once"));
866                }
867                weights_value = Some(value);
868            }
869            _ => {
870                style_args.push(arg.clone());
871                style_args.push(value);
872            }
873        }
874        idx += 2;
875    }
876
877    Ok((bin_options, normalization, style_args, weights_value))
878}
879
880fn parse_hist_bins(arg: Option<Value>, sample_len: usize) -> BuiltinResult<HistBinSpec> {
881    if arg.as_ref().is_some_and(|value| {
882        matches!(value, Value::Tensor(tensor) if tensor.integer_storage().is_some() && !tensor_utils::is_scalar_tensor(tensor))
883    }) {
884        crate::compatibility::ensure_builtin_extension_enabled(
885            &HIST_INTEGER_CENTERS_EXTENSION,
886            BUILTIN_NAME,
887        )?;
888    }
889    let spec = match arg {
890        None => HistBinSpec::Auto,
891        Some(Value::Tensor(tensor)) => parse_center_vector(tensor)?,
892        Some(Value::Int(value)) => parse_integer_bin_count(&value)?,
893        Some(Value::GpuTensor(_)) => {
894            return Err(hist_err("hist: bin definitions must reside on the host"))
895        }
896        Some(other) => {
897            if let Some(numeric) = value_as_f64(&other) {
898                parse_bin_count_value(numeric)?
899            } else {
900                return Err(hist_err(
901                    "hist: bin argument must be a scalar count or a vector of centers",
902                ));
903            }
904        }
905    };
906    Ok(match spec {
907        HistBinSpec::Count(0) => HistBinSpec::Count(default_bin_count(sample_len)),
908        other => other,
909    })
910}
911
912#[derive(Clone, Copy)]
913struct HistDataStats {
914    min: Option<f64>,
915    max: Option<f64>,
916}
917
918impl HistDataStats {
919    fn from_samples(samples: &[f64]) -> Self {
920        let mut min: Option<f64> = None;
921        let mut max: Option<f64> = None;
922        for &value in samples {
923            if value.is_nan() {
924                continue;
925            }
926            min = Some(match min {
927                Some(current) => current.min(value),
928                None => value,
929            });
930            max = Some(match max {
931                Some(current) => current.max(value),
932                None => value,
933            });
934        }
935        Self { min, max }
936    }
937}
938
939struct RealizedBins {
940    edges: Vec<f64>,
941    widths: Vec<f64>,
942    labels: Vec<String>,
943    centers: Vec<f64>,
944    uniform_width: Option<f64>,
945}
946
947impl RealizedBins {
948    fn from_edges(edges: Vec<f64>) -> BuiltinResult<Self> {
949        if edges.len() < 2 {
950            return Err(hist_err(
951                "hist: bin definitions must contain at least two edges",
952            ));
953        }
954        let widths = widths_from_edges(&edges);
955        let labels = histogram_labels_from_edges(&edges);
956        let centers = centers_from_edges(&edges);
957        let uniform_width = if widths.iter().all(|w| approx_equal(*w, widths[0])) {
958            Some(widths[0])
959        } else {
960            None
961        };
962        Ok(Self {
963            edges,
964            widths,
965            labels,
966            centers,
967            uniform_width,
968        })
969    }
970
971    fn bin_count(&self) -> usize {
972        self.widths.len()
973    }
974}
975
976fn realize_bins(
977    options: &HistBinOptions,
978    sample_len: usize,
979    stats: Option<&HistDataStats>,
980    fallback_value: Option<f64>,
981) -> BuiltinResult<RealizedBins> {
982    match &options.spec {
983        HistBinSpec::Centers(centers) => {
984            let edges = edges_from_centers(centers)?;
985            RealizedBins::from_edges(edges)
986        }
987        HistBinSpec::Edges(edges) => RealizedBins::from_edges(edges.clone()),
988        _ => {
989            if matches!(options.bin_method, Some(HistBinMethod::Integers)) {
990                let edges = integer_edges(options, stats, fallback_value)?;
991                return RealizedBins::from_edges(edges);
992            }
993            let edges = uniform_edges_from_options(options, sample_len, stats, fallback_value)?;
994            RealizedBins::from_edges(edges)
995        }
996    }
997}
998
999fn integer_edges(
1000    options: &HistBinOptions,
1001    stats: Option<&HistDataStats>,
1002    fallback_value: Option<f64>,
1003) -> BuiltinResult<Vec<f64>> {
1004    let (lower, upper) = determine_limits(options, stats, fallback_value)?;
1005    let start = lower.floor();
1006    let mut end = upper.ceil();
1007    if approx_equal(start, end) {
1008        end = start + 1.0;
1009    }
1010    if end <= start {
1011        end = start + 1.0;
1012    }
1013    let mut edges = Vec::new();
1014    let mut current = start;
1015    while current <= end {
1016        edges.push(current);
1017        current += 1.0;
1018    }
1019    if edges.len() < 2 {
1020        edges.push(edges[0] + 1.0);
1021    }
1022    Ok(edges)
1023}
1024
1025fn uniform_edges_from_options(
1026    options: &HistBinOptions,
1027    sample_len: usize,
1028    stats: Option<&HistDataStats>,
1029    fallback_value: Option<f64>,
1030) -> BuiltinResult<Vec<f64>> {
1031    let (mut lower, mut upper) = determine_limits(options, stats, fallback_value)?;
1032    if !lower.is_finite() || !upper.is_finite() {
1033        lower = -0.5;
1034        upper = 0.5;
1035    }
1036    if approx_equal(lower, upper) {
1037        upper = lower + 1.0;
1038    }
1039    if let Some(width) = options.bin_width {
1040        let bins = ((upper - lower) / width).ceil().max(1.0) as usize;
1041        let mut edges = Vec::with_capacity(bins + 1);
1042        for i in 0..=bins {
1043            edges.push(lower + width * i as f64);
1044        }
1045        if let Some(last) = edges.last_mut() {
1046            *last = upper;
1047        }
1048        return Ok(edges);
1049    }
1050    let span = (upper - lower).abs();
1051    let bin_count = determine_bin_count(options, sample_len)?;
1052    let mut edges = Vec::with_capacity(bin_count + 1);
1053    let step = if bin_count == 0 {
1054        1.0
1055    } else {
1056        span / bin_count as f64
1057    };
1058    for i in 0..=bin_count {
1059        edges.push(lower + step * i as f64);
1060    }
1061    if let Some(last) = edges.last_mut() {
1062        *last = upper;
1063    }
1064    Ok(edges)
1065}
1066
1067fn widths_from_edges(edges: &[f64]) -> Vec<f64> {
1068    edges
1069        .windows(2)
1070        .map(|pair| (pair[1] - pair[0]).max(f64::MIN_POSITIVE))
1071        .collect()
1072}
1073
1074fn determine_limits(
1075    options: &HistBinOptions,
1076    stats: Option<&HistDataStats>,
1077    fallback_value: Option<f64>,
1078) -> BuiltinResult<(f64, f64)> {
1079    if let Some((lo, hi)) = options.bin_limits {
1080        if hi <= lo {
1081            return Err(hist_err("hist: BinLimits must be increasing"));
1082        }
1083        return Ok((lo, hi));
1084    }
1085    if let Some(stats) = stats {
1086        if let (Some(min), Some(max)) = (stats.min, stats.max) {
1087            if approx_equal(min, max) {
1088                let span = options.bin_width.unwrap_or(1.0);
1089                return Ok((min - span * 0.5, min + span * 0.5));
1090            }
1091            return Ok((min, max));
1092        }
1093    }
1094    let center = fallback_value.unwrap_or(0.0);
1095    let span = options.bin_width.unwrap_or(1.0);
1096    Ok((center - span * 0.5, center + span * 0.5))
1097}
1098
1099fn determine_bin_count(options: &HistBinOptions, sample_len: usize) -> BuiltinResult<usize> {
1100    if let HistBinSpec::Count(count) = options.spec {
1101        return Ok(count.max(1));
1102    }
1103    if let Some(method) = options.bin_method {
1104        return Ok(match method {
1105            HistBinMethod::Sqrt => sqrt_bin_count(sample_len),
1106            HistBinMethod::Sturges => sturges_bin_count(sample_len),
1107            HistBinMethod::Integers => {
1108                return Err(hist_internal("internal integer bin method misuse"))
1109            }
1110        });
1111    }
1112    Ok(default_bin_count(sample_len))
1113}
1114
1115fn sqrt_bin_count(sample_len: usize) -> usize {
1116    ((sample_len as f64).sqrt().ceil() as usize).max(1)
1117}
1118
1119fn sturges_bin_count(sample_len: usize) -> usize {
1120    let n = sample_len.max(1) as f64;
1121    ((n.log2().ceil() + 1.0) as usize).max(1)
1122}
1123
1124fn approx_equal(a: f64, b: f64) -> bool {
1125    (a - b).abs() <= 1e-9
1126}
1127
1128fn ensure_spec_compatible(
1129    new_spec: &HistBinSpec,
1130    options: &HistBinOptions,
1131    source: &str,
1132) -> BuiltinResult<()> {
1133    if matches!(new_spec, HistBinSpec::Centers(_) | HistBinSpec::Edges(_))
1134        && (options.bin_width.is_some()
1135            || options.bin_method.is_some()
1136            || options.bin_limits.is_some())
1137    {
1138        return Err(hist_err(format!(
1139            "hist: {source} cannot be combined with BinWidth, BinLimits, or BinMethod"
1140        )));
1141    }
1142    Ok(())
1143}
1144
1145fn ensure_no_explicit_bins(options: &HistBinOptions, source: &str) -> BuiltinResult<()> {
1146    if matches!(
1147        options.spec,
1148        HistBinSpec::Centers(_) | HistBinSpec::Edges(_)
1149    ) {
1150        return Err(hist_err(format!(
1151            "hist: {source} cannot be combined with explicit bin centers or edges"
1152        )));
1153    }
1154    Ok(())
1155}
1156
1157fn parse_num_bins_value(value: &Value) -> BuiltinResult<usize> {
1158    if let Some(count) = exact_integer_scalar(value) {
1159        return parse_integer_num_bins(&count);
1160    }
1161    let Some(scalar) = value_as_f64(value) else {
1162        return Err(hist_err("hist: NumBins must be a numeric scalar"));
1163    };
1164    if !scalar.is_finite() || scalar <= 0.0 {
1165        return Err(hist_err("hist: NumBins must be a positive finite scalar"));
1166    }
1167    let rounded = scalar.round();
1168    if (scalar - rounded).abs() > 1e-9 {
1169        return Err(hist_err("hist: NumBins must be an integer"));
1170    }
1171    if rounded > usize::MAX as f64 || (usize::BITS == 64 && rounded == usize::MAX as f64) {
1172        return Err(hist_err("hist: NumBins is too large"));
1173    }
1174    Ok(rounded as usize)
1175}
1176
1177fn parse_positive_scalar(value: &Value, err: &str) -> BuiltinResult<f64> {
1178    let Some(scalar) = value_as_f64(value) else {
1179        return Err(hist_err(err));
1180    };
1181    if !scalar.is_finite() || scalar <= 0.0 {
1182        return Err(hist_err(err));
1183    }
1184
1185    Ok(scalar)
1186}
1187
1188fn parse_bin_limits_value(value: Value) -> BuiltinResult<(f64, f64)> {
1189    let tensor = Tensor::try_from(&value)
1190        .map_err(|_| hist_err("hist: BinLimits must be provided as a numeric vector"))?;
1191    let values = numeric_vector(tensor);
1192    if values.len() != 2 {
1193        return Err(hist_err(
1194            "hist: BinLimits must contain exactly two elements",
1195        ));
1196    }
1197    let lo = values[0];
1198    let hi = values[1];
1199    if !lo.is_finite() || !hi.is_finite() {
1200        return Err(hist_err("hist: BinLimits must be finite"));
1201    }
1202    if hi <= lo {
1203        return Err(hist_err("hist: BinLimits must be increasing"));
1204    }
1205    Ok((lo, hi))
1206}
1207
1208fn parse_hist_bin_method(value: &Value) -> BuiltinResult<HistBinMethod> {
1209    let Some(text) = value_as_string(value) else {
1210        return Err(hist_err("hist: BinMethod must be a string"));
1211    };
1212    match text.trim().to_ascii_lowercase().as_str() {
1213        "sqrt" => Ok(HistBinMethod::Sqrt),
1214        "sturges" => Ok(HistBinMethod::Sturges),
1215        "integers" => Ok(HistBinMethod::Integers),
1216        other => Err(hist_err(format!(
1217            "hist: BinMethod '{other}' is not supported yet (supported: 'sqrt', 'sturges', 'integers')"
1218        ))),
1219    }
1220}
1221
1222fn parse_center_vector(tensor: Tensor) -> BuiltinResult<HistBinSpec> {
1223    let len = tensor_utils::tensor_element_len(&tensor);
1224    if len == 0 {
1225        return Err(hist_err("hist: bin center array cannot be empty"));
1226    }
1227    if len == 1 {
1228        if let Some(value) = tensor
1229            .integer_storage()
1230            .and_then(|storage| storage.value_at(0))
1231        {
1232            return parse_integer_bin_count(&value);
1233        }
1234        return parse_bin_count_value(tensor_utils::tensor_value_f64(&tensor, 0));
1235    }
1236    let values = numeric_vector(tensor);
1237    validate_monotonic(&values)?;
1238    ensure_uniform_spacing(&values)?;
1239    Ok(HistBinSpec::Centers(values))
1240}
1241
1242fn parse_bin_count_value(value: f64) -> BuiltinResult<HistBinSpec> {
1243    if !value.is_finite() || value <= 0.0 {
1244        return Err(hist_err("hist: bin count must be positive"));
1245    }
1246    let rounded = value.round();
1247    if (value - rounded).abs() > 1e-9 {
1248        return Err(hist_err("hist: bin count must be an integer"));
1249    }
1250    if rounded > usize::MAX as f64 || (usize::BITS == 64 && rounded == usize::MAX as f64) {
1251        return Err(hist_err("hist: bin count is too large"));
1252    }
1253    Ok(HistBinSpec::Count(rounded as usize))
1254}
1255
1256fn exact_integer_scalar(value: &Value) -> Option<IntValue> {
1257    match value {
1258        Value::Int(value) => Some(value.clone()),
1259        Value::Tensor(tensor) if tensor_utils::is_scalar_tensor(tensor) => tensor
1260            .integer_storage()
1261            .and_then(|storage| storage.value_at(0)),
1262        _ => None,
1263    }
1264}
1265
1266fn parse_integer_num_bins(value: &IntValue) -> BuiltinResult<usize> {
1267    let Some(count) = value.try_to_usize() else {
1268        return Err(hist_err("hist: NumBins must be a positive finite scalar"));
1269    };
1270    if count == 0 {
1271        return Err(hist_err("hist: NumBins must be a positive finite scalar"));
1272    }
1273    Ok(count)
1274}
1275
1276fn parse_integer_bin_count(value: &IntValue) -> BuiltinResult<HistBinSpec> {
1277    let Some(count) = value.try_to_usize() else {
1278        return Err(hist_err("hist: bin count must be positive"));
1279    };
1280    if count == 0 {
1281        return Err(hist_err("hist: bin count must be positive"));
1282    }
1283    Ok(HistBinSpec::Count(count))
1284}
1285
1286fn is_bin_candidate(value: &Value) -> bool {
1287    matches!(
1288        value,
1289        Value::Tensor(_) | Value::Num(_) | Value::Int(_) | Value::Bool(_)
1290    )
1291}
1292
1293fn try_parse_norm_literal(value: &Value) -> Option<BuiltinResult<HistNormalization>> {
1294    match value {
1295        Value::String(_) | Value::CharArray(_) => {
1296            let cloned = value.clone();
1297            match parse_hist_normalization(Some(cloned)) {
1298                Ok(norm) => Some(Ok(norm)),
1299                Err(_) => None,
1300            }
1301        }
1302        _ => None,
1303    }
1304}
1305
1306fn parse_bin_edges_value(value: Value) -> BuiltinResult<Vec<f64>> {
1307    match value {
1308        Value::Tensor(tensor) => {
1309            let edges = numeric_vector(tensor);
1310            if edges.len() < 2 {
1311                return Err(hist_err(
1312                    "hist: 'BinEdges' must contain at least two elements",
1313                ));
1314            }
1315            validate_monotonic(&edges)?;
1316            Ok(edges)
1317        }
1318        Value::GpuTensor(_) => Err(hist_err("hist: 'BinEdges' must be provided on the host")),
1319        _ => Err(hist_err("hist: 'BinEdges' expects a numeric vector")),
1320    }
1321}
1322
1323fn ensure_uniform_spacing(values: &[f64]) -> BuiltinResult<()> {
1324    if values.len() <= 2 {
1325        return Ok(());
1326    }
1327    let mut diffs = values.windows(2).map(|pair| pair[1] - pair[0]);
1328    let first = diffs.next().unwrap();
1329    if first <= 0.0 || !first.is_finite() {
1330        return Err(hist_err("hist: bin centers must be strictly increasing"));
1331    }
1332    let tol = first.abs().max(1.0) * 1e-6;
1333    for diff in diffs {
1334        if (diff - first).abs() > tol {
1335            return Err(hist_err("hist: bin centers must be evenly spaced"));
1336        }
1337    }
1338    Ok(())
1339}
1340
1341fn uniform_edge_width(edges: &[f64]) -> Option<f64> {
1342    if edges.len() < 2 {
1343        return None;
1344    }
1345    let mut diffs = edges.windows(2).map(|pair| pair[1] - pair[0]);
1346    let first = diffs.next().unwrap();
1347    if first <= 0.0 || !first.is_finite() {
1348        return None;
1349    }
1350    let tol = first.abs().max(1.0) * 1e-5;
1351    for diff in diffs {
1352        if diff <= 0.0 || !diff.is_finite() {
1353            return None;
1354        }
1355        if (diff - first).abs() > tol {
1356            return None;
1357        }
1358    }
1359    Some(first)
1360}
1361
1362fn parse_hist_normalization(arg: Option<Value>) -> BuiltinResult<HistNormalization> {
1363    match arg {
1364        None => Ok(HistNormalization::Count),
1365        Some(Value::String(s)) => parse_norm_string(&s),
1366        Some(Value::CharArray(chars)) => {
1367            let text: String = chars.data.iter().collect();
1368            parse_norm_string(&text)
1369        }
1370        Some(value) => {
1371            if let Some(text) = value_as_string(&value) {
1372                parse_norm_string(&text)
1373            } else {
1374                Err(hist_err(
1375                    "hist: normalization must be 'count', 'probability', or 'pdf'",
1376                ))
1377            }
1378        }
1379    }
1380}
1381
1382fn parse_norm_string(text: &str) -> BuiltinResult<HistNormalization> {
1383    match text.trim().to_ascii_lowercase().as_str() {
1384        "count" | "counts" => Ok(HistNormalization::Count),
1385        "probability" | "prob" => Ok(HistNormalization::Probability),
1386        "pdf" => Ok(HistNormalization::Pdf),
1387        other => Err(hist_err(format!(
1388            "hist: unsupported normalization '{other}' (expected 'count', 'probability', or 'pdf')"
1389        ))),
1390    }
1391}
1392
1393fn value_as_string(value: &Value) -> Option<String> {
1394    match value {
1395        Value::String(s) => Some(s.clone()),
1396        Value::CharArray(chars) => Some(chars.data.iter().collect()),
1397        _ => None,
1398    }
1399}
1400
1401fn default_bin_count(sample_len: usize) -> usize {
1402    let _ = sample_len;
1403    10
1404}
1405
1406fn build_histogram_charts(
1407    data: Vec<f64>,
1408    shape: &[usize],
1409    bin_options: &HistBinOptions,
1410    normalization: HistNormalization,
1411    weights: Option<&[f64]>,
1412    total_weight: f64,
1413) -> BuiltinResult<HistComputation> {
1414    let rows = shape.first().copied().unwrap_or(data.len());
1415    let columns = if shape.len() >= 2 {
1416        shape[1..].iter().copied().product::<usize>()
1417    } else {
1418        1
1419    };
1420    if columns <= 1 {
1421        return build_histogram_chart(data, bin_options, normalization, weights, total_weight);
1422    }
1423
1424    let column_local_bins = matches!(bin_options.spec, HistBinSpec::Auto | HistBinSpec::Count(_))
1425        && bin_options.bin_width.is_none()
1426        && bin_options.bin_limits.is_none()
1427        && bin_options.bin_method.is_none();
1428    let shared_bins = if column_local_bins {
1429        None
1430    } else {
1431        let stats = HistDataStats::from_samples(&data);
1432        Some(realize_bins(
1433            bin_options,
1434            rows,
1435            Some(&stats),
1436            data.first().copied(),
1437        )?)
1438    };
1439    let expected_bin_count = shared_bins
1440        .as_ref()
1441        .map(RealizedBins::bin_count)
1442        .unwrap_or_else(|| match bin_options.spec {
1443            HistBinSpec::Count(count) => count.max(1),
1444            _ => default_bin_count(rows),
1445        });
1446    let mut all_counts = Vec::with_capacity(expected_bin_count * columns);
1447    let mut all_centers = Vec::with_capacity(expected_bin_count * columns);
1448    let mut charts = Vec::with_capacity(columns);
1449    for column in 0..columns {
1450        let start = column
1451            .checked_mul(rows)
1452            .ok_or_else(|| hist_internal("matrix column offset overflow"))?;
1453        let end = start
1454            .checked_add(rows)
1455            .ok_or_else(|| hist_internal("matrix column extent overflow"))?;
1456        let samples = data
1457            .get(start..end)
1458            .ok_or_else(|| hist_internal("matrix data does not match its shape"))?;
1459        let local_bins;
1460        let bins = if let Some(shared) = shared_bins.as_ref() {
1461            shared
1462        } else {
1463            let stats = HistDataStats::from_samples(samples);
1464            local_bins = realize_bins(bin_options, rows, Some(&stats), samples.first().copied())?;
1465            &local_bins
1466        };
1467        if bins.bin_count() != expected_bin_count {
1468            return Err(hist_internal(
1469                "matrix columns produced incompatible histogram widths",
1470            ));
1471        }
1472        let column_weights = weights.and_then(|values| values.get(start..end));
1473        let column_total = column_weights
1474            .map(|values| values.iter().copied().sum())
1475            .unwrap_or(rows as f64);
1476        let mut counts = vec![0.0; bins.bin_count()];
1477        for (sample_index, value) in samples.iter().enumerate() {
1478            let bin_index = find_bin_index(&bins.edges, *value);
1479            counts[bin_index] += column_weights
1480                .and_then(|values| values.get(sample_index).copied())
1481                .unwrap_or(1.0);
1482        }
1483        apply_normalization(&mut counts, &bins.widths, normalization, column_total);
1484        let chart = build_hist_cpu_chart(bins, counts.clone())?.with_group(column, columns);
1485        all_counts.extend_from_slice(&counts);
1486        all_centers.extend_from_slice(&bins.centers);
1487        charts.push(chart);
1488    }
1489    Ok(HistComputation {
1490        counts: all_counts,
1491        centers: all_centers,
1492        output_shape: vec![expected_bin_count, columns],
1493        charts,
1494    })
1495}
1496
1497fn build_histogram_chart(
1498    data: Vec<f64>,
1499    bin_options: &HistBinOptions,
1500    normalization: HistNormalization,
1501    weights: Option<&[f64]>,
1502    total_weight: f64,
1503) -> BuiltinResult<HistComputation> {
1504    let sample_len = data.len();
1505    if sample_len == 0 {
1506        return build_empty_histogram_chart(bin_options, normalization, 0, total_weight);
1507    }
1508    let stats = HistDataStats::from_samples(&data);
1509    let fallback = data.first().copied();
1510    let bins = realize_bins(bin_options, sample_len, Some(&stats), fallback)?;
1511    let weight_for_sample = |sample_idx: usize| -> f64 {
1512        weights
1513            .and_then(|slice| slice.get(sample_idx).copied())
1514            .unwrap_or(1.0)
1515    };
1516    let mut counts = vec![0f64; bins.bin_count()];
1517    for (sample_idx, value) in data.iter().enumerate() {
1518        let bin_idx = find_bin_index(&bins.edges, *value);
1519        counts[bin_idx] += weight_for_sample(sample_idx);
1520    }
1521    apply_normalization(&mut counts, &bins.widths, normalization, total_weight);
1522    build_hist_cpu_result(&bins, counts)
1523}
1524
1525fn build_empty_histogram_chart(
1526    bin_options: &HistBinOptions,
1527    _normalization: HistNormalization,
1528    sample_len: usize,
1529    _total_weight: f64,
1530) -> BuiltinResult<HistComputation> {
1531    let bins = realize_bins(bin_options, sample_len, None, None)?;
1532    let counts = vec![0.0; bins.bin_count()];
1533    build_hist_cpu_result(&bins, counts)
1534}
1535
1536fn build_hist_cpu_result(bins: &RealizedBins, counts: Vec<f64>) -> BuiltinResult<HistComputation> {
1537    let bar = build_hist_cpu_chart(bins, counts.clone())?;
1538    Ok(HistComputation {
1539        counts,
1540        centers: bins.centers.clone(),
1541        output_shape: vec![1, bins.bin_count()],
1542        charts: vec![bar],
1543    })
1544}
1545
1546fn build_hist_cpu_chart(bins: &RealizedBins, counts: Vec<f64>) -> BuiltinResult<BarChart> {
1547    let mut bar = BarChart::new(bins.labels.clone(), counts)
1548        .map_err(|err| hist_err(format!("hist: {err}")))?;
1549    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
1550    Ok(bar)
1551}
1552
1553fn validate_monotonic(values: &[f64]) -> BuiltinResult<()> {
1554    if values.windows(2).all(|w| w[0] < w[1]) {
1555        Ok(())
1556    } else {
1557        Err(hist_err("hist: values must be strictly increasing"))
1558    }
1559}
1560
1561fn find_bin_index(edges: &[f64], value: f64) -> usize {
1562    if value <= edges[0] {
1563        return 0;
1564    }
1565    let last = edges.len() - 2;
1566    for i in 0..=last {
1567        if value < edges[i + 1] || i == last {
1568            return i;
1569        }
1570    }
1571    last
1572}
1573
1574fn edges_from_centers(centers: &[f64]) -> BuiltinResult<Vec<f64>> {
1575    if centers.is_empty() {
1576        return Err(hist_err(
1577            "hist: bin centers must contain at least one element",
1578        ));
1579    }
1580    if centers.len() == 1 {
1581        let half = 0.5;
1582        return Ok(vec![centers[0] - half, centers[0] + half]);
1583    }
1584    validate_monotonic(centers)?;
1585    let mut edges = Vec::with_capacity(centers.len() + 1);
1586    edges.push(centers[0] - (centers[1] - centers[0]) * 0.5);
1587    for pair in centers.windows(2) {
1588        edges.push((pair[0] + pair[1]) * 0.5);
1589    }
1590    edges.push(
1591        centers[centers.len() - 1]
1592            + (centers[centers.len() - 1] - centers[centers.len() - 2]) * 0.5,
1593    );
1594    Ok(edges)
1595}
1596
1597fn histogram_labels_from_edges(edges: &[f64]) -> Vec<String> {
1598    edges
1599        .windows(2)
1600        .map(|pair| {
1601            let start = pair[0];
1602            let end = pair[1];
1603            format!("[{start:.3}, {end:.3})")
1604        })
1605        .collect()
1606}
1607
1608fn centers_from_edges(edges: &[f64]) -> Vec<f64> {
1609    edges
1610        .windows(2)
1611        .map(|pair| (pair[0] + pair[1]) * 0.5)
1612        .collect()
1613}
1614
1615fn apply_normalization(
1616    counts: &mut [f64],
1617    widths: &[f64],
1618    normalization: HistNormalization,
1619    total_weight: f64,
1620) {
1621    match normalization {
1622        HistNormalization::Count => {}
1623        HistNormalization::Probability => {
1624            let total = total_weight.max(f64::EPSILON);
1625            for count in counts {
1626                *count /= total;
1627            }
1628        }
1629        HistNormalization::Pdf => {
1630            let total = total_weight.max(f64::EPSILON);
1631            for (count, width) in counts.iter_mut().zip(widths.iter()) {
1632                let w = width.max(f64::MIN_POSITIVE);
1633                *count /= total * w;
1634            }
1635        }
1636    }
1637}
1638
1639async fn build_histogram_gpu_chart_async(
1640    values: &GpuTensorHandle,
1641    bin_options: &HistBinOptions,
1642    sample_len: usize,
1643    normalization: HistNormalization,
1644    style: &BarStyle,
1645    weights: &HistWeightsInput,
1646) -> BuiltinResult<HistComputation> {
1647    let context = crate::builtins::plotting::gpu_helpers::ensure_shared_wgpu_context(BUILTIN_NAME)?;
1648    let exported = runmat_accelerate_api::export_wgpu_buffer(values)
1649        .ok_or_else(|| hist_internal("unable to export GPU data"))?;
1650    if exported.len == 0 {
1651        let total_hint = weights
1652            .total_weight_hint(sample_len)
1653            .unwrap_or(sample_len as f64);
1654        return build_empty_histogram_chart(bin_options, normalization, sample_len, total_hint);
1655    }
1656
1657    let sample_count_u32 = u32::try_from(exported.len)
1658        .map_err(|_| hist_err("hist: sample count exceeds supported range"))?;
1659    let gpu_weights = weights.to_gpu_weights(sample_len)?;
1660    let (min_value_f32, max_value_f32) = axis_bounds_async(values, "hist").await?;
1661    let stats = HistDataStats {
1662        min: Some(min_value_f32 as f64),
1663        max: Some(max_value_f32 as f64),
1664    };
1665    let bins = realize_bins(
1666        bin_options,
1667        sample_len,
1668        Some(&stats),
1669        Some(min_value_f32 as f64),
1670    )?;
1671    let Some(uniform_width_f64) = bins.uniform_width else {
1672        return Err(hist_err(
1673            "hist: GPU rendering currently requires uniform bin edges",
1674        ));
1675    };
1676    let uniform_width = uniform_width_f64 as f32;
1677    let bin_count_u32 = u32::try_from(bins.bin_count())
1678        .map_err(|_| hist_err("hist: bin count exceeds supported range for GPU execution"))?;
1679
1680    let histogram_inputs = HistogramGpuInputs {
1681        samples: exported.buffer.clone(),
1682        sample_count: sample_count_u32,
1683        scalar: ScalarType::from_is_f64(exported.precision == ProviderPrecision::F64),
1684        weights: gpu_weights,
1685    };
1686    let histogram_params = HistogramGpuParams {
1687        min_value: bins.edges[0] as f32,
1688        inv_bin_width: 1.0 / uniform_width,
1689        bin_count: bin_count_u32,
1690    };
1691    let normalization_mode = match normalization {
1692        HistNormalization::Count => HistogramNormalizationMode::Count,
1693        HistNormalization::Probability => HistogramNormalizationMode::Probability,
1694        HistNormalization::Pdf => HistogramNormalizationMode::Pdf {
1695            bin_width: uniform_width.max(f32::MIN_POSITIVE),
1696        },
1697    };
1698
1699    let histogram_output = runmat_plot::gpu::histogram::histogram_values_buffer(
1700        &context.device,
1701        &context.queue,
1702        histogram_inputs,
1703        &histogram_params,
1704        normalization_mode,
1705    )
1706    .await
1707    .map_err(|e| hist_internal(format!("failed to build GPU histogram counts: {e}")))?;
1708
1709    let HistogramGpuOutput {
1710        values_buffer,
1711        total_weight,
1712    } = histogram_output;
1713
1714    let bar_inputs = BarGpuInputs {
1715        values_buffer,
1716        row_count: bin_count_u32,
1717        scalar: ScalarType::F32,
1718    };
1719    let bar_params = BarGpuParams {
1720        color: style.face_rgba(),
1721        bar_width: style.bar_width,
1722        series_index: 0,
1723        series_count: 1,
1724        source_row_count: bin_count_u32,
1725        transpose_source: false,
1726        group_index: 0,
1727        group_count: 1,
1728        orientation: BarOrientation::Vertical,
1729        layout: BarLayoutMode::Grouped,
1730    };
1731
1732    let gpu_vertices = runmat_plot::gpu::bar::pack_vertices_from_values(
1733        &context.device,
1734        &context.queue,
1735        &bar_inputs,
1736        &bar_params,
1737    )
1738    .map_err(|e| hist_internal(format!("failed to build GPU vertices: {e}")))?;
1739
1740    let bin_count = bins.bin_count();
1741    let normalization_scale = match normalization {
1742        HistNormalization::Count => 1.0,
1743        HistNormalization::Probability => {
1744            if total_weight <= f32::EPSILON {
1745                0.0
1746            } else {
1747                1.0 / total_weight
1748            }
1749        }
1750        HistNormalization::Pdf => {
1751            if total_weight <= f32::EPSILON {
1752                0.0
1753            } else {
1754                1.0 / (total_weight * uniform_width)
1755            }
1756        }
1757    };
1758    let bounds = histogram_bar_bounds(
1759        bin_count,
1760        total_weight,
1761        normalization_scale,
1762        style.bar_width,
1763    );
1764    let vertex_count = gpu_vertices.vertex_count;
1765    let mut bar = BarChart::from_gpu_buffer(
1766        bins.labels.clone(),
1767        bin_count,
1768        gpu_vertices,
1769        vertex_count,
1770        bounds,
1771        style.face_rgba(),
1772        style.bar_width,
1773    )
1774    .with_gpu_source(bar_inputs.clone(), 0, 1);
1775    bar.label = Some(HIST_DEFAULT_LABEL.to_string());
1776    let counts_f32 = runmat_plot::gpu::util::readback_f32_buffer(
1777        &context.device,
1778        bar_inputs.values_buffer.as_ref(),
1779        bin_count,
1780    )
1781    .await
1782    .map_err(|e| hist_internal(format!("failed to read GPU histogram counts: {e}")))?;
1783    let counts: Vec<f64> = counts_f32.iter().map(|v| *v as f64).collect();
1784
1785    Ok(HistComputation {
1786        counts,
1787        centers: bins.centers.clone(),
1788        output_shape: vec![1, bins.bin_count()],
1789        charts: vec![bar],
1790    })
1791}
1792
1793fn histogram_bar_bounds(
1794    bins: usize,
1795    total_weight: f32,
1796    normalization_scale: f32,
1797    bar_width: f32,
1798) -> BoundingBox {
1799    let min_x = 1.0 - bar_width * 0.5;
1800    let max_x = bins as f32 + bar_width * 0.5;
1801    let max_y = total_weight * normalization_scale;
1802    let max_y = if max_y.is_finite() && max_y > 0.0 {
1803        max_y
1804    } else {
1805        1.0
1806    };
1807    BoundingBox::new(Vec3::new(min_x, 0.0, 0.0), Vec3::new(max_x, max_y, 0.0))
1808}
1809
1810enum HistInput {
1811    Host(Tensor),
1812    Gpu(GpuTensorHandle),
1813}
1814
1815impl HistInput {
1816    fn from_value(value: Value) -> BuiltinResult<Self> {
1817        match value {
1818            Value::GpuTensor(handle) => Ok(Self::Gpu(handle)),
1819            other => {
1820                let tensor = tensor_utils::value_into_tensor_for("hist", other)
1821                    .map_err(|e| hist_err(format!("hist: {e}")))?;
1822                Ok(Self::Host(tensor))
1823            }
1824        }
1825    }
1826
1827    fn vector_gpu_handle(&self) -> Option<&GpuTensorHandle> {
1828        match self {
1829            Self::Gpu(handle)
1830                if handle.shape.iter().filter(|&&dim| dim > 1).count() <= 1
1831                    && runmat_accelerate_api::handle_integer_type(handle).is_none()
1832                    && !runmat_accelerate_api::handle_is_logical(handle) =>
1833            {
1834                Some(handle)
1835            }
1836            Self::Host(_) => None,
1837            Self::Gpu(_) => None,
1838        }
1839    }
1840
1841    fn len(&self) -> usize {
1842        match self {
1843            Self::Host(tensor) => tensor_utils::tensor_element_len(tensor),
1844            Self::Gpu(handle) => handle.shape.iter().product(),
1845        }
1846    }
1847}
1848
1849#[cfg(test)]
1850pub(crate) mod tests {
1851    use super::*;
1852    use crate::builtins::array::type_resolvers::row_vector_type;
1853    use crate::builtins::common::test_support;
1854    use crate::builtins::plotting::tests::ensure_plot_test_env;
1855    use crate::RuntimeError;
1856    use futures::executor::block_on;
1857    use runmat_accelerate_api::{HostIntegerDataView, HostIntegerTensorView};
1858    use runmat_builtins::{ResolveContext, Type};
1859
1860    fn setup_plot_tests() {
1861        ensure_plot_test_env();
1862    }
1863
1864    fn tensor_from(data: &[f64]) -> Tensor {
1865        Tensor::new(data.to_vec(), vec![data.len()]).expect("hist test vector")
1866    }
1867
1868    fn int_tensor(data: Vec<i16>) -> Tensor {
1869        Tensor::new_integer(
1870            runmat_value::IntegerStorage::I16(data.clone()),
1871            vec![data.len()],
1872        )
1873        .expect("integer tensor")
1874    }
1875
1876    fn assert_plotting_unavailable(err: &RuntimeError) {
1877        let lower = err.to_string().to_lowercase();
1878        assert!(
1879            lower.contains("plotting is unavailable") || lower.contains("non-main thread"),
1880            "unexpected error: {err}"
1881        );
1882    }
1883
1884    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1885    #[test]
1886    fn hist_respects_bin_argument() {
1887        setup_plot_tests();
1888        let data = Value::Tensor(tensor_from(&[1.0, 2.0, 3.0, 4.0]));
1889        let bins = vec![Value::from(2.0)];
1890        let result = block_on(hist_builtin(data, bins));
1891        if let Err(flow) = result {
1892            assert_plotting_unavailable(&flow);
1893        }
1894    }
1895
1896    #[test]
1897    fn hist_legacy_default_is_ten_bins() {
1898        assert_eq!(default_bin_count(0), 10);
1899        assert_eq!(default_bin_count(4), 10);
1900        assert_eq!(default_bin_count(10_000), 10);
1901    }
1902
1903    #[test]
1904    fn hist_integer_samples_are_strictly_extension_gated() {
1905        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1906        let error = block_on(hist_builtin(
1907            Value::Tensor(int_tensor(vec![1, 2, 3])),
1908            Vec::new(),
1909        ))
1910        .expect_err("integer samples require RunMat mode");
1911        assert_eq!(
1912            error.identifier(),
1913            HIST_INTEGER_DATA_EXTENSION.error_identifier
1914        );
1915    }
1916
1917    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1918    #[test]
1919    fn hist_accepts_bin_centers_vector() {
1920        setup_plot_tests();
1921        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
1922        let centers = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0]));
1923        let result = block_on(hist_builtin(data, vec![centers]));
1924        if let Err(flow) = result {
1925            assert_plotting_unavailable(&flow);
1926        }
1927    }
1928
1929    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1930    #[test]
1931    fn hist_bin_counts_read_typed_integer_tensors_exactly() {
1932        let exact = 9_007_199_254_740_993_u64;
1933        let scalar_count = runmat_value::Tensor::new_integer(
1934            runmat_value::IntegerStorage::U64(vec![exact]),
1935            vec![1, 1],
1936        )
1937        .expect("typed bin count");
1938        match parse_hist_bins(Some(Value::Tensor(scalar_count)), 10).unwrap() {
1939            HistBinSpec::Count(count) => assert_eq!(count, exact as usize),
1940            _ => panic!("expected count bin spec"),
1941        }
1942
1943        let num_bins = runmat_value::Tensor::new_integer(
1944            runmat_value::IntegerStorage::U64(vec![exact]),
1945            vec![1, 1],
1946        )
1947        .expect("typed NumBins");
1948        assert_eq!(
1949            parse_num_bins_value(&Value::Tensor(num_bins)).unwrap(),
1950            exact as usize
1951        );
1952
1953        let negative = runmat_value::Tensor::new_integer(
1954            runmat_value::IntegerStorage::I64(vec![-1]),
1955            vec![1, 1],
1956        )
1957        .expect("negative bin count");
1958        assert!(parse_hist_bins(Some(Value::Tensor(negative)), 10).is_err());
1959
1960        let boundary = if usize::BITS == 64 {
1961            usize::MAX as f64
1962        } else {
1963            (usize::MAX as f64) + 1.0
1964        };
1965        assert!(parse_num_bins_value(&Value::Num(boundary)).is_err());
1966        assert!(parse_hist_bins(Some(Value::Num(boundary)), 10).is_err());
1967        assert!(parse_hist_bins(Some(Value::Num(2.5)), 10).is_err());
1968    }
1969
1970    #[test]
1971    fn hist_weights_read_typed_integer_storage_exactly() {
1972        let weights = HistWeightsInput::from_value(Value::Tensor(int_tensor(vec![1, 2, 3])), 3)
1973            .expect("weights");
1974
1975        assert_eq!(weights.total_weight_hint(3), Some(6.0));
1976    }
1977
1978    #[test]
1979    fn hist_gpu_weights_preserve_native_single_class() {
1980        let tensor =
1981            Tensor::new_with_dtype(vec![1.0, 2.0, 3.0], vec![1, 3], NumericDType::F32).unwrap();
1982        let weights =
1983            HistWeightsInput::from_value(Value::Tensor(tensor), 3).expect("single weights");
1984
1985        match weights.to_gpu_weights(3).expect("GPU weights") {
1986            HistogramGpuWeights::HostF32 { data, total_weight } => {
1987                assert_eq!(data, vec![1.0_f32, 2.0, 3.0]);
1988                assert_eq!(total_weight, 6.0);
1989            }
1990            _ => panic!("expected native single host weights"),
1991        }
1992    }
1993
1994    #[test]
1995    fn resident_integer_hist_uses_compatibility_gate_before_owner_gather() {
1996        test_support::with_test_provider(|provider| {
1997            let input = provider
1998                .upload_integer(&HostIntegerTensorView {
1999                    data: HostIntegerDataView::U8(&[1, 2, 2, 3]),
2000                    shape: &[1, 4],
2001                })
2002                .expect("resident uint8 histogram input");
2003            let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
2004            let error = block_on(hist_builtin(Value::GpuTensor(input), Vec::new()))
2005                .expect_err("strict compatibility rejects resident integer hist data");
2006            assert_eq!(
2007                error.identifier(),
2008                Some("RunMat:compatibility:HistIntegerDataExtension")
2009            );
2010        });
2011    }
2012
2013    #[test]
2014    fn resident_integer_hist_gathers_exactly_in_runmat_mode() {
2015        test_support::with_test_provider(|provider| {
2016            ensure_plot_test_env();
2017            let input = provider
2018                .upload_integer(&HostIntegerTensorView {
2019                    data: HostIntegerDataView::U64(&[9_007_199_254_740_993, 9_007_199_254_740_994]),
2020                    shape: &[1, 2],
2021                })
2022                .expect("resident wide histogram input");
2023            let centers = Value::Tensor(
2024                Tensor::new_integer(
2025                    runmat_value::IntegerStorage::U64(vec![
2026                        9_007_199_254_740_993,
2027                        9_007_199_254_740_994,
2028                    ]),
2029                    vec![1, 2],
2030                )
2031                .unwrap(),
2032            );
2033            let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2034            let output = block_on(hist_builtin(Value::GpuTensor(input), vec![centers]))
2035                .expect("resident wide histogram");
2036            let counts = Tensor::try_from(&output).expect("histogram counts");
2037            let counts = counts.as_f64_slice().expect("double counts");
2038            assert_eq!(counts.iter().sum::<f64>(), 2.0);
2039            assert_eq!(counts.iter().filter(|&&count| count == 1.0).count(), 2);
2040        });
2041    }
2042
2043    #[test]
2044    fn resident_logical_hist_data_and_weights_gather_through_owner() {
2045        test_support::with_test_provider(|provider| {
2046            ensure_plot_test_env();
2047            let data = provider
2048                .upload(&runmat_accelerate_api::HostTensorView {
2049                    data: &[0.0, 1.0, 1.0, 0.0],
2050                    shape: &[1, 4],
2051                })
2052                .expect("resident logical data");
2053            runmat_accelerate_api::set_handle_logical(&data, true);
2054            let weights = provider
2055                .upload(&runmat_accelerate_api::HostTensorView {
2056                    data: &[1.0, 0.0, 1.0, 0.0],
2057                    shape: &[1, 4],
2058                })
2059                .expect("resident logical weights");
2060            runmat_accelerate_api::set_handle_logical(&weights, true);
2061            let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2062            let output = block_on(hist_builtin(
2063                Value::GpuTensor(data),
2064                vec![
2065                    Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap()),
2066                    Value::String("Weights".into()),
2067                    Value::GpuTensor(weights),
2068                ],
2069            ))
2070            .expect("resident logical histogram");
2071            let counts = Tensor::try_from(&output).expect("histogram counts");
2072            assert_eq!(counts.as_f64_slice().unwrap().iter().sum::<f64>(), 2.0);
2073        });
2074    }
2075
2076    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2077    #[test]
2078    fn hist_accepts_probability_normalization() {
2079        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2080        setup_plot_tests();
2081        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
2082        let result = block_on(hist_builtin(
2083            data,
2084            vec![Value::from(3.0), Value::String("probability".into())],
2085        ));
2086        if let Err(flow) = result {
2087            assert_plotting_unavailable(&flow);
2088        }
2089    }
2090
2091    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2092    #[test]
2093    fn hist_accepts_string_only_normalization() {
2094        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2095        setup_plot_tests();
2096        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
2097        let result = block_on(hist_builtin(data, vec![Value::String("pdf".into())]));
2098        if let Err(flow) = result {
2099            assert_plotting_unavailable(&flow);
2100        }
2101    }
2102
2103    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2104    #[test]
2105    fn hist_accepts_normalization_name_value_pair() {
2106        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2107        setup_plot_tests();
2108        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
2109        let result = block_on(hist_builtin(
2110            data,
2111            vec![
2112                Value::String("Normalization".into()),
2113                Value::String("probability".into()),
2114            ],
2115        ));
2116        if let Err(flow) = result {
2117            assert_plotting_unavailable(&flow);
2118        }
2119    }
2120
2121    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2122    #[test]
2123    fn hist_accepts_bin_edges_option() {
2124        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
2125        setup_plot_tests();
2126        let data = Value::Tensor(tensor_from(&[0.1, 0.4, 0.7]));
2127        let edges = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0]));
2128        let result = block_on(hist_builtin(
2129            data,
2130            vec![Value::String("BinEdges".into()), edges],
2131        ));
2132        if let Err(flow) = result {
2133            assert_plotting_unavailable(&flow);
2134        }
2135    }
2136
2137    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2138    #[test]
2139    fn hist_evaluate_returns_counts_and_centers() {
2140        setup_plot_tests();
2141        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.8, 1.0]));
2142        let eval = block_on(evaluate_async(data, &[])).expect("hist evaluate");
2143        let counts = match eval.counts_value() {
2144            Value::Tensor(tensor) => tensor.materialize_f64(),
2145            other => panic!("unexpected value: {other:?}"),
2146        };
2147        assert_eq!(counts.len(), 10);
2148        let centers = match eval.centers_value() {
2149            Value::Tensor(tensor) => tensor.materialize_f64(),
2150            other => panic!("unexpected centers: {other:?}"),
2151        };
2152        assert_eq!(centers.len(), 10);
2153    }
2154
2155    #[test]
2156    fn hist_matrix_input_returns_one_count_column_per_data_column() {
2157        setup_plot_tests();
2158        let data = Value::Tensor(
2159            Tensor::new(vec![1.0, 1.0, 2.0, 10.0, 10.0, 20.0], vec![3, 2]).expect("hist matrix"),
2160        );
2161        let bins = [Value::Tensor(
2162            Tensor::new(vec![1.0, 10.0], vec![2]).expect("hist centers"),
2163        )];
2164        let eval = block_on(evaluate_async(data, &bins)).expect("matrix hist evaluation");
2165        let counts = Tensor::try_from(&eval.counts_value()).expect("matrix counts");
2166        let centers = Tensor::try_from(&eval.centers_value()).expect("matrix centers");
2167        assert_eq!(counts.shape, vec![2, 2]);
2168        assert_eq!(counts.materialize_f64(), vec![3.0, 0.0, 0.0, 3.0]);
2169        assert_eq!(centers.shape, vec![2, 2]);
2170        assert_eq!(centers.materialize_f64(), vec![1.0, 10.0, 1.0, 10.0]);
2171        assert_eq!(eval.charts.len(), 2);
2172        assert_eq!(
2173            (eval.charts[0].group_index, eval.charts[0].group_count),
2174            (0, 2)
2175        );
2176        assert_eq!(
2177            (eval.charts[1].group_index, eval.charts[1].group_count),
2178            (1, 2)
2179        );
2180    }
2181
2182    #[test]
2183    fn hist_matrix_automatic_bins_are_selected_per_column() {
2184        setup_plot_tests();
2185        let data = Value::Tensor(
2186            Tensor::new(vec![0.0, 1.0, 2.0, 100.0, 110.0, 120.0], vec![3, 2]).expect("hist matrix"),
2187        );
2188        let eval = block_on(evaluate_async(data, &[])).expect("matrix hist evaluation");
2189        let centers = Tensor::try_from(&eval.centers_value()).expect("matrix centers");
2190        assert_eq!(centers.shape, vec![10, 2]);
2191        let values = centers.materialize_f64();
2192        assert!(values[0] < 1.0);
2193        assert!(values[10] > 100.0);
2194    }
2195
2196    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2197    #[test]
2198    fn hist_supports_numbins_option() {
2199        setup_plot_tests();
2200        let data = Value::Tensor(tensor_from(&[0.0, 0.5, 1.0, 1.5]));
2201        let args = vec![Value::String("NumBins".into()), Value::Num(4.0)];
2202        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
2203        let centers = match eval.centers_value() {
2204            Value::Tensor(tensor) => tensor.materialize_f64(),
2205            other => panic!("unexpected centers: {other:?}"),
2206        };
2207        assert_eq!(centers.len(), 4);
2208    }
2209
2210    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2211    #[test]
2212    fn hist_supports_binwidth_and_limits() {
2213        setup_plot_tests();
2214        let data = Value::Tensor(tensor_from(&[0.1, 0.2, 0.6, 0.8]));
2215        let args = vec![
2216            Value::String("BinWidth".into()),
2217            Value::Num(0.5),
2218            Value::String("BinLimits".into()),
2219            Value::Tensor(tensor_from(&[0.0, 1.0])),
2220        ];
2221        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
2222        let centers = match eval.centers_value() {
2223            Value::Tensor(tensor) => tensor.materialize_f64(),
2224            other => panic!("unexpected centers: {other:?}"),
2225        };
2226        assert_eq!(centers.len(), 2);
2227        assert!((centers[0] - 0.25).abs() < 1e-9);
2228    }
2229
2230    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2231    #[test]
2232    fn hist_supports_sqrt_binmethod() {
2233        setup_plot_tests();
2234        let data = Value::Tensor(tensor_from(&[0.0, 0.2, 0.4, 0.6, 0.8]));
2235        let args = vec![
2236            Value::String("BinMethod".into()),
2237            Value::String("sqrt".into()),
2238        ];
2239        let eval = block_on(evaluate_async(data, &args)).expect("hist evaluate");
2240        let centers = match eval.centers_value() {
2241            Value::Tensor(tensor) => tensor.materialize_f64(),
2242            other => panic!("unexpected centers: {other:?}"),
2243        };
2244        assert!(centers.len() >= 2);
2245    }
2246
2247    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2248    #[test]
2249    fn apply_normalization_handles_weighted_probability() {
2250        setup_plot_tests();
2251        let mut counts = vec![2.0, 4.0];
2252        let widths = vec![1.0, 1.0];
2253        apply_normalization(&mut counts, &widths, HistNormalization::Probability, 6.0);
2254        assert!((counts[0] - 2.0 / 6.0).abs() < 1e-12);
2255        assert!((counts[1] - 4.0 / 6.0).abs() < 1e-12);
2256    }
2257
2258    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2259    #[test]
2260    fn apply_normalization_handles_weighted_pdf() {
2261        setup_plot_tests();
2262        let mut counts = vec![5.0];
2263        let widths = vec![0.5];
2264        apply_normalization(&mut counts, &widths, HistNormalization::Pdf, 10.0);
2265        // PDF height = weight / (total_weight * bin_width) = 5 / (10 * 0.5) = 1
2266        assert!((counts[0] - 1.0).abs() < 1e-12);
2267    }
2268
2269    #[test]
2270    fn hist_type_defaults_to_row_vector() {
2271        let ctx = ResolveContext::new(Vec::new());
2272        assert_eq!(hist_type(&[Type::tensor()], &ctx), row_vector_type(&ctx));
2273    }
2274
2275    #[test]
2276    fn hist_type_uses_bin_centers_length() {
2277        let ctx = ResolveContext::new(Vec::new());
2278        let out = hist_type(
2279            &[
2280                Type::tensor(),
2281                Type::Tensor {
2282                    shape: Some(vec![Some(1), Some(5)]),
2283                },
2284            ],
2285            &ctx,
2286        );
2287        assert_eq!(
2288            out,
2289            Type::Tensor {
2290                shape: Some(vec![Some(1), Some(5)])
2291            }
2292        );
2293    }
2294
2295    #[test]
2296    fn hist_descriptor_includes_core_signatures() {
2297        let labels: Vec<&str> = HIST_DESCRIPTOR
2298            .signatures
2299            .iter()
2300            .map(|sig| sig.label)
2301            .collect();
2302        assert!(labels.contains(&"N = hist(X)"));
2303        assert!(labels.contains(&"N = hist(X, bins)"));
2304        assert!(labels.contains(&"N = hist(X, Name, Value, ...)"));
2305    }
2306
2307    #[test]
2308    fn hist_missing_option_value_uses_stable_identifier() {
2309        let result = block_on(evaluate_async(
2310            Value::Tensor(tensor_from(&[1.0, 2.0, 3.0])),
2311            &[Value::String("BinEdges".into())],
2312        ));
2313        let err = match result {
2314            Ok(_) => panic!("expected histogram parse failure"),
2315            Err(err) => err,
2316        };
2317        assert_eq!(err.identifier(), HIST_ERROR_INVALID_ARGUMENT.identifier);
2318    }
2319}