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