diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 215f4f21..993d4bb7 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -8,6 +8,7 @@ //! touches a terminal or runs a loop. use eyre::{Result, eyre}; +use ppvm_vihaco::component::{ComplexityMetric, ComplexityMetricKind}; use ppvm_vihaco::composite::{PPVM, StepOutcome}; use ppvm_vihaco::measurements::MeasurementResult; use ppvm_vihaco::{CircuitInstruction, PPVMModule, compile_program, load_module_file}; @@ -19,7 +20,108 @@ use ratatui::widgets::Clear; use crate::codeview::CodeView; use crate::command::{Command, parse_command}; use crate::editor::LineEditor; -use crate::widgets::{CommandLine, HelpOverlay, ProgramView, RecordView, StateView}; +use crate::widgets::{ + CommandLine, ComplexityTreeView, HelpOverlay, ProgramView, RecordView, StackView, StateView, +}; + +const STATE_DETAIL_QUBIT_LIMIT: usize = 16; +const STATE_DETAIL_COMPLEXITY_LIMIT: usize = 128; +const TREE_LAYER_STRIDE: usize = 6; +const DEFAULT_TREE_HEIGHT: u16 = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ComplexityLayer { + kind: ComplexityMetricKind, + count: usize, +} + +impl From for ComplexityLayer { + fn from(metric: ComplexityMetric) -> Self { + Self { + kind: metric.kind, + count: metric.count, + } + } +} + +#[derive(Debug, Default)] +struct ComplexityHistory { + layers: Vec, +} + +impl ComplexityHistory { + fn clear(&mut self) { + self.layers.clear(); + } + + fn push_if_changed(&mut self, metric: ComplexityMetric) { + let layer = ComplexityLayer::from(metric); + if self.layers.last() != Some(&layer) { + self.layers.push(layer); + } + } + + fn counts(&self) -> Vec { + self.layers.iter().map(|layer| layer.count).collect() + } + + fn visible_layer_count(&self, width: usize) -> usize { + (width.div_ceil(TREE_LAYER_STRIDE)) + .max(1) + .min(self.layers.len()) + } + + fn render(&self, width: u16, height: u16) -> String { + let Some(current) = self.layers.last() else { + return "(no device)".to_string(); + }; + + let width = usize::from(width).max(1); + let graph_height = usize::from(height.saturating_sub(1)).max(1); + let visible_layers = self.visible_layer_count(width); + let start = self.layers.len() - visible_layers; + let end = self.layers.len() - 1; + let layers = &self.layers[start..]; + + let mut canvas = vec![vec![' '; width]; graph_height]; + let layer_rows: Vec> = layers + .iter() + .map(|layer| spread_positions(layer.count, graph_height)) + .collect(); + let layer_x: Vec = (0..layers.len()) + .map(|idx| idx * TREE_LAYER_STRIDE) + .filter(|&x| x < width) + .collect(); + + for idx in 0..layer_x.len().saturating_sub(1) { + draw_transposed_connector( + &mut canvas, + layer_x[idx], + layer_x[idx + 1], + &layer_rows[idx], + &layer_rows[idx + 1], + ); + } + + for (&x, rows) in layer_x.iter().zip(layer_rows.iter()) { + for &row in rows { + canvas[row][x] = '●'; + } + } + + let mut lines = vec![format!( + "layers {start:03}..{end:03} | current: {} {}", + current.count, + current.kind.noun(current.count) + )]; + lines.extend( + canvas + .into_iter() + .map(|row| row.into_iter().collect::()), + ); + lines.join("\n") + } +} /// Terminal-agnostic state for the ppvm TUI. pub struct AppState { @@ -42,6 +144,10 @@ pub struct AppState { finished: bool, /// The command line: buffer, edit cursor, and history. editor: LineEditor, + /// Whether the State panel should render full state details when safe. + show_state_details: bool, + /// Branching-complexity history shown in the tree panel. + complexity: ComplexityHistory, /// The status/error line. status: String, /// Whether the help overlay is currently shown. @@ -68,6 +174,8 @@ impl AppState { paused: false, finished: false, editor: LineEditor::new(), + show_state_details: true, + complexity: ComplexityHistory::default(), status: String::new(), show_help: false, should_exit: false, @@ -112,6 +220,16 @@ impl AppState { self.show_help = !self.show_help; Ok(()) } + Command::ToggleState => { + self.show_state_details = !self.show_state_details; + let mode = if self.show_state_details { + "enabled" + } else { + "hidden" + }; + self.set_status(format!("state details {mode}")); + Ok(()) + } } } @@ -125,6 +243,7 @@ impl AppState { self.paused = false; self.finished = false; self.program.clear(); + self.reset_complexity_history(); self.set_status(format!("fresh {n}-qubit device")); Ok(()) } @@ -176,6 +295,7 @@ impl AppState { self.has_program = true; self.paused = true; self.finished = false; + self.reset_complexity_history(); self.refresh_cursor(); true } @@ -199,6 +319,7 @@ impl AppState { return Ok(()); } let outcome = self.machine.as_mut().unwrap().step_once()?; + self.record_complexity(); self.apply_outcome(outcome); self.refresh_cursor(); Ok(()) @@ -211,6 +332,7 @@ impl AppState { } while !self.finished { let outcome = self.machine.as_mut().unwrap().step_once()?; + self.record_complexity(); match outcome { StepOutcome::Continue => {} StepOutcome::Breakpoint => { @@ -253,6 +375,7 @@ impl AppState { } } else if self.n_qubits > 0 { self.machine = Some(PPVM::with_qubits(self.n_qubits)?); + self.reset_complexity_history(); self.set_status("reset device"); } else { self.set_status("nothing to reset"); @@ -289,6 +412,7 @@ impl AppState { self.log.push(format!(" => {bits}")); self.set_status(format!("=> {bits}")); } + self.record_complexity(); Ok(()) } @@ -296,6 +420,17 @@ impl AppState { self.status = s.into(); } + fn reset_complexity_history(&mut self) { + self.complexity.clear(); + self.record_complexity(); + } + + fn record_complexity(&mut self) { + if let Some(machine) = &self.machine { + self.complexity.push_if_changed(machine.complexity_metric()); + } + } + // ─── key handling ──────────────────────────────────────────────────── /// Apply one key event. Returns whether it was consumed. App-level keys @@ -366,11 +501,61 @@ impl AppState { /// The tableau rendering for the State panel. pub fn state_text(&self) -> String { match &self.machine { - Some(m) => m.state_string(), + Some(m) => { + let summary = state_summary(m); + let metric = m.complexity_metric(); + if !self.show_state_details { + return format!("state details hidden (:state to show)\n{summary}"); + } + if m.n_qubits() > STATE_DETAIL_QUBIT_LIMIT + || metric.count > STATE_DETAIL_COMPLEXITY_LIMIT + { + return format!( + "state details suppressed (limit: {STATE_DETAIL_QUBIT_LIMIT} qubits, {STATE_DETAIL_COMPLEXITY_LIMIT} {})\n{summary}", + metric.kind.noun(STATE_DETAIL_COMPLEXITY_LIMIT), + ); + } + format!("{summary}\n{}", m.compact_state_string()) + } None => "(no device — type `device N` or :load )".to_string(), } } + /// Current CPU operand stack, top entry first. + pub fn stack_text(&self) -> String { + let Some(machine) = &self.machine else { + return "(no device)".to_string(); + }; + let stack = machine.stack_snapshot(); + if stack.is_empty() { + return "(empty)".to_string(); + } + stack + .iter() + .enumerate() + .rev() + .map(|(idx, value)| { + let marker = if idx + 1 == stack.len() { "top" } else { " " }; + format!("{idx:04} {marker} {value}") + }) + .collect::>() + .join("\n") + } + + /// Exact complexity counts recorded in the tree, exposed for tests and + /// embedders that want to render the history themselves. + pub fn complexity_counts(&self) -> Vec { + self.complexity.counts() + } + + pub fn complexity_graph_text(&self, width: u16) -> String { + self.complexity.render(width, DEFAULT_TREE_HEIGHT) + } + + pub fn complexity_graph_text_for_area(&self, width: u16, height: u16) -> String { + self.complexity.render(width, height) + } + /// The measurement record as flat bits, events separated by spaces. pub fn measurement_bits(&self) -> String { match &self.machine { @@ -414,23 +599,28 @@ impl AppState { /// `…View` widgets itself. pub fn render(&self, frame: &mut Frame) { let root = Layout::vertical([ - Constraint::Min(6), // Program | State - Constraint::Length(3), // measurement record - Constraint::Length(2), // command line + Constraint::Ratio(3, 5), // Program | complexity tree + Constraint::Ratio(2, 5), // Stack | State + Constraint::Length(3), // measurement record + Constraint::Length(2), // command line ]) .split(frame.area()); - let top = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + let top = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]) .split(root[0]); + let middle = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(root[1]); frame.render_widget(ProgramView(self), top[0]); - frame.render_widget(StateView(self), top[1]); - frame.render_widget(RecordView(self), root[1]); - frame.render_widget(CommandLine(self), root[2]); + frame.render_widget(ComplexityTreeView(self), top[1]); + frame.render_widget(StackView(self), middle[0]); + frame.render_widget(StateView(self), middle[1]); + frame.render_widget(RecordView(self), root[2]); + frame.render_widget(CommandLine(self), root[3]); // Place the terminal cursor in the input line, just after the prompt, // clamped to the command area so a long line can't run off-panel. - let cmd = root[2]; + let cmd = root[3]; let col = cmd.x + CommandLine::PROMPT.len() as u16 + self.editor.cursor() as u16; let x = col.min(cmd.x + cmd.width.saturating_sub(1)); frame.set_cursor_position((x, cmd.y)); @@ -461,6 +651,7 @@ Meta / debug Enter (empty) :s step one instruction :continue :c run to the next breakpoint or the end :reset restart the loaded program / device + :state toggle detailed state rendering :help :h toggle this help :quit :q (Ctrl-C) leave @@ -490,6 +681,144 @@ fn format_record(record: &[MeasurementResult]) -> String { .join(" ") } +fn state_summary(machine: &PPVM) -> String { + let metric = machine.complexity_metric(); + format!( + "{} backend | {} {} | {} {}", + machine.backend_name(), + machine.n_qubits(), + plural(machine.n_qubits(), "qubit", "qubits"), + metric.count, + metric.kind.noun(metric.count) + ) +} + +fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str { + if count == 1 { one } else { many } +} + +fn spread_positions(count: usize, height: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let visible = count.min(height.max(1)); + if visible == 1 { + return vec![height.saturating_sub(1) / 2]; + } + + let full_span = height.saturating_sub(1); + let inner_span = height.saturating_sub(3); + let inner_capacity = height.saturating_sub(2); + let desired_span = if visible <= inner_capacity && inner_span > 0 { + if visible == 2 { + 2.min(inner_span) + } else { + (visible - 1).min(inner_span) + } + } else { + full_span + }; + let top = full_span.saturating_sub(desired_span) / 2; + let mut out = Vec::with_capacity(visible); + for idx in 0..visible { + let pos = top + idx * desired_span / (visible - 1); + if out.last() != Some(&pos) { + out.push(pos); + } + } + out +} + +fn draw_transposed_connector( + canvas: &mut [Vec], + from_x: usize, + to_x: usize, + from_rows: &[usize], + to_rows: &[usize], +) { + if from_rows.is_empty() || to_rows.is_empty() || from_x >= to_x { + return; + } + let bus_x = (from_x + to_x) / 2; + let min_row = from_rows + .iter() + .chain(to_rows.iter()) + .min() + .copied() + .unwrap_or(0); + let max_row = from_rows + .iter() + .chain(to_rows.iter()) + .max() + .copied() + .unwrap_or(min_row); + + for &row in from_rows { + draw_canvas_horizontal(canvas, row, from_x + 1, bus_x); + } + for &row in to_rows { + draw_canvas_horizontal(canvas, row, bus_x, to_x.saturating_sub(1)); + } + for row in min_row..=max_row { + let glyph = if row == min_row { + '╭' + } else if row == max_row { + '╰' + } else { + '│' + }; + put_canvas_connector(canvas, row, bus_x, glyph); + } + for &row in from_rows { + put_canvas_connector(canvas, row, bus_x, '┤'); + } + for &row in to_rows { + put_canvas_connector(canvas, row, bus_x, '├'); + } +} + +fn draw_canvas_horizontal(canvas: &mut [Vec], row: usize, start: usize, end: usize) { + if start > end { + return; + } + for idx in start..=end { + put_canvas_connector(canvas, row, idx, '─'); + } +} + +fn put_canvas_connector(canvas: &mut [Vec], row: usize, col: usize, glyph: char) { + let Some(line) = canvas.get_mut(row) else { + return; + }; + let Some(slot) = line.get_mut(col) else { + return; + }; + *slot = merge_connector(*slot, glyph); +} + +fn merge_connector(existing: char, incoming: char) -> char { + match (existing, incoming) { + (' ', glyph) => glyph, + ('─', glyph) | (glyph, '─') => glyph, + (same, glyph) if same == glyph => same, + ('╭', '├') | ('├', '╭') => '┌', + ('╰', '├') | ('├', '╰') => '└', + ('╭', '┤') | ('┤', '╭') => '┐', + ('╰', '┤') | ('┤', '╰') => '┘', + ('│', '├') | ('├', '│') => '├', + ('│', '┤') | ('┤', '│') => '┤', + ('├', '┤') | ('┤', '├') => '┼', + ('│', '┴') | ('┴', '│') => '┴', + ('│', '┬') | ('┬', '│') => '┬', + ('│', _) | (_, '│') => '┼', + ('╭' | '╮' | '╰' | '╯', '┴' | '┬' | '┼') + | ('┴' | '┬' | '┼', '╭' | '╮' | '╰' | '╯') + | ('┴', '┬') + | ('┬', '┴') => '┼', + (_, glyph) => glyph, + } +} + #[cfg(test)] mod tests { use super::*; @@ -508,6 +837,315 @@ mod tests { assert!(app.status().contains("=> 1"), "status: {}", app.status()); } + #[test] + fn state_command_toggles_state_details() { + let mut app = AppState::new(); + app.dispatch("device 1"); + assert!(app.state_text().contains("Tableau")); + + app.dispatch(":state"); + assert!( + app.state_text().contains("state details hidden"), + "state text: {}", + app.state_text() + ); + + app.dispatch(":state"); + assert!( + !app.state_text().contains("state details hidden"), + "state text: {}", + app.state_text() + ); + } + + #[test] + fn large_device_state_is_summarized_without_detail_formatting() { + let mut app = AppState::new(); + app.dispatch("device 17"); + let state = app.state_text(); + assert!( + state.contains("state details suppressed"), + "state text: {state}" + ); + assert!(state.contains("17 qubits"), "state text: {state}"); + assert!(state.contains("coefficient"), "state text: {state}"); + } + + #[test] + fn ten_qubit_state_uses_compact_side_by_side_tableau_format() { + let mut app = AppState::new(); + app.dispatch("device 10"); + + let state = app.state_text(); + assert!( + state.lines().count() <= 12, + "state text should be compact enough for a 10-qubit tableau:\n{state}" + ); + assert!( + state.lines().any(|line| { + line.contains("q00") + && line.contains(" D ") + && line.contains(" S ") + && line.contains("Index 0:") + }), + "coefficient column should share a row with paired tableau rows:\n{state}" + ); + assert!( + !state + .lines() + .any(|line| line.trim_start().starts_with("Index 0:")), + "coefficients should not be stacked below the tableau:\n{state}" + ); + } + + #[test] + fn stack_text_tracks_current_cpu_stack() { + const STACK_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + fn @main() { const.u64 7\n breakpoint\n ret }\n"; + let mut app = AppState::new(); + app.load_source(STACK_PROGRAM).unwrap(); + + assert_eq!(app.stack_text(), "(empty)"); + app.dispatch(""); // const.u64 7 + + let stack = app.stack_text(); + assert!(stack.contains("top"), "stack text: {stack}"); + assert!(stack.contains("7"), "stack text: {stack}"); + } + + #[test] + fn complexity_history_records_only_count_changes() { + let mut app = AppState::new(); + app.dispatch("device 1"); + assert_eq!(app.complexity_counts(), vec![1]); + + app.dispatch("h 0"); + assert_eq!( + app.complexity_counts(), + vec![1], + "Clifford H should not add a layer when coefficient count is unchanged" + ); + + app.dispatch("t 0"); + assert_eq!(app.complexity_counts(), vec![1, 2]); + + app.dispatch("measure 0"); + assert_eq!( + app.complexity_counts(), + vec![1, 2, 1], + "measurement should record the shrink back to one coefficient" + ); + } + + #[test] + fn complexity_tree_uses_unicode_connectors() { + let mut app = AppState::new(); + app.dispatch("device 2"); + app.dispatch("h 0"); + app.dispatch("h 1"); + app.dispatch("t 0"); + app.dispatch("t 1"); + assert_eq!(app.complexity_counts(), vec![1, 2, 4]); + + let graph = app.complexity_graph_text(72); + assert!(graph.contains('●'), "graph text: {graph}"); + assert!(graph.contains('┌'), "graph text: {graph}"); + assert!(graph.contains('└'), "graph text: {graph}"); + assert!(graph.contains('├'), "graph text: {graph}"); + assert!( + graph.contains('┤') || graph.contains('┼'), + "graph text: {graph}" + ); + assert!( + !graph.lines().skip(1).any(|line| line.contains('o')), + "graph text: {graph}" + ); + assert!(!graph.contains('/'), "graph text: {graph}"); + assert!(!graph.contains('\\'), "graph text: {graph}"); + } + + #[test] + fn complexity_tree_is_transposed_and_autoscrolls_to_latest_layers() { + let mut app = AppState::new(); + app.complexity.clear(); + for count in [1, 2, 4, 2, 5, 3, 6, 1] { + app.complexity.push_if_changed(ComplexityMetric { + kind: ComplexityMetricKind::Coefficients, + count, + }); + } + + let graph = app.complexity_graph_text(24); + assert!(graph.contains("layers 004..007"), "graph text: {graph}"); + assert!(!graph.contains("000"), "graph text: {graph}"); + assert!(!graph.contains("001"), "graph text: {graph}"); + assert!( + !graph.lines().any(|line| line.starts_with("000 1")), + "graph text: {graph}" + ); + assert!(graph.contains("004"), "graph text: {graph}"); + assert!(graph.contains("007"), "graph text: {graph}"); + } + + #[test] + fn complexity_tree_small_branching_stays_near_center() { + let mut app = AppState::new(); + app.complexity.clear(); + for count in [1, 2] { + app.complexity.push_if_changed(ComplexityMetric { + kind: ComplexityMetricKind::Coefficients, + count, + }); + } + + let graph_height = 7; + let graph = app.complexity_graph_text_for_area(24, graph_height + 2); + let node_rows: Vec = graph + .lines() + .skip(1) + .take(graph_height as usize) + .enumerate() + .filter_map(|(row, line)| line.contains('●').then_some(row)) + .collect(); + + assert!( + node_rows.contains(&(graph_height as usize / 2)), + "initial node should stay centered:\n{graph}" + ); + assert!( + !node_rows.contains(&0) && !node_rows.contains(&(graph_height as usize - 1)), + "two-way branching should not jump straight to the borders:\n{graph}" + ); + } + + #[test] + fn complexity_tree_centers_initial_node_in_short_even_viewport() { + let mut app = AppState::new(); + app.dispatch("device 1"); + + let graph_height = 5; + let graph = app.complexity_graph_text_for_area(24, graph_height + 1); + let node_row = graph + .lines() + .skip(1) + .take(graph_height as usize) + .position(|line| line.contains('●')) + .expect("initial node should be visible"); + + assert!( + node_row <= (graph_height as usize - 1) / 2, + "initial node should use the upper center row in a short even viewport:\n{graph}" + ); + } + + #[test] + fn complexity_tree_initial_view_does_not_start_with_bottom_label() { + let mut app = AppState::new(); + app.dispatch("device 1"); + + let graph = app.complexity_graph_text_for_area(24, 8); + assert!( + !graph.lines().last().unwrap_or("").contains("000:1"), + "initial tree should not draw its layer label at bottom-left:\n{graph}" + ); + } + + #[test] + fn rendered_complexity_tree_initial_node_is_vertically_centered() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = AppState::new(); + app.dispatch("device 1"); + + let mut terminal = Terminal::new(TestBackend::new(100, 18)).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + let lines = buffer_lines(terminal.backend().buffer(), 100); + + let tree_top = row_containing(&lines, "Complexity tree").unwrap(); + let tree_bottom = lines + .iter() + .enumerate() + .skip(tree_top + 1) + .find_map(|(row, line)| line.contains('┘').then_some(row)) + .unwrap(); + let node_row = lines + .iter() + .enumerate() + .skip(tree_top + 1) + .take(tree_bottom - tree_top) + .find_map(|(row, line)| line.contains('●').then_some(row)) + .unwrap(); + + let panel_mid = tree_top + (tree_bottom - tree_top) / 2; + let lower_slack = (tree_bottom - tree_top) / 4; + assert!( + node_row <= panel_mid + lower_slack, + "initial node should not render near the bottom of the tree panel\n{}", + lines.join("\n") + ); + } + + #[test] + fn standalone_layout_puts_tree_above_state_on_the_right() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = AppState::new(); + app.dispatch("device 2"); + app.dispatch("h 0"); + app.dispatch("t 0"); + + let mut terminal = Terminal::new(TestBackend::new(120, 36)).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + let lines = buffer_lines(terminal.backend().buffer(), 120); + + let tree_row = row_containing(&lines, "Complexity tree").unwrap(); + let state_row = row_containing(&lines, "State").unwrap(); + assert!( + tree_row < state_row, + "tree should be above state\n{}", + lines.join("\n") + ); + assert!( + lines[tree_row].find("Complexity tree").unwrap() > 50, + "tree should be in the right pane\n{}", + lines.join("\n") + ); + assert!( + lines[state_row].find("State").unwrap() > 50, + "state should remain in the right pane\n{}", + lines.join("\n") + ); + } + + #[test] + fn complexity_history_uses_paulisum_term_counts() { + const PAULISUM_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + device circuit.backend paulisum;\n\ + device circuit.observable Z;\n\ + fn @main() {\n\ + const.u64 0\n\ + const.f64 0.7\n\ + circuit.ry\n\ + ret\n\ + }\n"; + let mut app = AppState::new(); + app.load_source(PAULISUM_PROGRAM).unwrap(); + assert_eq!(app.complexity_counts(), vec![1]); + + app.dispatch(""); // const.u64 0 + app.dispatch(""); // const.f64 0.7 + app.dispatch(""); // circuit.ry: Z -> cos(theta) Z + sin(theta) X + + assert_eq!(app.complexity_counts(), vec![1, 2]); + assert!( + app.state_text().contains("2 terms"), + "state text: {}", + app.state_text() + ); + } + #[test] fn fresh_measure_is_zero() { let mut app = AppState::new(); @@ -784,4 +1422,16 @@ mod tests { "gate reference missing from overlay" ); } + + fn buffer_lines(buffer: &ratatui::buffer::Buffer, width: u16) -> Vec { + buffer + .content + .chunks(width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect()) + .collect() + } + + fn row_containing(lines: &[String], needle: &str) -> Option { + lines.iter().position(|line| line.contains(needle)) + } } diff --git a/crates/ppvm-tui/src/command.rs b/crates/ppvm-tui/src/command.rs index 0cea9ba1..878bb39d 100644 --- a/crates/ppvm-tui/src/command.rs +++ b/crates/ppvm-tui/src/command.rs @@ -80,6 +80,8 @@ pub enum Command { Load(String), /// Toggle the help overlay. Help, + /// Toggle detailed state rendering in the State panel. + ToggleState, /// Leave the TUI. Quit, } @@ -101,6 +103,7 @@ pub fn parse_command(line: &str) -> Result { "s" | "step" => Ok(Command::Step), "reset" => Ok(Command::Reset), "help" | "h" | "?" => Ok(Command::Help), + "state" => Ok(Command::ToggleState), "load" => { let path = it.next().ok_or_else(|| eyre!(":load needs a file path"))?; if it.next().is_some() { @@ -125,8 +128,9 @@ pub fn parse_command(line: &str) -> Result { return Ok(Command::Device(n)); } - let spec = - gate_spec(head).ok_or_else(|| eyre!("unknown command {head:?}; try :load or device N"))?; + let gate_name = head.to_ascii_lowercase(); + let spec = gate_spec(&gate_name) + .ok_or_else(|| eyre!("unknown command {head:?}; try :load or device N"))?; let expected = spec.qubits + spec.floats; if args.len() != expected { bail!( @@ -184,6 +188,34 @@ mod tests { ); } + #[test] + fn gate_names_are_case_insensitive() { + assert_eq!( + parse_command("H 0").unwrap(), + Command::Gate { + inst: CircuitInstruction::H, + qubits: vec![0], + params: vec![], + } + ); + assert_eq!( + parse_command("CNOT 0 1").unwrap(), + Command::Gate { + inst: CircuitInstruction::CNOT, + qubits: vec![0, 1], + params: vec![], + } + ); + assert_eq!( + parse_command("Rx 0 0.5").unwrap(), + Command::Gate { + inst: CircuitInstruction::RX, + qubits: vec![0], + params: vec![0.5], + } + ); + } + #[test] fn two_qubit_gate_keeps_operand_order() { assert_eq!( @@ -216,6 +248,7 @@ mod tests { assert_eq!(parse_command(":reset").unwrap(), Command::Reset); assert_eq!(parse_command(":help").unwrap(), Command::Help); assert_eq!(parse_command(":h").unwrap(), Command::Help); + assert_eq!(parse_command(":state").unwrap(), Command::ToggleState); assert_eq!( parse_command(":load foo.sst").unwrap(), Command::Load("foo.sst".to_string()) @@ -239,6 +272,11 @@ mod tests { assert!(parse_command(":load a.sst junk").is_err()); } + #[test] + fn tree_scroll_command_is_not_supported() { + assert!(parse_command(":tree older").is_err()); + } + #[test] fn device_rejects_trailing_tokens() { assert!(parse_command("device 2").is_ok()); diff --git a/crates/ppvm-tui/src/widgets.rs b/crates/ppvm-tui/src/widgets.rs index f0dcf0ce..92a7767e 100644 --- a/crates/ppvm-tui/src/widgets.rs +++ b/crates/ppvm-tui/src/widgets.rs @@ -33,7 +33,8 @@ impl Widget for ProgramView<'_> { } } -/// The right panel: the tableau state (`PPVM::state_string`). +/// The right panel: backend summary plus state details when they are safe to +/// render. pub struct StateView<'a>(pub &'a AppState); impl Widget for StateView<'_> { @@ -45,6 +46,35 @@ impl Widget for StateView<'_> { } } +/// CPU operand-stack panel. The app supplies top-first text so the newest value +/// remains visible when the terminal clips the panel. +pub struct StackView<'a>(pub &'a AppState); + +impl Widget for StackView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.stack_text()) + .block(Block::bordered().title("CPU stack")) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + +/// Branching-complexity history: PauliSum term count or tableau coefficient +/// count, depending on the active backend. +pub struct ComplexityTreeView<'a>(pub &'a AppState); + +impl Widget for ComplexityTreeView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.complexity_graph_text_for_area( + area.width.saturating_sub(2), + area.height.saturating_sub(2), + )) + .block(Block::bordered().title("Complexity tree")) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + /// The measurement-record band. pub struct RecordView<'a>(pub &'a AppState); @@ -113,6 +143,11 @@ mod tests { .collect(); assert!(content.contains("Program"), "missing Program panel"); assert!(content.contains("State"), "missing State panel"); + assert!(content.contains("CPU stack"), "missing CPU stack panel"); + assert!( + content.contains("Complexity tree"), + "missing complexity tree panel" + ); assert!( content.contains("Measurement record"), "missing record panel" diff --git a/crates/ppvm-vihaco/src/component.rs b/crates/ppvm-vihaco/src/component.rs index c0f1efa9..099a1e86 100644 --- a/crates/ppvm-vihaco/src/component.rs +++ b/crates/ppvm-vihaco/src/component.rs @@ -38,6 +38,33 @@ type PauliSumConfig = ByteFxHashF64; type LossyPauliSumConfig = ByteFxHashF64>; +/// What the TUI should count for the active backend's branching complexity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComplexityMetricKind { + /// Number of Pauli terms in a `PauliSum` / `LossyPauliSum`. + Terms, + /// Number of sparse coefficients in a `GeneralizedTableau`. + Coefficients, +} + +impl ComplexityMetricKind { + pub fn noun(self, count: usize) -> &'static str { + match (self, count) { + (Self::Terms, 1) => "term", + (Self::Terms, _) => "terms", + (Self::Coefficients, 1) => "coefficient", + (Self::Coefficients, _) => "coefficients", + } + } +} + +/// Exact active-backend complexity count for debugger visualisation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ComplexityMetric { + pub kind: ComplexityMetricKind, + pub count: usize, +} + /// Build a `PauliSumStrategy` value from a `PPVMDeviceInfo`. Pulled out so the /// six size-bucket constructors don't each repeat the strategy spelling. fn paulisum_strategy(info: &PPVMDeviceInfo) -> PauliSumStrategy { @@ -71,6 +98,27 @@ pub struct CircuitExecutor, I: TableauIndex, C: SparseVec pub tab: GeneralizedTableau, } +impl CircuitExecutor +where + T: Config, + I: TableauIndex, + C: SparseVector, +{ + fn complexity_metric(&self) -> ComplexityMetric { + ComplexityMetric { + kind: ComplexityMetricKind::Coefficients, + count: self.tab.coefficients.len(), + } + } + + fn compact_state_string(&self) -> String + where + I: std::fmt::Display, + { + compact_generalized_tableau(&self.tab) + } +} + #[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl CircuitExecutor where @@ -449,6 +497,18 @@ pub struct PauliSumExecutor> { initial: PauliSum, } +impl PauliSumExecutor +where + T: Config, +{ + fn complexity_metric(&self) -> ComplexityMetric { + ComplexityMetric { + kind: ComplexityMetricKind::Terms, + count: self.state.len(), + } + } +} + #[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl PauliSumExecutor where @@ -500,6 +560,18 @@ pub struct LossyPauliSumExecutor> { initial: PauliSum, } +impl LossyPauliSumExecutor +where + T: Config, +{ + fn complexity_metric(&self) -> ComplexityMetric { + ComplexityMetric { + kind: ComplexityMetricKind::Terms, + count: self.state.len(), + } + } +} + #[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] impl LossyPauliSumExecutor where @@ -650,6 +722,28 @@ impl TableauCircuit { Self::Bits2048(ex) => ex.tab.to_string(), } } + + pub fn compact_state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.compact_state_string(), + Self::Bits128(ex) => ex.compact_state_string(), + Self::Bits256(ex) => ex.compact_state_string(), + Self::Bits512(ex) => ex.compact_state_string(), + Self::Bits1024(ex) => ex.compact_state_string(), + Self::Bits2048(ex) => ex.compact_state_string(), + } + } + + pub fn complexity_metric(&self) -> ComplexityMetric { + match self { + Self::Bits64(ex) => ex.complexity_metric(), + Self::Bits128(ex) => ex.complexity_metric(), + Self::Bits256(ex) => ex.complexity_metric(), + Self::Bits512(ex) => ex.complexity_metric(), + Self::Bits1024(ex) => ex.complexity_metric(), + Self::Bits2048(ex) => ex.complexity_metric(), + } + } } impl vihaco::Reset for TableauCircuit { @@ -742,6 +836,17 @@ impl PauliSumCircuit { Self::Bits2048(ex) => ex.state.to_string(), } } + + pub fn complexity_metric(&self) -> ComplexityMetric { + match self { + Self::Bits64(ex) => ex.complexity_metric(), + Self::Bits128(ex) => ex.complexity_metric(), + Self::Bits256(ex) => ex.complexity_metric(), + Self::Bits512(ex) => ex.complexity_metric(), + Self::Bits1024(ex) => ex.complexity_metric(), + Self::Bits2048(ex) => ex.complexity_metric(), + } + } } impl vihaco::Reset for PauliSumCircuit { @@ -831,6 +936,17 @@ impl LossyPauliSumCircuit { Self::Bits2048(ex) => ex.state.to_string(), } } + + pub fn complexity_metric(&self) -> ComplexityMetric { + match self { + Self::Bits64(ex) => ex.complexity_metric(), + Self::Bits128(ex) => ex.complexity_metric(), + Self::Bits256(ex) => ex.complexity_metric(), + Self::Bits512(ex) => ex.complexity_metric(), + Self::Bits1024(ex) => ex.complexity_metric(), + Self::Bits2048(ex) => ex.complexity_metric(), + } + } } impl vihaco::Reset for LossyPauliSumCircuit { @@ -918,6 +1034,89 @@ impl Circuit { Self::LossyPauliSum(c) => c.state_string(), } } + + pub fn compact_state_string(&self) -> String { + match self { + Self::Tableau(c) => c.compact_state_string(), + Self::PauliSum(c) => c.state_string(), + Self::LossyPauliSum(c) => c.state_string(), + } + } + + pub fn backend_name(&self) -> &'static str { + match self { + Self::Tableau(_) => "Tableau", + Self::PauliSum(_) => "PauliSum", + Self::LossyPauliSum(_) => "LossyPauliSum", + } + } + + pub fn complexity_metric(&self) -> ComplexityMetric { + match self { + Self::Tableau(c) => c.complexity_metric(), + Self::PauliSum(c) => c.complexity_metric(), + Self::LossyPauliSum(c) => c.complexity_metric(), + } + } +} + +fn compact_generalized_tableau(tab: &GeneralizedTableau) -> String +where + T: Config, + I: TableauIndex + std::fmt::Display, + C: SparseVector, +{ + let mut left = Vec::with_capacity(1 + tab.tableau.n_qubits); + left.push(format!("Tableau rows ({} qubits)", tab.tableau.n_qubits)); + left.extend( + tab.tableau + .destabilizers() + .iter() + .zip(tab.tableau.stabilizers()) + .enumerate() + .map(|(idx, (destabilizer, stabilizer))| { + format!("q{idx:02} D {destabilizer} S {stabilizer}") + }), + ); + + let mut right = Vec::new(); + right.push("Coefficients".to_string()); + right.extend( + tab.coefficients + .iter() + .map(|(coeff, idx)| format!("Index {idx}: {coeff}")), + ); + if tab.is_lost.iter().all(|&lost| !lost) { + right.push("Lost: none".to_string()); + } else { + right.push("Lost qubits".to_string()); + right.extend( + tab.is_lost + .iter() + .enumerate() + .filter(|&(_, &lost)| lost) + .map(|(idx, _)| format!("q{idx}: true")), + ); + } + + merge_text_columns(&left, &right) +} + +fn merge_text_columns(left: &[String], right: &[String]) -> String { + let left_width = left.iter().map(|line| line.len()).max().unwrap_or(0); + let rows = left.len().max(right.len()); + let mut out = Vec::with_capacity(rows); + for idx in 0..rows { + match (left.get(idx), right.get(idx)) { + (Some(left), Some(right)) => { + out.push(format!("{left: out.push(left.clone()), + (None, Some(right)) => out.push(format!("{:left_width$} {right}", "")), + (None, None) => {} + } + } + out.join("\n") } #[observe(CircuitEffect, effect=CircuitOutcomeEffect)] diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index 72877bdb..41299731 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -13,9 +13,9 @@ use vihaco_cpu::{CPU, CPUMessage}; /// without depending on `vihaco-cpu` directly. pub use vihaco_cpu::StepOutcome; -use crate::component::Circuit; #[cfg(test)] use crate::component::TableauCircuit; +use crate::component::{Circuit, ComplexityMetric}; use crate::measurements::{ CircuitOutcomeEffect, MeasurementEffect, MeasurementObserver, MeasurementResult, TraceEffect, TraceObserver, @@ -353,6 +353,48 @@ impl PPVM { self.circuit.state_string() } + /// Compact state view for debugger panels. Tableau-backed devices place + /// coefficient metadata beside the tableau rows instead of below them. + pub fn compact_state_string(&self) -> String { + self.circuit.compact_state_string() + } + + /// Human-readable active backend name. + pub fn backend_name(&self) -> &'static str { + self.circuit.backend_name() + } + + /// Number of qubits configured for the loaded machine. + pub fn n_qubits(&self) -> usize { + self.loader.module.extra.n_qubits + } + + /// Active-backend branching complexity: PauliSum terms or tableau + /// coefficients, depending on the current backend. + pub fn complexity_metric(&self) -> ComplexityMetric { + self.circuit.complexity_metric() + } + + /// Current CPU operand stack, formatted bottom-to-top. String values are + /// resolved through the loaded module's string table when possible. + pub fn stack_snapshot(&self) -> Vec { + self.cpu + .stack() + .iter() + .map(|value| self.format_stack_value(value)) + .collect() + } + + fn format_stack_value(&self, value: &Value) -> String { + match value { + Value::String(addr) => match self.loader.get_string(*addr as usize) { + Ok(s) => format!("str[{addr}] {s:?}"), + Err(_) => format!("str[{addr}] "), + }, + _ => value.to_string(), + } + } + /// Build a fresh, initialized `n_qubits`-qubit device with no code. The /// REPL's `device` command uses this to (re)create the machine. Errors if /// `n_qubits` is zero (a device must have at least one qubit).