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