Skip to main content

cqlib_core/visualization/result/
data.rs

1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Data normalization and ordering for result/statistics plots.
14//!
15//! This module validates count series, optionally normalizes them into probability
16//! distributions, and prepares grouped bar-chart data for SVG rendering.
17
18use super::options::{PlotSeries, ResultPlotKind, ResultPlotOptions};
19use super::plot::render_bar_svg;
20use crate::device::ExecutionResult;
21use crate::visualization::VisualizationError;
22use std::collections::BTreeSet;
23
24pub(crate) struct PreparedResultPlot {
25    /// Display labels after applying the selected sort policy.
26    pub(crate) labels: Vec<String>,
27    /// Per-dataset values aligned to [`PreparedResultPlot::labels`].
28    pub(crate) values: Vec<Vec<f64>>,
29    /// Plot family, used by renderers to choose axis and label formatting.
30    pub(crate) kind: ResultPlotKind,
31}
32
33/// Plot an execution result's raw measured counts as an SVG histogram.
34///
35/// # Arguments
36///
37/// * `result` - Execution result containing measured counts.
38/// * `options` - Sorting, coloring, and layout options.
39///
40/// # Returns
41///
42/// SVG markup as a UTF-8 string.
43///
44/// # Errors
45///
46/// Returns [`VisualizationError::InvalidInput`] when the execution result contains no counts
47/// or plot options are inconsistent.
48///
49/// # Examples
50///
51/// ```no_run
52/// use cqlib_core::device::ExecutionResult;
53/// use cqlib_core::visualization::{ResultPlotOptions, plot_histogram};
54///
55/// # fn demo(result: &ExecutionResult) {
56/// let svg = plot_histogram(result, &ResultPlotOptions::default()).unwrap();
57/// assert!(svg.contains("<svg"));
58/// # }
59/// ```
60pub fn plot_histogram(
61    result: &ExecutionResult,
62    options: &ResultPlotOptions,
63) -> Result<String, VisualizationError> {
64    let series = execution_result_to_plot_series(result)?;
65    let prepared = prepare_result_plot(&[series], options, ResultPlotKind::Histogram)?;
66    Ok(render_bar_svg(&prepared, options))
67}
68
69/// Plot an execution result's measured counts as a normalized probability distribution.
70///
71/// # Arguments
72///
73/// * `result` - Execution result containing measured counts.
74/// * `options` - Sorting, coloring, and layout options.
75///
76/// # Returns
77///
78/// SVG markup as a UTF-8 string.
79///
80/// # Errors
81///
82/// Returns [`VisualizationError::InvalidInput`] when the execution result contains no counts,
83/// the measured counts sum to zero, or plot options are inconsistent.
84///
85/// # Examples
86///
87/// ```no_run
88/// use cqlib_core::device::ExecutionResult;
89/// use cqlib_core::visualization::{ResultPlotOptions, plot_distribution};
90///
91/// # fn demo(result: &ExecutionResult) {
92/// let svg = plot_distribution(result, &ResultPlotOptions::default()).unwrap();
93/// assert!(svg.contains("<svg"));
94/// # }
95/// ```
96pub fn plot_distribution(
97    result: &ExecutionResult,
98    options: &ResultPlotOptions,
99) -> Result<String, VisualizationError> {
100    let series = execution_result_to_plot_series(result)?;
101    let prepared = prepare_result_plot(&[series], options, ResultPlotKind::Distribution)?;
102    Ok(render_bar_svg(&prepared, options))
103}
104
105/// Convert an execution result's measured counts into a plot series keyed by bitstring.
106///
107/// Bitstrings are formatted using [`crate::device::Outcome::to_bitstring`] with
108/// [`ExecutionResult::num_qubits`], preserving Cqlib's measurement-result display
109/// convention.
110pub(crate) fn execution_result_to_plot_series(
111    result: &ExecutionResult,
112) -> Result<PlotSeries, VisualizationError> {
113    if result.counts().is_empty() {
114        return Err(VisualizationError::InvalidInput(
115            "execution result contains no counts to plot".to_string(),
116        ));
117    }
118
119    let num_qubits = result.num_qubits();
120    Ok(result
121        .counts()
122        .iter()
123        .map(|(outcome, count)| (outcome.to_bitstring(num_qubits), *count as f64))
124        .collect())
125}
126
127pub(crate) fn prepare_result_plot(
128    data: &[PlotSeries],
129    options: &ResultPlotOptions,
130    kind: ResultPlotKind,
131) -> Result<PreparedResultPlot, VisualizationError> {
132    validate_inputs(data, options)?;
133
134    let mut datasets = data.to_vec();
135    if kind == ResultPlotKind::Distribution {
136        datasets = datasets
137            .into_iter()
138            .map(normalize_distribution)
139            .collect::<Result<Vec<_>, _>>()?;
140    }
141    if let Some(k) = options.number_to_keep {
142        datasets = datasets
143            .into_iter()
144            .map(|dataset| keep_topk_with_rest(&dataset, k))
145            .collect();
146    }
147
148    let labels = sorted_labels(&datasets, options)?;
149    let values = datasets
150        .iter()
151        .map(|dataset| {
152            labels
153                .iter()
154                .map(|label| *dataset.get(label).unwrap_or(&0.0))
155                .collect()
156        })
157        .collect();
158
159    Ok(PreparedResultPlot {
160        labels,
161        values,
162        kind,
163    })
164}
165
166/// Validate cross-dataset options before normalization or rendering.
167///
168/// The data layer checks structural consistency here so renderers can assume at least one
169/// dataset and, when present, one legend item per dataset.
170fn validate_inputs(
171    data: &[PlotSeries],
172    options: &ResultPlotOptions,
173) -> Result<(), VisualizationError> {
174    if data.is_empty() {
175        return Err(VisualizationError::InvalidInput(
176            "plot data must contain at least one dataset".to_string(),
177        ));
178    }
179    if let Some(legend) = &options.legend
180        && legend.len() != data.len()
181    {
182        return Err(VisualizationError::InvalidInput(format!(
183            "legend length ({}) does not match dataset count ({})",
184            legend.len(),
185            data.len()
186        )));
187    }
188    Ok(())
189}
190
191/// Normalize one count-like series into probabilities.
192///
193/// Values are divided by their arithmetic sum. Negative entries are not rejected here because
194/// result visualization accepts generic numeric series; callers that need physical probability
195/// constraints should validate before calling this function.
196pub(crate) fn normalize_distribution(data: PlotSeries) -> Result<PlotSeries, VisualizationError> {
197    let total: f64 = data.values().sum();
198    if total.abs() < 1e-12 {
199        return Err(VisualizationError::InvalidInput(
200            "distribution data sum is zero".to_string(),
201        ));
202    }
203    Ok(data
204        .into_iter()
205        .map(|(label, value)| (label, value / total))
206        .collect())
207}
208
209/// Keep the largest `k` entries by value and aggregate the remainder as `rest`.
210///
211/// Ties are resolved by bitstring label to keep output deterministic across platforms.
212pub(crate) fn keep_topk_with_rest(data: &PlotSeries, k: usize) -> PlotSeries {
213    if k == 0 || k >= data.len() {
214        return data.clone();
215    }
216
217    let mut items: Vec<_> = data
218        .iter()
219        .map(|(label, value)| (label.clone(), *value))
220        .collect();
221    items.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
222
223    let rest = items[..items.len() - k]
224        .iter()
225        .map(|(_, value)| *value)
226        .sum();
227    let mut out = items[items.len() - k..]
228        .iter()
229        .cloned()
230        .collect::<PlotSeries>();
231    out.insert("rest".to_string(), rest);
232    out
233}
234
235/// Collect and sort display labels across all datasets.
236///
237/// Missing values are filled as zero during preparation, so this returns the union of all
238/// labels. The special `rest` bucket is kept at the end for Hamming-distance sorting.
239pub(crate) fn sorted_labels(
240    datasets: &[PlotSeries],
241    options: &ResultPlotOptions,
242) -> Result<Vec<String>, VisualizationError> {
243    let mut labels: Vec<String> = datasets
244        .iter()
245        .flat_map(|dataset| dataset.keys().cloned())
246        .collect::<BTreeSet<_>>()
247        .into_iter()
248        .collect();
249
250    match options.sort.as_str() {
251        "asc" => {}
252        "desc" => labels.reverse(),
253        "value" => {
254            labels.sort_by(|a, b| {
255                let av = peak_value(datasets, a);
256                let bv = peak_value(datasets, b);
257                av.total_cmp(&bv).then_with(|| a.cmp(b))
258            });
259        }
260        "value_desc" => {
261            labels.sort_by(|a, b| {
262                let av = peak_value(datasets, a);
263                let bv = peak_value(datasets, b);
264                bv.total_cmp(&av).then_with(|| a.cmp(b))
265            });
266        }
267        "hamming" => {
268            let target = options.target_string.as_ref().ok_or_else(|| {
269                VisualizationError::InvalidInput(
270                    "sort='hamming' requires target_string".to_string(),
271                )
272            })?;
273            labels.sort_by(|a, b| {
274                if a == "rest" {
275                    return std::cmp::Ordering::Greater;
276                }
277                if b == "rest" {
278                    return std::cmp::Ordering::Less;
279                }
280                let da = hamming_distance(a, target).unwrap_or(usize::MAX);
281                let db = hamming_distance(b, target).unwrap_or(usize::MAX);
282                da.cmp(&db).then_with(|| a.cmp(b))
283            });
284            for label in labels.iter().filter(|label| label.as_str() != "rest") {
285                hamming_distance(label, target)?;
286            }
287        }
288        other => {
289            return Err(VisualizationError::InvalidInput(format!(
290                "sort must be one of asc, desc, value, value_desc, hamming; got '{other}'"
291            )));
292        }
293    }
294
295    Ok(labels)
296}
297
298/// Maximum value associated with `label` across datasets.
299fn peak_value(datasets: &[PlotSeries], label: &str) -> f64 {
300    datasets
301        .iter()
302        .map(|dataset| dataset.get(label).copied().unwrap_or(0.0))
303        .fold(f64::NEG_INFINITY, f64::max)
304}
305
306/// Compute Hamming distance between equal-length bitstrings.
307///
308/// # Errors
309///
310/// Returns [`VisualizationError::InvalidInput`] when the two labels have different lengths.
311pub(crate) fn hamming_distance(a: &str, b: &str) -> Result<usize, VisualizationError> {
312    if a.len() != b.len() {
313        return Err(VisualizationError::InvalidInput(
314            "hamming sort needs equal-length bitstrings".to_string(),
315        ));
316    }
317    Ok(a.chars().zip(b.chars()).filter(|(x, y)| x != y).count())
318}