cqlib_core/visualization/result/
data.rs1use 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 pub(crate) labels: Vec<String>,
27 pub(crate) values: Vec<Vec<f64>>,
29 pub(crate) kind: ResultPlotKind,
31}
32
33pub 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
69pub 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
105pub(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
166fn 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
191pub(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
209pub(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
235pub(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
298fn 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
306pub(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}