1use 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 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
366pub 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
677pub 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 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}