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