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