From c33b8d7384c8d7ae1e53178af27e6b7cb8a1b397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Mon, 28 Sep 2026 13:35:44 +0200 Subject: [PATCH 1/8] Decouple calculation presentation from input files --- README.md | 11 ++- crates/rustiq-core/src/calculation.rs | 2 + crates/rustiq-core/src/calculation/builder.rs | 32 ++++++- .../src/calculation/prepared_calculation.rs | 20 ++++ crates/rustiq-core/src/calculation/request.rs | 41 ++++++++ crates/rustiq-core/src/molecules/molecule.rs | 2 - src/cli/commands/init_command.rs | 4 +- src/cli/commands/run_command.rs | 45 ++++++--- src/cli/ux/calculation_presentation.rs | 94 +++++++++++++++++++ src/cli/ux/mod.rs | 1 + src/runfile/cache.rs | 2 +- src/runfile/global/mod.rs | 2 +- src/runfile/global/molecule_config.rs | 2 +- src/runfile/hf.rs | 6 +- src/runfile/mod.rs | 2 +- src/runfile/mp2.rs | 2 +- src/runfile/output.rs | 28 +++++- src/runfile/parser.rs | 8 -- tests/cli_samples.rs | 6 ++ 19 files changed, 272 insertions(+), 38 deletions(-) create mode 100644 crates/rustiq-core/src/calculation/request.rs create mode 100644 src/cli/ux/calculation_presentation.rs diff --git a/README.md b/README.md index acf893b..368a1c9 100644 --- a/README.md +++ b/README.md @@ -744,7 +744,16 @@ the CLI only loads inputs and presents them. The builder follows the `WSLCommand` conventions from WSLPlugins-rs: mutable setters, consuming `with_*` variants, getters, and `prepare()` / `execute()`. `PreparedCalculation` retains the validated molecule and basis and can be -executed repeatedly through the shared `CalculationExecution` trait. +executed repeatedly through the shared `CalculationExecution` trait. It also +exposes a normalized `CalculationRequest` with requested coordinates, units, +molecular state, and effective scientific options, without frontend source +spans or geometry paths. In text mode, `run` presents the requested settings in +canonical TOML with defaults, followed by XYZ rendered from that request in the +requested units. It then presents the resolved method, basis size, and +canonical XYZ in Bohr from the prepared calculation. TOML and XYZ are CLI +presentation adapters; future input frontends should adapt into the same +scientific configuration and preparation path rather than introduce their +syntax into `rustiq-core`. `run_hf()` returns `HfOutcome::Converged(HfSolution)` or `HfOutcome::Unconverged(HfSolution)`. Both retain the HF summary, orbitals and integrals; only the converged type exposes `mp2()`. Cloning a diff --git a/crates/rustiq-core/src/calculation.rs b/crates/rustiq-core/src/calculation.rs index 080e9d4..e1b76ba 100644 --- a/crates/rustiq-core/src/calculation.rs +++ b/crates/rustiq-core/src/calculation.rs @@ -81,6 +81,7 @@ pub use solution::{CalculationExecutionError, Converged, HfOutcome, HfSolution, mod builder; mod execution; mod prepared_calculation; +mod request; pub use crate::basis::{Basis, BasisError}; pub use crate::eri::EriError; use crate::hf::{scf::ScfSetupError, uhf::UhfSetupError}; @@ -104,6 +105,7 @@ pub use execution::{ HfCalculationResult, Mp2MemoryPlan, }; pub use prepared_calculation::PreparedCalculation; +pub use request::CalculationRequest; use crate::{ config::{ diff --git a/crates/rustiq-core/src/calculation/builder.rs b/crates/rustiq-core/src/calculation/builder.rs index 0fa19af..0db2c74 100644 --- a/crates/rustiq-core/src/calculation/builder.rs +++ b/crates/rustiq-core/src/calculation/builder.rs @@ -8,7 +8,7 @@ use std::time::Instant; use super::{ CalculationError, CalculationEvent, CalculationExecution, CalculationExecutionError, - CalculationResult, PreparedCalculation, + CalculationRequest, CalculationResult, PreparedCalculation, }; /// Configure a calculation from explicitly loaded inputs. @@ -151,7 +151,24 @@ impl<'a> CalculationBuilder<'a> { basis: &basis, elapsed: start.elapsed(), }); + let mut requested_geometry = self.geometry.clone(); + requested_geometry.comment = "Requested geometry".into(); + let request = CalculationRequest { + geometry: requested_geometry, + molecule: MoleculeConfig { + units: self.molecule_config.units, + charge: self.molecule_config.charge.value.into(), + multiplicity: self.molecule_config.multiplicity.value.into(), + }, + basis_name: self.basis_file.name().to_owned(), + hf: request_hf_config(&self.hf), + mp2: self.mp2.map(|config| Mp2Config { + frozen_orbitals: config.frozen_orbitals.value.into(), + memory_limit: config.memory_limit.value.into(), + }), + }; Ok(PreparedCalculation { + request, molecule, basis, hf, @@ -161,6 +178,19 @@ impl<'a> CalculationBuilder<'a> { } } +fn request_hf_config(config: &HfConfig) -> HfConfig { + HfConfig { + method: config.method.value.into(), + max_iterations: config.max_iterations, + convergence_threshold: config.convergence_threshold, + linear_dependency_threshold: config.linear_dependency_threshold.value.into(), + eri_schwarz_threshold: config.eri_schwarz_threshold, + guess: config.guess.value.into(), + diis: config.diis, + diis_size: config.diis_size, + } +} + impl CalculationExecution for CalculationBuilder<'_> { fn execute_with_events( &self, diff --git a/crates/rustiq-core/src/calculation/prepared_calculation.rs b/crates/rustiq-core/src/calculation/prepared_calculation.rs index 6dee6ce..2bb82be 100644 --- a/crates/rustiq-core/src/calculation/prepared_calculation.rs +++ b/crates/rustiq-core/src/calculation/prepared_calculation.rs @@ -11,11 +11,14 @@ use crate::{ }; use std::cell::RefCell; +use super::CalculationRequest; + /// A validated molecule in Bohr and its basis, prepared together by the builder. /// /// Each execution starts fresh HF state and uses the same immutable inputs. /// This avoids self-referential SCF storage and allows reuse of the basis. pub struct PreparedCalculation { + pub(super) request: CalculationRequest, pub(super) molecule: Molecule, pub(super) basis: Basis, pub(super) hf: (HfConfig, ResolvedHfMethod), @@ -24,6 +27,11 @@ pub struct PreparedCalculation { } impl PreparedCalculation { + /// Returns the normalized inputs as requested before scientific resolution. + pub fn request(&self) -> &CalculationRequest { + &self.request + } + pub fn get_molecule(&self) -> &Molecule { &self.molecule } @@ -31,6 +39,18 @@ impl PreparedCalculation { pub fn get_basis(&self) -> &Basis { &self.basis } + + pub fn hf_method(&self) -> ResolvedHfMethod { + self.hf.1 + } + + pub fn hf_config(&self) -> &HfConfig { + &self.hf.0 + } + + pub fn mp2_config(&self) -> Option<&Mp2Config> { + self.mp2.as_ref() + } } impl PreparedCalculation { diff --git a/crates/rustiq-core/src/calculation/request.rs b/crates/rustiq-core/src/calculation/request.rs new file mode 100644 index 0000000..392f964 --- /dev/null +++ b/crates/rustiq-core/src/calculation/request.rs @@ -0,0 +1,41 @@ +use crate::{ + config::{HfConfig, MoleculeConfig, Mp2Config}, + molecules::geometry::Geometry, +}; + +/// Frontend-independent, normalized inputs used to prepare a calculation. +/// +/// This view keeps requested coordinates and units alongside effective +/// scientific options. It deliberately contains no source text, source spans, +/// frontend syntax, or filesystem paths. +#[derive(Debug, Clone)] +pub struct CalculationRequest { + pub(crate) geometry: Geometry, + pub(crate) molecule: MoleculeConfig, + pub(crate) basis_name: String, + pub(crate) hf: HfConfig, + pub(crate) mp2: Option, +} + +impl CalculationRequest { + pub fn geometry(&self) -> &Geometry { + &self.geometry + } + + pub fn molecule(&self) -> &MoleculeConfig { + &self.molecule + } + + /// Canonical basis name associated with the loaded basis data. + pub fn basis_name(&self) -> &str { + &self.basis_name + } + + pub fn hf(&self) -> &HfConfig { + &self.hf + } + + pub fn mp2(&self) -> Option<&Mp2Config> { + self.mp2.as_ref() + } +} diff --git a/crates/rustiq-core/src/molecules/molecule.rs b/crates/rustiq-core/src/molecules/molecule.rs index cefcc8d..b025410 100644 --- a/crates/rustiq-core/src/molecules/molecule.rs +++ b/crates/rustiq-core/src/molecules/molecule.rs @@ -94,12 +94,10 @@ impl Molecule { &self.geometry } - #[allow(dead_code)] pub fn unit(&self) -> Units { self.unit } - #[allow(dead_code)] pub fn charge(&self) -> i32 { self.charge } diff --git a/src/cli/commands/init_command.rs b/src/cli/commands/init_command.rs index 50cb227..6ac215c 100644 --- a/src/cli/commands/init_command.rs +++ b/src/cli/commands/init_command.rs @@ -225,7 +225,7 @@ mod tests { command(&input, &output, &["--mp2"]).run().unwrap(); let content = fs::read_to_string(&output).unwrap(); - // Use the same parser and formatted TOML that RunCommand passes to bat. + // Use the same canonical TOML representation shown by RunCommand. let parsed = parse_runfile("calculation.toml", &content).unwrap(); for field in [ "charge", @@ -238,7 +238,7 @@ mod tests { ] { assert!(!content.contains(field), "init must omit {field}"); assert!( - parsed.formatted_toml.contains(field), + parsed.runfile.canonical_toml().unwrap().contains(field), "run display must include {field}" ); } diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index 33da7f4..16aa83c 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -6,11 +6,16 @@ use std::{ }; use clap::{ArgAction, ValueEnum}; -use miette::{miette, IntoDiagnostic, NamedSource}; +use miette::{miette, Diagnostic, IntoDiagnostic, NamedSource, Report}; use crate::cli::{ self, - ux::{bat, calculation_report::CalculationReporter, json_output::CalculationOutput}, + ux::{ + bat, + calculation_presentation::{requested_geometry, requested_heading, resolved_calculation}, + calculation_report::CalculationReporter, + json_output::CalculationOutput, + }, }; use crate::runfile::{hf::HfOutputFormat, parser::parse_runfile}; use rustiq_core::{ @@ -123,17 +128,9 @@ impl Runnable for RunCommand { }; let parsed = parse_runfile(source_name.clone(), &toml_content)?; let source_code = NamedSource::new(source_name, toml_content); - let scientific_error = - |error| miette::Report::new(error).with_source_code(source_code.clone()); let run = parsed.runfile; - if !json_output { - bat::print_toml(&parsed.formatted_toml); - } let molecule_path = &run.global.molecule.geometry; let geom = Geometry::from_path(molecule_path).into_diagnostic()?; - if !json_output { - bat::print_xyz(&geom.to_string()); - } if !json_output { println!("Loading basis set..."); } @@ -167,14 +164,31 @@ impl Runnable for RunCommand { } else { calculation }; + let prepared = { + let stdout = io::stdout(); + let mut reporter = CalculationReporter::new(stdout.lock(), !json_output, show_scf); + let prepared = calculation.prepare_with_events(|event| reporter.on_event(event)); + if let Some(error) = reporter.take_error() { + return Err(miette!("failed to write calculation report: {error}")); + } + prepared.map_err(|error| with_source(error, &source_code))? + }; + if !json_output { + let request_toml = run.canonical_toml().into_diagnostic()?; + println!("Requested calculation (TOML)"); + bat::print_toml(&request_toml); + println!("{}", requested_heading(prepared.request().molecule().units)); + bat::print_xyz(&requested_geometry(prepared.request())); + println!("{}", resolved_calculation(&prepared)); + } let result = { let stdout = io::stdout(); let mut reporter = CalculationReporter::new(stdout.lock(), !json_output, show_scf); - let outcome = calculation.execute_with_events(|event| reporter.on_event(event)); + let outcome = prepared.execute_with_events(|event| reporter.on_event(event)); if let Some(error) = reporter.take_error() { return Err(miette!("failed to write calculation report: {error}")); } - outcome.map_err(&scientific_error)? + outcome.map_err(|error| with_source(error, &source_code))? }; if json_output { let stdout = io::stdout(); @@ -193,6 +207,13 @@ impl Runnable for RunCommand { } } +fn with_source(error: E, source: &NamedSource) -> Report +where + E: Diagnostic + Send + Sync + 'static, +{ + Report::new(error).with_source_code(source.clone()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs new file mode 100644 index 0000000..e27f102 --- /dev/null +++ b/src/cli/ux/calculation_presentation.rs @@ -0,0 +1,94 @@ +use rustiq_core::{ + calculation::{CalculationRequest, PreparedCalculation}, + molecules::units::Units, +}; + +pub(crate) fn requested_geometry(request: &CalculationRequest) -> String { + request.geometry().to_string() +} + +pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> String { + let molecule = prepared.get_molecule(); + let multiplicity = molecule.multiplicity().get(); + let mut geometry = molecule.geometry().clone(); + geometry.comment = "Resolved geometry".into(); + format!( + "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)\n\nResolved geometry (XYZ, Bohr)\n{}", + molecule.charge(), + multiplicity, + prepared.hf_method(), + prepared.request().basis_name(), + prepared.get_basis().nbasis(), + geometry, + ) +} + +pub(crate) fn requested_heading(unit: Units) -> String { + format!("Requested geometry (XYZ, {})", unit_name(unit)) +} + +fn unit_name(unit: Units) -> &'static str { + match unit { + Units::Bohr => "Bohr", + Units::Angstrom => "Angstrom", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustiq_core::{ + basis::BasisFile, + calculation::CalculationBuilder, + config::{HfConfig, MoleculeConfig}, + molecules::{geometry::Geometry, units::Units}, + }; + + #[test] + fn prepared_views_render_requested_and_resolved_geometry_without_source_files() { + let geometry = + Geometry::from_reader(&include_bytes!("../../../samples/h2/molecule.xyz")[..]).unwrap(); + let basis = + BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap(); + let prepared = CalculationBuilder::new(&geometry, &basis) + .with_molecule_config(MoleculeConfig { + units: Units::Angstrom, + ..Default::default() + }) + .with_hf(HfConfig::default()) + .prepare() + .unwrap(); + drop(geometry); + + assert!(requested_geometry(prepared.request()).contains("0.370000")); + assert!(requested_geometry(prepared.request()).contains("Requested geometry")); + assert!(!requested_geometry(prepared.request()).contains("Hydrogen molecule")); + assert!(requested_heading(prepared.request().molecule().units).contains("Angstrom")); + let resolved = resolved_calculation(&prepared); + assert!(resolved.contains("HF method RHF")); + assert!(resolved.contains("Coordinates Bohr")); + assert!(resolved.contains("0.699199")); + } + + #[test] + fn normalized_request_drops_input_source_spans_and_preserves_auto() { + let geometry = + Geometry::from_reader(&include_bytes!("../../../samples/h2/molecule.xyz")[..]).unwrap(); + let basis = + BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap(); + let mut hf = HfConfig::default(); + hf.method.value = rustiq_core::config::HfMethod::Auto; + hf.method.span = Some((2, 3).into()); + let prepared = CalculationBuilder::new(&geometry, &basis) + .with_hf(hf) + .prepare() + .unwrap(); + + assert_eq!( + prepared.request().hf().method.value, + rustiq_core::config::HfMethod::Auto + ); + assert!(prepared.request().hf().method.span.is_none()); + assert_eq!(prepared.hf_method().to_string(), "RHF"); + } +} diff --git a/src/cli/ux/mod.rs b/src/cli/ux/mod.rs index 5d44ace..e3039c3 100644 --- a/src/cli/ux/mod.rs +++ b/src/cli/ux/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod banner; pub mod basis_table; pub(crate) mod bat; +pub(crate) mod calculation_presentation; pub(crate) mod calculation_report; pub(crate) mod json_output; pub(crate) mod mp2_report; diff --git a/src/runfile/cache.rs b/src/runfile/cache.rs index 157bfdf..bbe6aab 100644 --- a/src/runfile/cache.rs +++ b/src/runfile/cache.rs @@ -1,6 +1,6 @@ use toml_spanner::Toml; -#[derive(Debug, Default, Toml)] +#[derive(Debug, Clone, Default, Toml)] #[toml(Toml, recoverable)] pub struct CacheConfig { #[toml(default)] diff --git a/src/runfile/global/mod.rs b/src/runfile/global/mod.rs index 4d423f8..f998992 100644 --- a/src/runfile/global/mod.rs +++ b/src/runfile/global/mod.rs @@ -3,7 +3,7 @@ use molecule_config::MoleculeConfig; use toml_spanner::Toml; -#[derive(Debug, Toml)] +#[derive(Debug, Clone, Toml)] #[toml(Toml, recoverable)] pub struct Global { pub basis: String, diff --git a/src/runfile/global/molecule_config.rs b/src/runfile/global/molecule_config.rs index ea6729c..7efaa7b 100644 --- a/src/runfile/global/molecule_config.rs +++ b/src/runfile/global/molecule_config.rs @@ -4,7 +4,7 @@ use toml_spanner::Toml; use rustiq_core::molecules::units::Units; -#[derive(Debug, Toml)] +#[derive(Debug, Clone, Toml)] #[toml(Toml)] pub struct MoleculeConfig { #[toml( diff --git a/src/runfile/hf.rs b/src/runfile/hf.rs index 99306ed..0b445d1 100644 --- a/src/runfile/hf.rs +++ b/src/runfile/hf.rs @@ -14,7 +14,7 @@ pub use density_guess_config::DensityGuessConfig; pub use guess_perturbation_config::GuessPerturbationConfig; pub use random_guess_config::RandomGuessConfig; -#[derive(Debug, Toml)] +#[derive(Debug, Clone, Toml)] #[toml(Toml, recoverable)] pub struct HfConfig { #[toml(default)] @@ -59,7 +59,7 @@ impl Default for HfConfig { } } -#[derive(Debug, Default, Serialize, Deserialize, Toml, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Toml, PartialEq, Eq)] #[toml(Toml)] pub enum HfMethod { #[default] @@ -79,7 +79,7 @@ impl HfMethod { } } -#[derive(Debug, Default, Toml, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Toml, PartialEq, Eq)] #[toml(Toml)] pub enum HfOutputFormat { #[default] diff --git a/src/runfile/mod.rs b/src/runfile/mod.rs index cff83a4..db88a89 100644 --- a/src/runfile/mod.rs +++ b/src/runfile/mod.rs @@ -14,7 +14,7 @@ pub mod random_config; pub mod validated; use toml_spanner::Toml; -#[derive(Debug, Toml)] +#[derive(Debug, Clone, Toml)] #[toml(Toml, recoverable)] pub struct RunFile { pub global: Global, diff --git a/src/runfile/mp2.rs b/src/runfile/mp2.rs index 514b249..711d8fa 100644 --- a/src/runfile/mp2.rs +++ b/src/runfile/mp2.rs @@ -2,7 +2,7 @@ use bytesize::ByteSize; use serde::{Deserialize, Serialize}; use toml_spanner::Toml; -#[derive(Debug, Default, Serialize, Deserialize, Toml)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, Toml)] #[toml(Toml)] pub struct Mp2Config { #[toml(default)] diff --git a/src/runfile/output.rs b/src/runfile/output.rs index ec3ea6e..8f942ef 100644 --- a/src/runfile/output.rs +++ b/src/runfile/output.rs @@ -17,6 +17,15 @@ impl RunFile { pub fn output(&self, defaults: Defaults) -> TomlOutput<'_, Self> { TomlOutput::new(self, defaults) } + + /// Renders the effective runfile with stable TOML formatting and defaults. + pub fn canonical_toml(&self) -> Result { + let mut effective = self.clone(); + if effective.hf.is_none() { + effective.hf = Some(super::hf::HfConfig::default()); + } + toml_spanner::to_string(&effective.output(Defaults::Include)) + } } impl<'a, T> TomlOutput<'a, T> { @@ -155,10 +164,7 @@ mod tests { "frozen_orbitals", ] { assert!(full.contains(field), "missing {field}"); - assert!( - parsed.formatted_toml.contains(field), - "display missing {field}" - ); + assert!(parsed.runfile.canonical_toml().unwrap().contains(field)); assert!(!compact.contains(field), "unexpected {field}"); } assert!(compact.contains("[hf]")); @@ -174,6 +180,20 @@ mod tests { ); } + #[test] + fn canonical_output_normalizes_omitted_and_explicit_defaults() { + let implicit = parse_runfile("implicit", "[global]\nbasis = 'sto-3g'\n").unwrap(); + let explicit_source = implicit.runfile.canonical_toml().unwrap(); + let explicit = parse_runfile("explicit", &explicit_source).unwrap(); + + assert_eq!( + implicit.runfile.canonical_toml().unwrap(), + explicit.runfile.canonical_toml().unwrap() + ); + assert!(explicit_source.contains("Auto")); + assert!(explicit_source.contains("Angstrom")); + } + #[test] fn compact_output_preserves_non_default_and_tagged_configuration() { let source = r#" diff --git a/src/runfile/parser.rs b/src/runfile/parser.rs index a54c3ff..fa09813 100644 --- a/src/runfile/parser.rs +++ b/src/runfile/parser.rs @@ -1,5 +1,3 @@ -use miette::IntoDiagnostic; - use crate::runfile::RunFile; use super::diagnostics::FromTomlErrorMietteExt; @@ -7,7 +5,6 @@ use super::diagnostics::FromTomlErrorMietteExt; #[derive(Debug)] pub struct ParsedRunFile { pub runfile: RunFile, - pub formatted_toml: String, /// Scientific options with locations in the original input, not the formatted output. pub hf_config: Option, pub mp2_config: Option, @@ -26,10 +23,6 @@ pub fn parse_runfile( let runfile = document .to::() .map_err(|error| error.into_miette_diagnostic(source_name, toml_content))?; - let formatted_toml = toml_spanner::Formatting::preserved_from(&document) - .format(&runfile.output(super::output::Defaults::Include)) - .into_diagnostic()?; - let mut hf_config = runfile.hf.as_ref().map(rustiq_core::config::HfConfig::from); let mut molecule_config = rustiq_core::config::MoleculeConfig::from(&runfile.global.molecule); let mut mp2_config = runfile @@ -63,7 +56,6 @@ pub fn parse_runfile( Ok(ParsedRunFile { runfile, - formatted_toml, hf_config, mp2_config, molecule_config, diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index 89cd8a7..c6091f3 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -135,6 +135,12 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); + assert!(stdout.contains("Requested calculation (TOML)")); + assert!(stdout.contains("Requested geometry (XYZ, Angstrom)")); + assert!(stdout.contains("Resolved calculation")); + assert!(stdout.contains("Coordinates Bohr")); + assert!(stdout.contains("HF method RHF")); + assert!(stdout.contains("Basis STO-3G (2 functions)")); } #[test] From 89abb835f7fafcf7ed9e7feafd301d3009e605bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Mon, 28 Sep 2026 13:45:16 +0200 Subject: [PATCH 2/8] Preserve original calculation input in text output --- README.md | 9 +-- src/cli/commands/init_command.rs | 6 +- src/cli/commands/run_command.rs | 17 +++--- src/cli/ux/bat/mod.rs | 77 -------------------------- src/cli/ux/calculation_presentation.rs | 17 +----- src/cli/ux/mod.rs | 2 +- src/cli/ux/source_text.rs | 7 +++ src/runfile/cache.rs | 2 +- src/runfile/global/mod.rs | 2 +- src/runfile/global/molecule_config.rs | 2 +- src/runfile/hf.rs | 6 +- src/runfile/mod.rs | 2 +- src/runfile/mp2.rs | 2 +- src/runfile/output.rs | 24 -------- tests/cli_samples.rs | 39 ++++++++++++- 15 files changed, 73 insertions(+), 141 deletions(-) delete mode 100644 src/cli/ux/bat/mod.rs create mode 100644 src/cli/ux/source_text.rs diff --git a/README.md b/README.md index 368a1c9..ffd4905 100644 --- a/README.md +++ b/README.md @@ -747,10 +747,11 @@ setters, consuming `with_*` variants, getters, and `prepare()` / `execute()`. executed repeatedly through the shared `CalculationExecution` trait. It also exposes a normalized `CalculationRequest` with requested coordinates, units, molecular state, and effective scientific options, without frontend source -spans or geometry paths. In text mode, `run` presents the requested settings in -canonical TOML with defaults, followed by XYZ rendered from that request in the -requested units. It then presents the resolved method, basis size, and -canonical XYZ in Bohr from the prepared calculation. TOML and XYZ are CLI +spans or geometry paths. In text mode, `run` prints the original TOML and XYZ +sources verbatim so they can be copied back into files, preserving comments, +paths, and coordinate precision. Relative geometry paths require the same file +layout when reused. It then presents the resolved method, basis size, and XYZ +in Bohr from the prepared calculation. TOML and XYZ are CLI presentation adapters; future input frontends should adapt into the same scientific configuration and preparation path rather than introduce their syntax into `rustiq-core`. diff --git a/src/cli/commands/init_command.rs b/src/cli/commands/init_command.rs index 6ac215c..61be487 100644 --- a/src/cli/commands/init_command.rs +++ b/src/cli/commands/init_command.rs @@ -225,8 +225,8 @@ mod tests { command(&input, &output, &["--mp2"]).run().unwrap(); let content = fs::read_to_string(&output).unwrap(); - // Use the same canonical TOML representation shown by RunCommand. let parsed = parse_runfile("calculation.toml", &content).unwrap(); + let expanded = toml_spanner::to_string(&parsed.runfile.output(Defaults::Include)).unwrap(); for field in [ "charge", "multiplicity", @@ -238,8 +238,8 @@ mod tests { ] { assert!(!content.contains(field), "init must omit {field}"); assert!( - parsed.runfile.canonical_toml().unwrap().contains(field), - "run display must include {field}" + expanded.contains(field), + "expanded runfile must include {field}" ); } let molecule = &parsed.runfile.global.molecule; diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index 16aa83c..c20d6e1 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -11,10 +11,10 @@ use miette::{miette, Diagnostic, IntoDiagnostic, NamedSource, Report}; use crate::cli::{ self, ux::{ - bat, - calculation_presentation::{requested_geometry, requested_heading, resolved_calculation}, + calculation_presentation::{requested_heading, resolved_calculation}, calculation_report::CalculationReporter, json_output::CalculationOutput, + source_text, }, }; use crate::runfile::{hf::HfOutputFormat, parser::parse_runfile}; @@ -127,10 +127,12 @@ impl Runnable for RunCommand { ("".to_string(), content) }; let parsed = parse_runfile(source_name.clone(), &toml_content)?; - let source_code = NamedSource::new(source_name, toml_content); + let source_code = NamedSource::new(source_name, toml_content.clone()); let run = parsed.runfile; let molecule_path = &run.global.molecule.geometry; - let geom = Geometry::from_path(molecule_path).into_diagnostic()?; + let xyz_content = fs::read_to_string(molecule_path).into_diagnostic()?; + let geom = Geometry::from_source(molecule_path.display().to_string(), &xyz_content) + .into_diagnostic()?; if !json_output { println!("Loading basis set..."); } @@ -174,11 +176,10 @@ impl Runnable for RunCommand { prepared.map_err(|error| with_source(error, &source_code))? }; if !json_output { - let request_toml = run.canonical_toml().into_diagnostic()?; - println!("Requested calculation (TOML)"); - bat::print_toml(&request_toml); + println!("Requested calculation (TOML source)"); + source_text::print(&toml_content); println!("{}", requested_heading(prepared.request().molecule().units)); - bat::print_xyz(&requested_geometry(prepared.request())); + source_text::print(&xyz_content); println!("{}", resolved_calculation(&prepared)); } let result = { diff --git a/src/cli/ux/bat/mod.rs b/src/cli/ux/bat/mod.rs deleted file mode 100644 index 4595327..0000000 --- a/src/cli/ux/bat/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::{ - fs, io, - io::IsTerminal, - path::{Path, PathBuf}, - sync::OnceLock, -}; - -use ::bat::{ - assets::HighlightingAssets, config::Config, controller::Controller, Input, PrettyPrinter, -}; - -const BAT_SYNTAXES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bat-assets/syntaxes.bin")); -const BAT_THEMES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bat-assets/themes.bin")); - -static BAT_ASSET_CACHE_DIR: OnceLock = OnceLock::new(); - -pub fn print_toml(content: &str) { - if PrettyPrinter::new() - .input_from_bytes(content.as_bytes()) - .paging_mode(::bat::PagingMode::Never) - .language("toml") - .print() - .is_err() - { - println!("{content}"); - } -} - -pub fn print_xyz(content: &str) { - if !io::stdout().is_terminal() { - print!("{content}"); - return; - } - - if print_highlighted_xyz(content).is_err() { - print!("{content}"); - } -} - -fn print_highlighted_xyz(content: &str) -> Result<(), Box> { - let assets = HighlightingAssets::from_cache(bat_asset_cache_dir()?)?; - let config = Config { - language: Some("xyz"), - colored_output: true, - true_color: true, - paging_mode: ::bat::PagingMode::Never, - ..Default::default() - }; - let controller = Controller::new(&config, &assets); - - controller.run(vec![Input::from_reader(content.as_bytes()).into()], None)?; - - Ok(()) -} - -fn bat_asset_cache_dir() -> io::Result<&'static Path> { - let dir = BAT_ASSET_CACHE_DIR.get_or_init(|| { - std::env::temp_dir() - .join("rustiq") - .join("bat-assets") - .join(env!("CARGO_PKG_VERSION")) - }); - - fs::create_dir_all(dir)?; - write_asset_if_needed(&dir.join("syntaxes.bin"), BAT_SYNTAXES)?; - write_asset_if_needed(&dir.join("themes.bin"), BAT_THEMES)?; - - Ok(dir.as_path()) -} - -fn write_asset_if_needed(path: &Path, contents: &[u8]) -> io::Result<()> { - if fs::read(path).is_ok_and(|existing| existing == contents) { - return Ok(()); - } - - fs::write(path, contents) -} diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index e27f102..6dbfd5b 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -1,11 +1,4 @@ -use rustiq_core::{ - calculation::{CalculationRequest, PreparedCalculation}, - molecules::units::Units, -}; - -pub(crate) fn requested_geometry(request: &CalculationRequest) -> String { - request.geometry().to_string() -} +use rustiq_core::{calculation::PreparedCalculation, molecules::units::Units}; pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> String { let molecule = prepared.get_molecule(); @@ -24,7 +17,7 @@ pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> String { } pub(crate) fn requested_heading(unit: Units) -> String { - format!("Requested geometry (XYZ, {})", unit_name(unit)) + format!("Requested geometry (XYZ source, {})", unit_name(unit)) } fn unit_name(unit: Units) -> &'static str { @@ -45,7 +38,7 @@ mod tests { }; #[test] - fn prepared_views_render_requested_and_resolved_geometry_without_source_files() { + fn resolved_view_renders_prepared_geometry_without_source_files() { let geometry = Geometry::from_reader(&include_bytes!("../../../samples/h2/molecule.xyz")[..]).unwrap(); let basis = @@ -60,10 +53,6 @@ mod tests { .unwrap(); drop(geometry); - assert!(requested_geometry(prepared.request()).contains("0.370000")); - assert!(requested_geometry(prepared.request()).contains("Requested geometry")); - assert!(!requested_geometry(prepared.request()).contains("Hydrogen molecule")); - assert!(requested_heading(prepared.request().molecule().units).contains("Angstrom")); let resolved = resolved_calculation(&prepared); assert!(resolved.contains("HF method RHF")); assert!(resolved.contains("Coordinates Bohr")); diff --git a/src/cli/ux/mod.rs b/src/cli/ux/mod.rs index e3039c3..ad21681 100644 --- a/src/cli/ux/mod.rs +++ b/src/cli/ux/mod.rs @@ -1,10 +1,10 @@ pub(crate) mod banner; pub mod basis_table; -pub(crate) mod bat; pub(crate) mod calculation_presentation; pub(crate) mod calculation_report; pub(crate) mod json_output; pub(crate) mod mp2_report; pub mod scf_report; +pub(crate) mod source_text; pub(crate) use banner::print_startup_banner; pub(crate) use basis_table::BasisTableItem; diff --git a/src/cli/ux/source_text.rs b/src/cli/ux/source_text.rs new file mode 100644 index 0000000..81c9191 --- /dev/null +++ b/src/cli/ux/source_text.rs @@ -0,0 +1,7 @@ +pub(crate) fn print(source: &str) { + print!("{source}"); + if !source.ends_with('\n') { + println!(); + } + println!(); +} diff --git a/src/runfile/cache.rs b/src/runfile/cache.rs index bbe6aab..157bfdf 100644 --- a/src/runfile/cache.rs +++ b/src/runfile/cache.rs @@ -1,6 +1,6 @@ use toml_spanner::Toml; -#[derive(Debug, Clone, Default, Toml)] +#[derive(Debug, Default, Toml)] #[toml(Toml, recoverable)] pub struct CacheConfig { #[toml(default)] diff --git a/src/runfile/global/mod.rs b/src/runfile/global/mod.rs index f998992..4d423f8 100644 --- a/src/runfile/global/mod.rs +++ b/src/runfile/global/mod.rs @@ -3,7 +3,7 @@ use molecule_config::MoleculeConfig; use toml_spanner::Toml; -#[derive(Debug, Clone, Toml)] +#[derive(Debug, Toml)] #[toml(Toml, recoverable)] pub struct Global { pub basis: String, diff --git a/src/runfile/global/molecule_config.rs b/src/runfile/global/molecule_config.rs index 7efaa7b..ea6729c 100644 --- a/src/runfile/global/molecule_config.rs +++ b/src/runfile/global/molecule_config.rs @@ -4,7 +4,7 @@ use toml_spanner::Toml; use rustiq_core::molecules::units::Units; -#[derive(Debug, Clone, Toml)] +#[derive(Debug, Toml)] #[toml(Toml)] pub struct MoleculeConfig { #[toml( diff --git a/src/runfile/hf.rs b/src/runfile/hf.rs index 0b445d1..99306ed 100644 --- a/src/runfile/hf.rs +++ b/src/runfile/hf.rs @@ -14,7 +14,7 @@ pub use density_guess_config::DensityGuessConfig; pub use guess_perturbation_config::GuessPerturbationConfig; pub use random_guess_config::RandomGuessConfig; -#[derive(Debug, Clone, Toml)] +#[derive(Debug, Toml)] #[toml(Toml, recoverable)] pub struct HfConfig { #[toml(default)] @@ -59,7 +59,7 @@ impl Default for HfConfig { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize, Toml, PartialEq, Eq)] +#[derive(Debug, Default, Serialize, Deserialize, Toml, PartialEq, Eq)] #[toml(Toml)] pub enum HfMethod { #[default] @@ -79,7 +79,7 @@ impl HfMethod { } } -#[derive(Debug, Clone, Default, Toml, PartialEq, Eq)] +#[derive(Debug, Default, Toml, PartialEq, Eq)] #[toml(Toml)] pub enum HfOutputFormat { #[default] diff --git a/src/runfile/mod.rs b/src/runfile/mod.rs index db88a89..cff83a4 100644 --- a/src/runfile/mod.rs +++ b/src/runfile/mod.rs @@ -14,7 +14,7 @@ pub mod random_config; pub mod validated; use toml_spanner::Toml; -#[derive(Debug, Clone, Toml)] +#[derive(Debug, Toml)] #[toml(Toml, recoverable)] pub struct RunFile { pub global: Global, diff --git a/src/runfile/mp2.rs b/src/runfile/mp2.rs index 711d8fa..514b249 100644 --- a/src/runfile/mp2.rs +++ b/src/runfile/mp2.rs @@ -2,7 +2,7 @@ use bytesize::ByteSize; use serde::{Deserialize, Serialize}; use toml_spanner::Toml; -#[derive(Debug, Clone, Default, Serialize, Deserialize, Toml)] +#[derive(Debug, Default, Serialize, Deserialize, Toml)] #[toml(Toml)] pub struct Mp2Config { #[toml(default)] diff --git a/src/runfile/output.rs b/src/runfile/output.rs index 8f942ef..d5c8919 100644 --- a/src/runfile/output.rs +++ b/src/runfile/output.rs @@ -17,15 +17,6 @@ impl RunFile { pub fn output(&self, defaults: Defaults) -> TomlOutput<'_, Self> { TomlOutput::new(self, defaults) } - - /// Renders the effective runfile with stable TOML formatting and defaults. - pub fn canonical_toml(&self) -> Result { - let mut effective = self.clone(); - if effective.hf.is_none() { - effective.hf = Some(super::hf::HfConfig::default()); - } - toml_spanner::to_string(&effective.output(Defaults::Include)) - } } impl<'a, T> TomlOutput<'a, T> { @@ -164,7 +155,6 @@ mod tests { "frozen_orbitals", ] { assert!(full.contains(field), "missing {field}"); - assert!(parsed.runfile.canonical_toml().unwrap().contains(field)); assert!(!compact.contains(field), "unexpected {field}"); } assert!(compact.contains("[hf]")); @@ -180,20 +170,6 @@ mod tests { ); } - #[test] - fn canonical_output_normalizes_omitted_and_explicit_defaults() { - let implicit = parse_runfile("implicit", "[global]\nbasis = 'sto-3g'\n").unwrap(); - let explicit_source = implicit.runfile.canonical_toml().unwrap(); - let explicit = parse_runfile("explicit", &explicit_source).unwrap(); - - assert_eq!( - implicit.runfile.canonical_toml().unwrap(), - explicit.runfile.canonical_toml().unwrap() - ); - assert!(explicit_source.contains("Auto")); - assert!(explicit_source.contains("Angstrom")); - } - #[test] fn compact_output_preserves_non_default_and_tagged_configuration() { let source = r#" diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index c6091f3..01f5e66 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -135,14 +135,49 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); - assert!(stdout.contains("Requested calculation (TOML)")); - assert!(stdout.contains("Requested geometry (XYZ, Angstrom)")); + assert!(stdout.contains("Requested calculation (TOML source)")); + assert!(stdout.contains("Requested geometry (XYZ source, Angstrom)")); assert!(stdout.contains("Resolved calculation")); assert!(stdout.contains("Coordinates Bohr")); assert!(stdout.contains("HF method RHF")); assert!(stdout.contains("Basis STO-3G (2 functions)")); } +#[test] +fn test_cli_prints_original_toml_and_xyz_for_copying() { + let temp_root = temp_root("cli-original-input"); + prepare_basis_store(&temp_root); + let input_dir = temp_root.join("input"); + fs::create_dir_all(&input_dir).unwrap(); + let toml = "# Keep the original path and formatting\n[global]\nbasis = 'sto-3g'\n\n[global.molecule]\ngeometry = 'molecule.xyz' # relative to this file\n"; + let xyz = "2\nHydrogen molecule -- original comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n"; + let calculation_path = input_dir.join("calculation.toml"); + fs::write(&calculation_path, toml).unwrap(); + fs::write(input_dir.join("molecule.xyz"), xyz).unwrap(); + + let output = + run_rustiq_with_data_home(&["run", calculation_path.to_str().unwrap()], &temp_root); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let toml_section = stdout + .split_once("Requested calculation (TOML source)\n") + .unwrap() + .1 + .split_once("Requested geometry (XYZ source, Angstrom)\n") + .unwrap() + .0; + assert_eq!(toml_section, format!("{toml}\n")); + let xyz_section = stdout + .split_once("Requested geometry (XYZ source, Angstrom)\n") + .unwrap() + .1 + .split_once("Resolved calculation\n") + .unwrap() + .0; + assert_eq!(xyz_section, format!("{xyz}\n")); +} + #[test] fn test_cli_h2_sample_uses_eri_cache_when_enabled_in_runfile() { let temp_root = temp_root("cli-enabled-eri-cache"); From 62a519738af81d55ededa79de71853c6997110bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Wed, 30 Sep 2026 14:46:07 +0200 Subject: [PATCH 3/8] Render semantic calculation views alongside original sources --- README.md | 12 +- crates/rustiq-core/src/calculation/builder.rs | 38 +- .../src/calculation/prepared_calculation.rs | 14 +- crates/rustiq-core/src/calculation/request.rs | 3 +- src/cli/commands/run_command.rs | 18 +- src/cli/ux/calculation_presentation.rs | 339 +++++++++++++++--- src/runfile/adapter.rs | 77 +++- tests/cli_samples.rs | 62 +++- 8 files changed, 484 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index ffd4905..400a19a 100644 --- a/README.md +++ b/README.md @@ -750,8 +750,16 @@ molecular state, and effective scientific options, without frontend source spans or geometry paths. In text mode, `run` prints the original TOML and XYZ sources verbatim so they can be copied back into files, preserving comments, paths, and coordinate precision. Relative geometry paths require the same file -layout when reused. It then presents the resolved method, basis size, and XYZ -in Bohr from the prepared calculation. TOML and XYZ are CLI +layout when reused. It then renders the normalized request as canonical TOML +with scientific defaults and XYZ in the requested units, followed by the +resolved configuration and XYZ in Bohr. These canonical pairs can each be +copied into `calculation.toml` and `molecule.xyz` to recreate the corresponding +semantic input. Canonical XYZ retains enough coordinate digits for an exact +floating-point round trip. Canonical TOML uses typed CLI adapters and +`toml-spanner`, without cache or terminal options. The requested basis label is +distinct from the resolved basis name and AO contents; only the resolved +scientific state determines artifact compatibility. Source provenance is +optional and never needed to render either semantic view. TOML and XYZ are CLI presentation adapters; future input frontends should adapt into the same scientific configuration and preparation path rather than introduce their syntax into `rustiq-core`. diff --git a/crates/rustiq-core/src/calculation/builder.rs b/crates/rustiq-core/src/calculation/builder.rs index 0db2c74..e477f95 100644 --- a/crates/rustiq-core/src/calculation/builder.rs +++ b/crates/rustiq-core/src/calculation/builder.rs @@ -40,6 +40,7 @@ use super::{ pub struct CalculationBuilder<'a> { geometry: &'a Geometry, basis_file: &'a BasisFile, + basis_label: Option, molecule_config: MoleculeConfig, hf: HfConfig, mp2: Option, @@ -51,6 +52,7 @@ impl<'a> CalculationBuilder<'a> { Self { geometry, basis_file, + basis_label: None, molecule_config: MoleculeConfig::default(), hf: HfConfig::default(), mp2: None, @@ -64,6 +66,18 @@ impl<'a> CalculationBuilder<'a> { pub fn get_basis_file(&self) -> &BasisFile { self.basis_file } + + /// Set a portable requested basis label; the loaded basis remains authoritative. + pub fn basis_label(&mut self, label: impl Into) -> &mut Self { + self.basis_label = Some(label.into()); + self + } + + #[must_use] + pub fn with_basis_label(mut self, label: impl Into) -> Self { + self.basis_label(label); + self + } pub fn get_molecule_config(&self) -> &MoleculeConfig { &self.molecule_config } @@ -141,9 +155,17 @@ impl<'a> CalculationBuilder<'a> { &self, mut events: impl FnMut(CalculationEvent<'_>), ) -> Result { - let mut molecule = self.molecule_config.build(self.geometry.clone())?; + let mut requested_geometry = self.geometry.clone(); + requested_geometry.comment.clear(); + let mut molecule = self.molecule_config.build(requested_geometry.clone())?; molecule.convert_to(Units::Bohr); - let hf = (self.hf.clone(), self.hf.resolve_method(&molecule)?); + let method = self.hf.resolve_method(&molecule)?; + let mut resolved_hf = self.hf.clone(); + resolved_hf.method.value = match method { + crate::config::ResolvedHfMethod::Rhf => crate::config::HfMethod::Rhf, + crate::config::ResolvedHfMethod::Uhf => crate::config::HfMethod::Uhf, + }; + let hf = (resolved_hf, method); events(CalculationEvent::BasisStarted); let start = Instant::now(); let basis = Basis::try_load(self.basis_file, &molecule)?; @@ -151,8 +173,6 @@ impl<'a> CalculationBuilder<'a> { basis: &basis, elapsed: start.elapsed(), }); - let mut requested_geometry = self.geometry.clone(); - requested_geometry.comment = "Requested geometry".into(); let request = CalculationRequest { geometry: requested_geometry, molecule: MoleculeConfig { @@ -160,8 +180,11 @@ impl<'a> CalculationBuilder<'a> { charge: self.molecule_config.charge.value.into(), multiplicity: self.molecule_config.multiplicity.value.into(), }, - basis_name: self.basis_file.name().to_owned(), - hf: request_hf_config(&self.hf), + basis_name: self + .basis_label + .clone() + .unwrap_or_else(|| self.basis_file.name().to_owned()), + hf: normalized_hf_config(&self.hf), mp2: self.mp2.map(|config| Mp2Config { frozen_orbitals: config.frozen_orbitals.value.into(), memory_limit: config.memory_limit.value.into(), @@ -171,6 +194,7 @@ impl<'a> CalculationBuilder<'a> { request, molecule, basis, + basis_name: self.basis_file.name().to_owned(), hf, mp2: self.mp2, eri_cache: self.eri_cache.clone(), @@ -178,7 +202,7 @@ impl<'a> CalculationBuilder<'a> { } } -fn request_hf_config(config: &HfConfig) -> HfConfig { +pub(super) fn normalized_hf_config(config: &HfConfig) -> HfConfig { HfConfig { method: config.method.value.into(), max_iterations: config.max_iterations, diff --git a/crates/rustiq-core/src/calculation/prepared_calculation.rs b/crates/rustiq-core/src/calculation/prepared_calculation.rs index 2bb82be..485fc4e 100644 --- a/crates/rustiq-core/src/calculation/prepared_calculation.rs +++ b/crates/rustiq-core/src/calculation/prepared_calculation.rs @@ -21,6 +21,7 @@ pub struct PreparedCalculation { pub(super) request: CalculationRequest, pub(super) molecule: Molecule, pub(super) basis: Basis, + pub(super) basis_name: String, pub(super) hf: (HfConfig, ResolvedHfMethod), pub(super) mp2: Option, pub(super) eri_cache: Option, @@ -44,12 +45,19 @@ impl PreparedCalculation { self.hf.1 } - pub fn hf_config(&self) -> &HfConfig { - &self.hf.0 + /// Canonical label of the loaded basis; AO contents are exposed by `get_basis()`. + pub fn basis_name(&self) -> &str { + &self.basis_name } + /// Effective HF options with an explicit method and no frontend source spans. + pub fn hf_config(&self) -> HfConfig { + super::builder::normalized_hf_config(&self.hf.0) + } + + /// MP2 options without frontend source spans; automatic memory resolves at execution. pub fn mp2_config(&self) -> Option<&Mp2Config> { - self.mp2.as_ref() + self.request.mp2() } } diff --git a/crates/rustiq-core/src/calculation/request.rs b/crates/rustiq-core/src/calculation/request.rs index 392f964..4d09a7f 100644 --- a/crates/rustiq-core/src/calculation/request.rs +++ b/crates/rustiq-core/src/calculation/request.rs @@ -26,7 +26,8 @@ impl CalculationRequest { &self.molecule } - /// Canonical basis name associated with the loaded basis data. + /// Requested portable basis label, or the loaded basis name for direct API use. + /// This label is not the scientific identity of the resolved basis. pub fn basis_name(&self) -> &str { &self.basis_name } diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index c20d6e1..b163ffb 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -11,7 +11,9 @@ use miette::{miette, Diagnostic, IntoDiagnostic, NamedSource, Report}; use crate::cli::{ self, ux::{ - calculation_presentation::{requested_heading, resolved_calculation}, + calculation_presentation::{ + requested_calculation, resolved_calculation, source_geometry_heading, + }, calculation_report::CalculationReporter, json_output::CalculationOutput, source_text, @@ -150,6 +152,7 @@ impl Runnable for RunCommand { .as_ref() .is_none_or(|hf| hf.format != HfOutputFormat::Nope); let calculation = CalculationBuilder::new(&geom, &basis_file) + .with_basis_label(&run.global.basis) .with_molecule_config(parsed.molecule_config) .with_mp2(parsed.mp2_config); let calculation = if run.cache.enabled { @@ -176,11 +179,18 @@ impl Runnable for RunCommand { prepared.map_err(|error| with_source(error, &source_code))? }; if !json_output { - println!("Requested calculation (TOML source)"); + println!("Original calculation (TOML source)"); source_text::print(&toml_content); - println!("{}", requested_heading(prepared.request().molecule().units)); + println!( + "{}", + source_geometry_heading(prepared.request().molecule().units) + ); source_text::print(&xyz_content); - println!("{}", resolved_calculation(&prepared)); + println!( + "{}", + requested_calculation(prepared.request()).into_diagnostic()? + ); + println!("{}", resolved_calculation(&prepared).into_diagnostic()?); } let result = { let stdout = io::stdout(); diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index 6dbfd5b..8308737 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -1,23 +1,128 @@ -use rustiq_core::{calculation::PreparedCalculation, molecules::units::Units}; +use std::{fmt::Write, num::NonZeroUsize}; -pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> String { +use rustiq_core::{ + calculation::{CalculationRequest, PreparedCalculation}, + config::{ + self, + validated::{DiisSize, NonNegativeFiniteF64, PositiveFiniteF64}, + }, + molecules::{geometry::Geometry, units::Units}, +}; +use toml_spanner::{ToTomlError, Toml}; + +use crate::runfile::{ + global::{molecule_config::MoleculeConfig, Global}, + hf::{DensityGuessConfig, HfMethod}, + mp2::Mp2Config, +}; + +/// CLI rendering schema, not a scientific persistence schema. The generated +/// geometry filename belongs to this TOML/XYZ adapter, never to the core views. +#[derive(Toml)] +#[toml(ToToml)] +struct CalculationToml { + global: Global, + hf: HfSettings, + mp2: Option, +} + +/// Scientific HF settings using the existing runfile spelling and serializers. +/// Terminal/reporting options are not part of the semantic presentation. +#[derive(Toml)] +#[toml(ToToml)] +struct HfSettings { + method: HfMethod, + #[toml(with = crate::runfile::validated::non_zero_usize)] + max_iterations: NonZeroUsize, + #[toml(with = crate::runfile::validated::positive_finite_f64)] + convergence_threshold: PositiveFiniteF64, + #[toml(with = crate::runfile::validated::non_negative_finite_f64)] + linear_dependency_threshold: NonNegativeFiniteF64, + // A disabled threshold must be explicit: omitting it would restore screening. + eri_schwarz_threshold: f64, + guess: DensityGuessConfig, + diis: bool, + #[toml(with = crate::runfile::validated::diis_size)] + diis_size: DiisSize, +} + +impl From<&config::HfConfig> for HfSettings { + fn from(value: &config::HfConfig) -> Self { + Self { + method: value.method.value.into(), + max_iterations: value.max_iterations, + convergence_threshold: value.convergence_threshold, + linear_dependency_threshold: value.linear_dependency_threshold.value, + eri_schwarz_threshold: value + .eri_schwarz_threshold + .map_or(0.0, PositiveFiniteF64::into_inner), + guess: value.guess.value.into(), + diis: value.diis, + diis_size: value.diis_size, + } + } +} + +impl CalculationToml { + fn requested(request: &CalculationRequest) -> Self { + Self { + global: Global { + basis: request.basis_name().into(), + molecule: MoleculeConfig { + geometry: "molecule.xyz".into(), + charge: request.molecule().charge.value, + multiplicity: request.molecule().multiplicity.value, + molecule_unit: request.molecule().units, + }, + }, + hf: request.hf().into(), + mp2: request.mp2().map(Into::into), + } + } + + fn resolved(prepared: &PreparedCalculation) -> Self { + let molecule = prepared.get_molecule(); + Self { + global: Global { + basis: prepared.basis_name().into(), + molecule: MoleculeConfig { + geometry: "molecule.xyz".into(), + charge: molecule.charge(), + multiplicity: molecule.multiplicity(), + molecule_unit: molecule.unit(), + }, + }, + hf: (&prepared.hf_config()).into(), + mp2: prepared.mp2_config().map(Into::into), + } + } +} + +pub(crate) fn requested_calculation(request: &CalculationRequest) -> Result { + Ok(format!( + "Requested calculation (canonical TOML)\n{}\nRequested geometry (canonical XYZ, {})\n{}", + toml_spanner::to_string(&CalculationToml::requested(request))?, + unit_name(request.molecule().units), + geometry_xyz(request.geometry(), "Requested geometry"), + )) +} + +pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> Result { let molecule = prepared.get_molecule(); - let multiplicity = molecule.multiplicity().get(); - let mut geometry = molecule.geometry().clone(); - geometry.comment = "Resolved geometry".into(); - format!( - "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)\n\nResolved geometry (XYZ, Bohr)\n{}", + Ok(format!( + "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)\n\nResolved configuration (canonical TOML)\n{}\nResolved geometry (canonical XYZ, Bohr)\n{}", molecule.charge(), - multiplicity, + molecule.multiplicity().get(), prepared.hf_method(), - prepared.request().basis_name(), + prepared.basis_name(), prepared.get_basis().nbasis(), - geometry, - ) + toml_spanner::to_string(&CalculationToml::resolved(prepared))?, + geometry_xyz(molecule.geometry(), "Resolved geometry"), + )) } -pub(crate) fn requested_heading(unit: Units) -> String { - format!("Requested geometry (XYZ source, {})", unit_name(unit)) +pub(crate) fn source_geometry_heading(unit: Units) -> String { + format!("Original geometry (XYZ source, {})", unit_name(unit)) } fn unit_name(unit: Units) -> &'static str { @@ -27,57 +132,181 @@ fn unit_name(unit: Units) -> &'static str { } } +fn geometry_xyz(geometry: &Geometry, comment: &str) -> String { + let mut output = format!("{}\n{comment}\n", geometry.atoms.len()); + for atom in &geometry.atoms { + // f64 Display retains enough digits to recover each coordinate exactly. + writeln!( + output, + "{} {} {} {}", + atom.element.symbol, atom.position.x, atom.position.y, atom.position.z, + ) + .expect("writing to a String cannot fail"); + } + output +} + #[cfg(test)] mod tests { use super::*; - use rustiq_core::{ - basis::BasisFile, - calculation::CalculationBuilder, - config::{HfConfig, MoleculeConfig}, - molecules::{geometry::Geometry, units::Units}, - }; + use crate::runfile::parser::parse_runfile; + use rustiq_core::{basis::BasisFile, calculation::CalculationBuilder}; - #[test] - fn resolved_view_renders_prepared_geometry_without_source_files() { - let geometry = - Geometry::from_reader(&include_bytes!("../../../samples/h2/molecule.xyz")[..]).unwrap(); - let basis = - BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap(); - let prepared = CalculationBuilder::new(&geometry, &basis) - .with_molecule_config(MoleculeConfig { - units: Units::Angstrom, - ..Default::default() - }) - .with_hf(HfConfig::default()) + fn basis() -> BasisFile { + BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap() + } + + fn prepare(source: &str, geometry: &Geometry) -> PreparedCalculation { + let parsed = parse_runfile("input.toml", source).unwrap(); + let basis = basis(); + CalculationBuilder::new(geometry, &basis) + .with_basis_label(parsed.runfile.global.basis) + .with_molecule_config(parsed.molecule_config) + .with_hf(parsed.hf_config.unwrap_or_default()) + .with_mp2(parsed.mp2_config) .prepare() - .unwrap(); + .unwrap() + } + + #[test] + fn canonical_views_render_without_source_files_and_preserve_coordinate_precision() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("molecule.xyz"); + std::fs::write( + &path, + "2\nOriginal comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n", + ) + .unwrap(); + let geometry = Geometry::from_path(&path).unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + std::fs::remove_file(path).unwrap(); drop(geometry); - let resolved = resolved_calculation(&prepared); - assert!(resolved.contains("HF method RHF")); - assert!(resolved.contains("Coordinates Bohr")); - assert!(resolved.contains("0.699199")); + assert_eq!(prepared.request().molecule().units, Units::Angstrom); + assert_eq!(prepared.get_molecule().unit(), Units::Bohr); + assert_eq!(prepared.request().hf().method.value, config::HfMethod::Auto); + assert_eq!(prepared.hf_config().method.value, config::HfMethod::Rhf); + assert_eq!(prepared.request().basis_name(), "sto-3g"); + assert_eq!(prepared.basis_name(), "STO-3G"); + for (rendered, expected, method) in [ + ( + CalculationToml::requested(prepared.request()), + prepared.request().geometry(), + config::HfMethod::Auto, + ), + ( + CalculationToml::resolved(&prepared), + prepared.get_molecule().geometry(), + config::HfMethod::Rhf, + ), + ] { + let toml = toml_spanner::to_string(&rendered).unwrap(); + let restored = parse_runfile("rendered.toml", &toml).unwrap(); + assert_eq!(restored.hf_config.unwrap().method.value, method); + assert!(!toml.contains("format =")); + assert!(!toml.contains("cache")); + let xyz = geometry_xyz(expected, "Canonical geometry"); + let restored = Geometry::from_source("rendered.xyz", &xyz).unwrap(); + for (actual, expected) in restored.atoms.iter().zip(&expected.atoms) { + assert_eq!(actual.position, expected.position); + assert_eq!(actual.element.symbol, expected.element.symbol); + } + } + assert!(requested_calculation(prepared.request()) + .unwrap() + .contains("method = \"Auto\"")); + assert!(resolved_calculation(&prepared) + .unwrap() + .contains("method = \"Rhf\"")); } #[test] - fn normalized_request_drops_input_source_spans_and_preserves_auto() { - let geometry = - Geometry::from_reader(&include_bytes!("../../../samples/h2/molecule.xyz")[..]).unwrap(); - let basis = - BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap(); - let mut hf = HfConfig::default(); - hf.method.value = rustiq_core::config::HfMethod::Auto; - hf.method.span = Some((2, 3).into()); - let prepared = CalculationBuilder::new(&geometry, &basis) - .with_hf(hf) - .prepare() - .unwrap(); - - assert_eq!( - prepared.request().hf().method.value, - rustiq_core::config::HfMethod::Auto - ); - assert!(prepared.request().hf().method.span.is_none()); - assert_eq!(prepared.hf_method().to_string(), "RHF"); + fn equivalent_defaults_and_source_provenance_render_identically() { + let original = Geometry::from_source( + "original.xyz", + "2\nOriginal comment\nH 0 0 -0.37\nH 0 0 0.37\n", + ) + .unwrap(); + let equivalent = Geometry::from_source( + "different.xyz", + "2\nDifferent comment\n1 0.0 0.0 -0.370000\nH 0.0 0.0 0.370000\n", + ) + .unwrap(); + for post_hf in ["", "\n[mp2]\n"] { + let implicit = prepare( + &format!("# omitted defaults\n[global]\nbasis = 'sto-3g'\n{post_hf}"), + &original, + ); + let explicit_source = + toml_spanner::to_string(&CalculationToml::requested(implicit.request())).unwrap(); + let explicit_source = format!( + "# explicitly written defaults\n{}\n[cache]\nenabled = true\n", + explicit_source.replace("molecule.xyz", "../different.xyz") + ); + let explicit = prepare(&explicit_source, &equivalent); + assert_eq!( + requested_calculation(implicit.request()).unwrap(), + requested_calculation(explicit.request()).unwrap() + ); + assert_eq!( + resolved_calculation(&implicit).unwrap(), + resolved_calculation(&explicit).unwrap() + ); + assert!(explicit.request().geometry().comment.is_empty()); + assert!(explicit.get_molecule().geometry().comment.is_empty()); + assert!(explicit.request().molecule().charge.span.is_none()); + assert!(explicit.request().molecule().multiplicity.span.is_none()); + for hf in [explicit.request().hf().clone(), explicit.hf_config()] { + assert!(hf.method.span.is_none()); + assert!(hf.guess.span.is_none()); + assert!(hf.linear_dependency_threshold.span.is_none()); + } + if let Some(mp2) = explicit.mp2_config() { + assert!(mp2.frozen_orbitals.span.is_none()); + assert!(mp2.memory_limit.span.is_none()); + } + } + } + + #[test] + fn canonical_request_round_trips_non_default_hf_and_mp2_options() { + let geometry = Geometry::from_source("input.xyz", "2\nH2\nH 0 0 0\nH 0 0 0.74\n").unwrap(); + for guess in [ + "[hf.guess]\ntype = 'Zero'\n", + "[hf.guess]\ntype = 'CoreHamiltonian'\n[hf.guess.perturbation]\nseed = 41\n", + "[hf.guess]\ntype = 'OneElectron'\n[hf.guess.perturbation]\ndistribution = 'Uniform'\nmin = -0.001\nmax = 0.001\nseed = 42\n", + "[hf.guess]\ntype = 'Random'\ndistribution = 'Normal'\nmean = 0.25\nstd_dev = 0.01\nseed = 43\n", + ] { + let source = format!("[global]\nbasis = 'sto-3g'\n[hf]\nmethod = 'Uhf'\nmax_iterations = 42\nconvergence_threshold = 1e-7\nlinear_dependency_threshold = 0.0\neri_schwarz_threshold = 0.0\ndiis = true\ndiis_size = 8\nformat = 'Nope'\n{guess}\n[mp2]\nfrozen_orbitals = 1\nmemory_limit = '9007199254740993 B'\n"); + let prepared = prepare(&source, &geometry); + let rendered = toml_spanner::to_string(&CalculationToml::requested(prepared.request())).unwrap(); + let original = parse_runfile("original.toml", &source).unwrap(); + let reparsed = parse_runfile("rendered.toml", &rendered).unwrap(); + assert_eq!( + toml_spanner::to_string(&original.runfile.hf.unwrap().guess).unwrap(), + toml_spanner::to_string(&reparsed.runfile.hf.unwrap().guess).unwrap(), + ); + let restored = prepare(&rendered, &geometry); + assert_eq!(requested_calculation(prepared.request()).unwrap(), requested_calculation(restored.request()).unwrap()); + assert_eq!(prepared.request().hf().max_iterations.get(), 42); + assert!(prepared.request().hf().eri_schwarz_threshold.is_none()); + assert_eq!(prepared.request().mp2().unwrap().frozen_orbitals.value, 1); + assert_eq!(prepared.request().mp2().unwrap().memory_limit.value, config::MemoryLimit::Fixed(bytesize::ByteSize::b(9_007_199_254_740_993))); + assert!(!rendered.contains("format =")); + } + } + + #[test] + fn auto_remains_requested_and_resolves_to_uhf_for_a_doublet() { + let geometry = Geometry::from_source("input.xyz", "2\nH2+\nH 0 0 0\nH 0 0 1.4\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n[global.molecule]\ncharge = 1\nmultiplicity = 2\nmolecule_unit = 'Bohr'\n", &geometry); + assert_eq!(prepared.request().hf().method.value, config::HfMethod::Auto); + assert_eq!(prepared.hf_config().method.value, config::HfMethod::Uhf); + assert!(requested_calculation(prepared.request()) + .unwrap() + .contains("method = \"Auto\"")); + assert!(resolved_calculation(&prepared) + .unwrap() + .contains("method = \"Uhf\"")); } } diff --git a/src/runfile/adapter.rs b/src/runfile/adapter.rs index 9104b41..480aecc 100644 --- a/src/runfile/adapter.rs +++ b/src/runfile/adapter.rs @@ -1,4 +1,4 @@ -//! Explicit conversion from the TOML schema to scientific options. +//! Explicit conversions between CLI TOML representations and scientific options. use super::{hf, mp2, random_config}; use rustiq_core::config as core; @@ -110,6 +110,81 @@ impl From for core::random_config::RandomConfig { } } +impl From for hf::HfMethod { + fn from(value: core::HfMethod) -> Self { + match value { + core::HfMethod::Auto => Self::Auto, + core::HfMethod::Rhf => Self::Rhf, + core::HfMethod::Uhf => Self::Uhf, + } + } +} + +impl From for hf::DensityGuessConfig { + fn from(value: core::DensityGuessConfig) -> Self { + match value { + core::DensityGuessConfig::CoreHamiltonian { perturbation } => Self::CoreHamiltonian { + perturbation: perturbation.map(|value| hf::GuessPerturbationConfig { + random: value.random.into(), + }), + }, + core::DensityGuessConfig::OneElectron { perturbation } => Self::OneElectron { + perturbation: perturbation.map(|value| hf::GuessPerturbationConfig { + random: value.random.into(), + }), + }, + core::DensityGuessConfig::Random { config } => Self::Random { + config: hf::RandomGuessConfig { + random: config.random.into(), + }, + }, + core::DensityGuessConfig::Zero => Self::Zero, + } + } +} + +impl From for random_config::RandomConfig { + fn from(value: core::random_config::RandomConfig) -> Self { + use random_config::distribution_config::{ + NormalDistributionConfig, UniformDistributionConfig, + }; + let distribution = match value.distribution { + core::random_config::DistributionConfig::Normal { config } => { + random_config::DistributionConfig::Normal { + config: NormalDistributionConfig { + mean: config.mean, + std_dev: config.std_dev, + }, + } + } + core::random_config::DistributionConfig::Uniform { config } => { + random_config::DistributionConfig::Uniform { + config: UniformDistributionConfig { + min: config.min, + max: config.max, + }, + } + } + }; + Self { + distribution, + seed: value.seed, + } + } +} + +impl From<&core::Mp2Config> for mp2::Mp2Config { + fn from(value: &core::Mp2Config) -> Self { + Self { + frozen_orbitals: value.frozen_orbitals.value, + memory_limit: match value.memory_limit.value { + core::MemoryLimit::Auto => mp2::MemoryLimit::Auto, + core::MemoryLimit::Fixed(size) => mp2::MemoryLimit::Fixed(size), + }, + } + } +} + #[cfg(test)] mod tests { use rustiq_core::config::{DensityGuessConfig, RandomGuessConfig}; diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index 01f5e66..c3aef50 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -135,8 +135,14 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); - assert!(stdout.contains("Requested calculation (TOML source)")); - assert!(stdout.contains("Requested geometry (XYZ source, Angstrom)")); + assert!(stdout.contains("Original calculation (TOML source)")); + assert!(stdout.contains("Original geometry (XYZ source, Angstrom)")); + assert!(stdout.contains("Requested calculation (canonical TOML)")); + assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); + assert!(stdout.contains("method = \"Auto\"")); + assert!(stdout.contains("max_iterations = 100")); + assert!(stdout.contains("Resolved configuration (canonical TOML)")); + assert!(stdout.contains("method = \"Rhf\"")); assert!(stdout.contains("Resolved calculation")); assert!(stdout.contains("Coordinates Bohr")); assert!(stdout.contains("HF method RHF")); @@ -161,21 +167,65 @@ fn test_cli_prints_original_toml_and_xyz_for_copying() { let stdout = String::from_utf8(output.stdout).unwrap(); let toml_section = stdout - .split_once("Requested calculation (TOML source)\n") + .split_once("Original calculation (TOML source)\n") .unwrap() .1 - .split_once("Requested geometry (XYZ source, Angstrom)\n") + .split_once("Original geometry (XYZ source, Angstrom)\n") .unwrap() .0; assert_eq!(toml_section, format!("{toml}\n")); let xyz_section = stdout - .split_once("Requested geometry (XYZ source, Angstrom)\n") + .split_once("Original geometry (XYZ source, Angstrom)\n") .unwrap() .1 - .split_once("Resolved calculation\n") + .split_once("Requested calculation (canonical TOML)\n") .unwrap() .0; assert_eq!(xyz_section, format!("{xyz}\n")); + + // Both semantic TOML/XYZ pairs must also be usable as standalone inputs. + for (name, toml_heading, xyz_heading) in [ + ( + "requested", + "Requested calculation (canonical TOML)\n", + "Requested geometry (canonical XYZ, Angstrom)\n", + ), + ( + "resolved", + "Resolved configuration (canonical TOML)\n", + "Resolved geometry (canonical XYZ, Bohr)\n", + ), + ] { + let after_heading = stdout.split_once(toml_heading).unwrap().1; + let (canonical_toml, after_xyz_heading) = after_heading.split_once(xyz_heading).unwrap(); + let atom_count = after_xyz_heading + .lines() + .next() + .unwrap() + .parse::() + .unwrap(); + let canonical_xyz = after_xyz_heading + .lines() + .take(atom_count + 2) + .collect::>() + .join("\n"); + let copied_dir = temp_root.join(name); + fs::create_dir_all(&copied_dir).unwrap(); + let copied_toml = copied_dir.join("calculation.toml"); + fs::write(&copied_toml, canonical_toml).unwrap(); + fs::write(copied_dir.join("molecule.xyz"), canonical_xyz).unwrap(); + let repeated = + run_rustiq_with_data_home(&["run", copied_toml.to_str().unwrap()], &temp_root); + assert_success(&repeated); + let repeated_stdout = String::from_utf8(repeated.stdout).unwrap(); + let energy_line = |text: &str| { + text.lines() + .find(|line| line.starts_with("Total Energy (including nuclear repulsion):")) + .unwrap() + .to_owned() + }; + assert_eq!(energy_line(&repeated_stdout), energy_line(&stdout)); + } } #[test] From 23a402532d1027bd54a83ebab407348ad30ffda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Wed, 30 Sep 2026 15:10:48 +0200 Subject: [PATCH 4/8] Render all calculation blocks with bat --- src/cli/commands/run_command.rs | 24 ++++--- src/cli/ux/bat/mod.rs | 73 ++++++++++++++++++++ src/cli/ux/calculation_presentation.rs | 94 +++++++++++++++++++------- src/cli/ux/mod.rs | 2 +- src/cli/ux/source_text.rs | 7 -- tests/cli_samples.rs | 25 ++++++- 6 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 src/cli/ux/bat/mod.rs delete mode 100644 src/cli/ux/source_text.rs diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index b163ffb..de53903 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -11,12 +11,12 @@ use miette::{miette, Diagnostic, IntoDiagnostic, NamedSource, Report}; use crate::cli::{ self, ux::{ + bat, calculation_presentation::{ requested_calculation, resolved_calculation, source_geometry_heading, }, calculation_report::CalculationReporter, json_output::CalculationOutput, - source_text, }, }; use crate::runfile::{hf::HfOutputFormat, parser::parse_runfile}; @@ -180,17 +180,25 @@ impl Runnable for RunCommand { }; if !json_output { println!("Original calculation (TOML source)"); - source_text::print(&toml_content); + bat::print_toml(&toml_content); println!( "{}", source_geometry_heading(prepared.request().molecule().units) ); - source_text::print(&xyz_content); - println!( - "{}", - requested_calculation(prepared.request()).into_diagnostic()? - ); - println!("{}", resolved_calculation(&prepared).into_diagnostic()?); + bat::print_xyz(&xyz_content); + + let requested = requested_calculation(prepared.request()).into_diagnostic()?; + println!("\nRequested calculation (canonical TOML)"); + bat::print_toml(&requested.toml); + println!("\nRequested geometry (canonical XYZ, {})", requested.units); + bat::print_xyz(&requested.xyz); + + let resolved = resolved_calculation(&prepared).into_diagnostic()?; + println!("\n{}", resolved.summary); + println!("\nResolved configuration (canonical TOML)"); + bat::print_toml(&resolved.configuration); + println!("\nResolved geometry (canonical XYZ, Bohr)"); + bat::print_xyz(&resolved.geometry); } let result = { let stdout = io::stdout(); diff --git a/src/cli/ux/bat/mod.rs b/src/cli/ux/bat/mod.rs new file mode 100644 index 0000000..8e411cb --- /dev/null +++ b/src/cli/ux/bat/mod.rs @@ -0,0 +1,73 @@ +use std::{ + fs, io, + io::IsTerminal, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use ::bat::{ + assets::HighlightingAssets, config::Config, controller::Controller, Input, PrettyPrinter, +}; + +const BAT_SYNTAXES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bat-assets/syntaxes.bin")); +const BAT_THEMES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bat-assets/themes.bin")); + +static BAT_ASSET_CACHE_DIR: OnceLock = OnceLock::new(); + +pub(crate) fn print_toml(content: &str) { + if PrettyPrinter::new() + .input_from_bytes(content.as_bytes()) + .paging_mode(::bat::PagingMode::Never) + .language("toml") + .print() + .is_err() + { + println!("{content}"); + } +} + +pub(crate) fn print_xyz(content: &str) { + if !io::stdout().is_terminal() { + print!("{content}"); + return; + } + + if print_highlighted_xyz(content).is_err() { + print!("{content}"); + } +} + +fn print_highlighted_xyz(content: &str) -> Result<(), Box> { + let assets = HighlightingAssets::from_cache(bat_asset_cache_dir()?)?; + let config = Config { + language: Some("xyz"), + colored_output: true, + true_color: true, + paging_mode: ::bat::PagingMode::Never, + ..Default::default() + }; + let controller = Controller::new(&config, &assets); + controller.run(vec![Input::from_reader(content.as_bytes()).into()], None)?; + Ok(()) +} + +fn bat_asset_cache_dir() -> io::Result<&'static Path> { + let dir = BAT_ASSET_CACHE_DIR.get_or_init(|| { + std::env::temp_dir() + .join("rustiq") + .join("bat-assets") + .join(env!("CARGO_PKG_VERSION")) + }); + + fs::create_dir_all(dir)?; + write_asset_if_needed(&dir.join("syntaxes.bin"), BAT_SYNTAXES)?; + write_asset_if_needed(&dir.join("themes.bin"), BAT_THEMES)?; + Ok(dir.as_path()) +} + +fn write_asset_if_needed(path: &Path, contents: &[u8]) -> io::Result<()> { + if fs::read(path).is_ok_and(|existing| existing == contents) { + return Ok(()); + } + fs::write(path, contents) +} diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index 8308737..a90586d 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -98,27 +98,64 @@ impl CalculationToml { } } -pub(crate) fn requested_calculation(request: &CalculationRequest) -> Result { - Ok(format!( - "Requested calculation (canonical TOML)\n{}\nRequested geometry (canonical XYZ, {})\n{}", - toml_spanner::to_string(&CalculationToml::requested(request))?, - unit_name(request.molecule().units), - geometry_xyz(request.geometry(), "Requested geometry"), - )) +pub(crate) struct CanonicalPair { + pub(crate) toml: String, + pub(crate) xyz: String, + pub(crate) units: &'static str, } -pub(crate) fn resolved_calculation(prepared: &PreparedCalculation) -> Result { +impl CanonicalPair { + #[cfg(test)] + fn combined(&self) -> String { + format!( + "Requested calculation (canonical TOML)\n{}\nRequested geometry (canonical XYZ, {})\n{}", + self.toml, self.units, self.xyz + ) + } +} + +pub(crate) fn requested_calculation( + request: &CalculationRequest, +) -> Result { + Ok(CanonicalPair { + toml: toml_spanner::to_string(&CalculationToml::requested(request))?, + xyz: geometry_xyz(request.geometry(), "Requested geometry"), + units: unit_name(request.molecule().units), + }) +} + +pub(crate) struct ResolvedCalculation { + pub(crate) summary: String, + pub(crate) configuration: String, + pub(crate) geometry: String, +} + +impl ResolvedCalculation { + #[cfg(test)] + fn combined(&self) -> String { + format!( + "{}\n\nResolved configuration (canonical TOML)\n{}\nResolved geometry (canonical XYZ, Bohr)\n{}", + self.summary, self.configuration, self.geometry + ) + } +} + +pub(crate) fn resolved_calculation( + prepared: &PreparedCalculation, +) -> Result { let molecule = prepared.get_molecule(); - Ok(format!( - "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)\n\nResolved configuration (canonical TOML)\n{}\nResolved geometry (canonical XYZ, Bohr)\n{}", - molecule.charge(), - molecule.multiplicity().get(), - prepared.hf_method(), - prepared.basis_name(), - prepared.get_basis().nbasis(), - toml_spanner::to_string(&CalculationToml::resolved(prepared))?, - geometry_xyz(molecule.geometry(), "Resolved geometry"), - )) + Ok(ResolvedCalculation { + summary: format!( + "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)", + molecule.charge(), + molecule.multiplicity().get(), + prepared.hf_method(), + prepared.basis_name(), + prepared.get_basis().nbasis(), + ), + configuration: toml_spanner::to_string(&CalculationToml::resolved(prepared))?, + geometry: geometry_xyz(molecule.geometry(), "Resolved geometry"), + }) } pub(crate) fn source_geometry_heading(unit: Units) -> String { @@ -214,9 +251,11 @@ mod tests { } assert!(requested_calculation(prepared.request()) .unwrap() + .toml .contains("method = \"Auto\"")); assert!(resolved_calculation(&prepared) .unwrap() + .configuration .contains("method = \"Rhf\"")); } @@ -245,12 +284,16 @@ mod tests { ); let explicit = prepare(&explicit_source, &equivalent); assert_eq!( - requested_calculation(implicit.request()).unwrap(), - requested_calculation(explicit.request()).unwrap() + requested_calculation(implicit.request()) + .unwrap() + .combined(), + requested_calculation(explicit.request()) + .unwrap() + .combined() ); assert_eq!( - resolved_calculation(&implicit).unwrap(), - resolved_calculation(&explicit).unwrap() + resolved_calculation(&implicit).unwrap().combined(), + resolved_calculation(&explicit).unwrap().combined() ); assert!(explicit.request().geometry().comment.is_empty()); assert!(explicit.get_molecule().geometry().comment.is_empty()); @@ -287,7 +330,10 @@ mod tests { toml_spanner::to_string(&reparsed.runfile.hf.unwrap().guess).unwrap(), ); let restored = prepare(&rendered, &geometry); - assert_eq!(requested_calculation(prepared.request()).unwrap(), requested_calculation(restored.request()).unwrap()); + assert_eq!( + requested_calculation(prepared.request()).unwrap().combined(), + requested_calculation(restored.request()).unwrap().combined() + ); assert_eq!(prepared.request().hf().max_iterations.get(), 42); assert!(prepared.request().hf().eri_schwarz_threshold.is_none()); assert_eq!(prepared.request().mp2().unwrap().frozen_orbitals.value, 1); @@ -304,9 +350,11 @@ mod tests { assert_eq!(prepared.hf_config().method.value, config::HfMethod::Uhf); assert!(requested_calculation(prepared.request()) .unwrap() + .toml .contains("method = \"Auto\"")); assert!(resolved_calculation(&prepared) .unwrap() + .configuration .contains("method = \"Uhf\"")); } } diff --git a/src/cli/ux/mod.rs b/src/cli/ux/mod.rs index ad21681..e3039c3 100644 --- a/src/cli/ux/mod.rs +++ b/src/cli/ux/mod.rs @@ -1,10 +1,10 @@ pub(crate) mod banner; pub mod basis_table; +pub(crate) mod bat; pub(crate) mod calculation_presentation; pub(crate) mod calculation_report; pub(crate) mod json_output; pub(crate) mod mp2_report; pub mod scf_report; -pub(crate) mod source_text; pub(crate) use banner::print_startup_banner; pub(crate) use basis_table::BasisTableItem; diff --git a/src/cli/ux/source_text.rs b/src/cli/ux/source_text.rs deleted file mode 100644 index 81c9191..0000000 --- a/src/cli/ux/source_text.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub(crate) fn print(source: &str) { - print!("{source}"); - if !source.ends_with('\n') { - println!(); - } - println!(); -} diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index c3aef50..4f97d1c 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -73,6 +73,27 @@ fn assert_error(output: &Output) { ); } +fn strip_ansi(input: &str) -> String { + let bytes = input.as_bytes(); + let mut visible = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index..].starts_with(b"\x1b[") { + index += 2; + while index < bytes.len() && !(0x40..=0x7e).contains(&bytes[index]) { + index += 1; + } + index += usize::from(index < bytes.len()); + } else { + let character = input[index..].chars().next().unwrap(); + let end = index + character.len_utf8(); + visible.extend_from_slice(&bytes[index..end]); + index = end; + } + } + String::from_utf8(visible).unwrap() +} + #[test] #[cfg(feature = "online")] fn test_online_basis_commands_are_available_with_default_features() { @@ -165,7 +186,7 @@ fn test_cli_prints_original_toml_and_xyz_for_copying() { run_rustiq_with_data_home(&["run", calculation_path.to_str().unwrap()], &temp_root); assert_success(&output); - let stdout = String::from_utf8(output.stdout).unwrap(); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); let toml_section = stdout .split_once("Original calculation (TOML source)\n") .unwrap() @@ -173,7 +194,7 @@ fn test_cli_prints_original_toml_and_xyz_for_copying() { .split_once("Original geometry (XYZ source, Angstrom)\n") .unwrap() .0; - assert_eq!(toml_section, format!("{toml}\n")); + assert_eq!(toml_section, toml); let xyz_section = stdout .split_once("Original geometry (XYZ source, Angstrom)\n") .unwrap() From 2a06feb5b618e6cac88dbfa28b2fe0ff772a3f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Wed, 30 Sep 2026 18:23:24 +0200 Subject: [PATCH 5/8] Strip terminal colors in CLI output assertions --- tests/cli_samples.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index 4f97d1c..8c1dc96 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -152,7 +152,7 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert_success(&output); - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = strip_ansi(&String::from_utf8_lossy(&output.stdout)); assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); From e952e64195b67c4127c12d862b540720fb986e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Wed, 30 Sep 2026 20:34:06 +0200 Subject: [PATCH 6/8] Resolve random seeds during calculation preparation --- crates/rustiq-core/src/calculation/builder.rs | 61 ++++++--- .../src/calculation/prepared_calculation.rs | 14 ++- crates/rustiq-core/tests/public_api.rs | 117 ++++++++++++++++++ src/cli/ux/calculation_presentation.rs | 3 + 4 files changed, 174 insertions(+), 21 deletions(-) diff --git a/crates/rustiq-core/src/calculation/builder.rs b/crates/rustiq-core/src/calculation/builder.rs index e477f95..383d277 100644 --- a/crates/rustiq-core/src/calculation/builder.rs +++ b/crates/rustiq-core/src/calculation/builder.rs @@ -155,17 +155,15 @@ impl<'a> CalculationBuilder<'a> { &self, mut events: impl FnMut(CalculationEvent<'_>), ) -> Result { + let request = self.normalized_request(); let mut requested_geometry = self.geometry.clone(); requested_geometry.comment.clear(); let mut molecule = self.molecule_config.build(requested_geometry.clone())?; molecule.convert_to(Units::Bohr); let method = self.hf.resolve_method(&molecule)?; - let mut resolved_hf = self.hf.clone(); - resolved_hf.method.value = match method { - crate::config::ResolvedHfMethod::Rhf => crate::config::HfMethod::Rhf, - crate::config::ResolvedHfMethod::Uhf => crate::config::HfMethod::Uhf, - }; - let hf = (resolved_hf, method); + let mut execution_hf = self.hf.clone(); + resolve_random_seeds(&mut execution_hf); + let hf = (execution_hf, method); events(CalculationEvent::BasisStarted); let start = Instant::now(); let basis = Basis::try_load(self.basis_file, &molecule)?; @@ -173,8 +171,22 @@ impl<'a> CalculationBuilder<'a> { basis: &basis, elapsed: start.elapsed(), }); - let request = CalculationRequest { - geometry: requested_geometry, + Ok(PreparedCalculation { + request, + molecule, + basis, + basis_name: self.basis_file.name().to_owned(), + hf, + mp2: self.mp2, + eri_cache: self.eri_cache.clone(), + }) + } + + fn normalized_request(&self) -> CalculationRequest { + let mut geometry = self.geometry.clone(); + geometry.comment.clear(); + CalculationRequest { + geometry, molecule: MoleculeConfig { units: self.molecule_config.units, charge: self.molecule_config.charge.value.into(), @@ -189,16 +201,29 @@ impl<'a> CalculationBuilder<'a> { frozen_orbitals: config.frozen_orbitals.value.into(), memory_limit: config.memory_limit.value.into(), }), - }; - Ok(PreparedCalculation { - request, - molecule, - basis, - basis_name: self.basis_file.name().to_owned(), - hf, - mp2: self.mp2, - eri_cache: self.eri_cache.clone(), - }) + } + } +} + +fn resolve_random_seeds(config: &mut HfConfig) { + let guess = &mut config.guess.value; + match guess { + crate::config::DensityGuessConfig::Random { config } => { + resolve_seed(&mut config.random.seed); + } + crate::config::DensityGuessConfig::CoreHamiltonian { perturbation } + | crate::config::DensityGuessConfig::OneElectron { perturbation } => { + if let Some(perturbation) = perturbation { + resolve_seed(&mut perturbation.random.seed); + } + } + crate::config::DensityGuessConfig::Zero => {} + } +} + +fn resolve_seed(seed: &mut Option) { + if seed.is_none() { + *seed = Some(rand::random()); } } diff --git a/crates/rustiq-core/src/calculation/prepared_calculation.rs b/crates/rustiq-core/src/calculation/prepared_calculation.rs index 485fc4e..590796f 100644 --- a/crates/rustiq-core/src/calculation/prepared_calculation.rs +++ b/crates/rustiq-core/src/calculation/prepared_calculation.rs @@ -45,14 +45,22 @@ impl PreparedCalculation { self.hf.1 } - /// Canonical label of the loaded basis; AO contents are exposed by `get_basis()`. + /// Human-readable label of the loaded basis; this is not its scientific identity. + /// The resolved basis contents exposed by `get_basis()` are authoritative. Replaying + /// canonical TOML that uses this label assumes a compatible basis store. pub fn basis_name(&self) -> &str { &self.basis_name } - /// Effective HF options with an explicit method and no frontend source spans. + /// Resolved HF presentation options with an explicit method and no frontend source spans. + /// Random seeds resolved during preparation are retained here. pub fn hf_config(&self) -> HfConfig { - super::builder::normalized_hf_config(&self.hf.0) + let mut config = super::builder::normalized_hf_config(&self.hf.0); + config.method.value = match self.hf.1 { + ResolvedHfMethod::Rhf => crate::config::HfMethod::Rhf, + ResolvedHfMethod::Uhf => crate::config::HfMethod::Uhf, + }; + config } /// MP2 options without frontend source spans; automatic memory resolves at execution. diff --git a/crates/rustiq-core/tests/public_api.rs b/crates/rustiq-core/tests/public_api.rs index 9b4d94d..21a2f6d 100644 --- a/crates/rustiq-core/tests/public_api.rs +++ b/crates/rustiq-core/tests/public_api.rs @@ -183,6 +183,123 @@ fn calculation_builder_normalizes_units_and_orchestrates_both_hf_methods_and_mp2 } } +#[test] +fn prepared_calculation_resolves_missing_random_seeds_and_preserves_auto_event_config() { + let geometry = geometry(); + let file = basis_file(); + let mut config = HfConfig { + guess: DensityGuessConfig::Random { + config: RandomGuessConfig { + random: RandomConfig { + distribution: DistributionConfig::Uniform { + config: UniformDistributionConfig { + min: -1.0, + max: 1.0, + }, + }, + seed: None, + }, + }, + } + .into(), + ..Default::default() + }; + config.method.value = HfMethod::Auto; + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(config) + .prepare() + .unwrap(); + + let request_guess = prepared.request().hf().guess.value; + let resolved_guess = prepared.hf_config().guess.value; + let seed = |guess| match guess { + DensityGuessConfig::Random { config } => config.random.seed, + _ => panic!("expected random guess"), + }; + assert_eq!(seed(request_guess), None); + let resolved_seed = seed(resolved_guess).expect("preparation resolves the missing seed"); + assert_eq!(seed(prepared.hf_config().guess.value), Some(resolved_seed)); + assert_eq!(prepared.hf_config().method.value, HfMethod::Rhf); + + for _ in 0..2 { + let mut observed_auto = false; + let mut event_seed = None; + prepared + .run_hf_with_events(|event| { + if let CalculationEvent::HfStarted { method, config } = event { + observed_auto = + method == ResolvedHfMethod::Rhf && config.method.value == HfMethod::Auto; + event_seed = Some(seed(config.guess.value)); + } + }) + .unwrap(); + assert!(observed_auto); + assert_eq!(event_seed, Some(Some(resolved_seed))); + } +} + +#[test] +fn preparation_resolves_missing_perturbation_seeds_but_keeps_explicit_seeds() { + let geometry = geometry(); + let file = basis_file(); + for (guess, expected) in [ + ( + DensityGuessConfig::CoreHamiltonian { + perturbation: Some(Default::default()), + }, + None, + ), + ( + DensityGuessConfig::OneElectron { + perturbation: Some(Default::default()), + }, + None, + ), + ] { + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(HfConfig { + guess: guess.into(), + ..Default::default() + }) + .prepare() + .unwrap(); + let seed = |guess| match guess { + DensityGuessConfig::CoreHamiltonian { perturbation } + | DensityGuessConfig::OneElectron { perturbation } => perturbation.unwrap().random.seed, + _ => panic!("expected perturbed guess"), + }; + assert_eq!(seed(prepared.request().hf().guess.value), expected); + assert!(seed(prepared.hf_config().guess.value).is_some()); + let resolved_seed = seed(prepared.hf_config().guess.value); + assert!(resolved_seed.is_some()); + assert_eq!(seed(prepared.hf_config().guess.value), resolved_seed); + } + + let explicit = DensityGuessConfig::CoreHamiltonian { + perturbation: Some(rustiq_core::config::GuessPerturbationConfig { + random: RandomConfig { + distribution: DistributionConfig::Uniform { + config: UniformDistributionConfig { min: 0.0, max: 1.0 }, + }, + seed: Some(8675309), + }, + }), + }; + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(HfConfig { + guess: explicit.into(), + ..Default::default() + }) + .prepare() + .unwrap(); + match prepared.hf_config().guess.value { + DensityGuessConfig::CoreHamiltonian { perturbation } => { + assert_eq!(perturbation.unwrap().random.seed, Some(8675309)); + } + _ => panic!("expected core Hamiltonian guess"), + } +} + #[test] fn builder_mutable_setters_keep_hf_mandatory_and_allow_disabling_mp2() { let geometry = geometry(); diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index a90586d..e2a0d8c 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -18,6 +18,9 @@ use crate::runfile::{ /// CLI rendering schema, not a scientific persistence schema. The generated /// geometry filename belongs to this TOML/XYZ adapter, never to the core views. +/// Resolved TOML includes the loaded basis label for CLI replay; replay assumes +/// a compatible basis store, while `PreparedCalculation::get_basis()` is the +/// authoritative resolved basis content. #[derive(Toml)] #[toml(ToToml)] struct CalculationToml { From ac7958446f12238596cc734224076e1a9c52a83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Wed, 30 Sep 2026 20:40:17 +0200 Subject: [PATCH 7/8] Test rendered resolved random seed --- src/cli/ux/calculation_presentation.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index e2a0d8c..5604022 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -345,6 +345,25 @@ mod tests { } } + #[test] + fn resolved_rendered_toml_includes_prepared_random_seed() { + let geometry = Geometry::from_source("input.xyz", "2\nH2\nH 0 0 0\nH 0 0 0.74\n").unwrap(); + let prepared = prepare( + "[global]\nbasis = 'sto-3g'\n[hf.guess]\ntype = 'Random'\ndistribution = 'Uniform'\nmin = -1.0\nmax = 1.0\n", + &geometry, + ); + let seed = match prepared.hf_config().guess.value { + config::DensityGuessConfig::Random { config } => { + config.random.seed.expect("preparation resolves the seed") + } + _ => panic!("expected random guess"), + }; + let requested = requested_calculation(prepared.request()).unwrap().toml; + let resolved = resolved_calculation(&prepared).unwrap().configuration; + assert!(!requested.contains("seed =")); + assert!(resolved.contains(&format!("seed = {seed}"))); + } + #[test] fn auto_remains_requested_and_resolves_to_uhf_for_a_doublet() { let geometry = Geometry::from_source("input.xyz", "2\nH2+\nH 0 0 0\nH 0 0 1.4\n").unwrap(); From 37af41334f9f1828e562e305b55f831963a4af14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20V=C3=A9ril?= Date: Thu, 1 Oct 2026 20:55:43 +0200 Subject: [PATCH 8/8] Show canonical request in run summary --- src/cli/commands/run_command.rs | 37 +++--- src/cli/ux/calculation_presentation.rs | 151 ++++++++++++++++++++++++- tests/cli_samples.rs | 104 ++++++----------- 3 files changed, 195 insertions(+), 97 deletions(-) diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index de53903..cf02f24 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -12,9 +12,7 @@ use crate::cli::{ self, ux::{ bat, - calculation_presentation::{ - requested_calculation, resolved_calculation, source_geometry_heading, - }, + calculation_presentation::{calculation_summary, requested_calculation, SourceProvenance}, calculation_report::CalculationReporter, json_output::CalculationOutput, }, @@ -129,12 +127,20 @@ impl Runnable for RunCommand { ("".to_string(), content) }; let parsed = parse_runfile(source_name.clone(), &toml_content)?; - let source_code = NamedSource::new(source_name, toml_content.clone()); let run = parsed.runfile; let molecule_path = &run.global.molecule.geometry; let xyz_content = fs::read_to_string(molecule_path).into_diagnostic()?; - let geom = Geometry::from_source(molecule_path.display().to_string(), &xyz_content) - .into_diagnostic()?; + let source = SourceProvenance::new( + source_name, + toml_content, + molecule_path.clone(), + xyz_content, + ); + let source_code = + NamedSource::new(source.calculation_name.clone(), source.calculation.clone()); + let geom = + Geometry::from_source(source.geometry_path.display().to_string(), &source.geometry) + .into_diagnostic()?; if !json_output { println!("Loading basis set..."); } @@ -179,26 +185,15 @@ impl Runnable for RunCommand { prepared.map_err(|error| with_source(error, &source_code))? }; if !json_output { - println!("Original calculation (TOML source)"); - bat::print_toml(&toml_content); - println!( - "{}", - source_geometry_heading(prepared.request().molecule().units) - ); - bat::print_xyz(&xyz_content); - let requested = requested_calculation(prepared.request()).into_diagnostic()?; println!("\nRequested calculation (canonical TOML)"); bat::print_toml(&requested.toml); println!("\nRequested geometry (canonical XYZ, {})", requested.units); bat::print_xyz(&requested.xyz); - - let resolved = resolved_calculation(&prepared).into_diagnostic()?; - println!("\n{}", resolved.summary); - println!("\nResolved configuration (canonical TOML)"); - bat::print_toml(&resolved.configuration); - println!("\nResolved geometry (canonical XYZ, Bohr)"); - bat::print_xyz(&resolved.geometry); + println!( + "\n{}", + calculation_summary(&prepared, &source.geometry_path) + ); } let result = { let stdout = io::stdout(); diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs index 5604022..e800623 100644 --- a/src/cli/ux/calculation_presentation.rs +++ b/src/cli/ux/calculation_presentation.rs @@ -1,4 +1,12 @@ -use std::{fmt::Write, num::NonZeroUsize}; +// Full canonical views are lazy capabilities for inspection and artifact reuse; +// the normal `run` path uses only the concise summary below. +#![allow(dead_code)] + +use std::{ + fmt::Write, + num::NonZeroUsize, + path::{Path, PathBuf}, +}; use rustiq_core::{ calculation::{CalculationRequest, PreparedCalculation}, @@ -107,6 +115,31 @@ pub(crate) struct CanonicalPair { pub(crate) units: &'static str, } +/// Exact frontend input retained at the CLI boundary for diagnostics or an +/// explicit source view. It is provenance only and never enters core state. +pub(crate) struct SourceProvenance { + pub(crate) calculation_name: String, + pub(crate) calculation: String, + pub(crate) geometry_path: PathBuf, + pub(crate) geometry: String, +} + +impl SourceProvenance { + pub(crate) fn new( + calculation_name: String, + calculation: String, + geometry_path: PathBuf, + geometry: String, + ) -> Self { + Self { + calculation_name, + calculation, + geometry_path, + geometry, + } + } +} + impl CanonicalPair { #[cfg(test)] fn combined(&self) -> String { @@ -133,6 +166,38 @@ pub(crate) struct ResolvedCalculation { pub(crate) geometry: String, } +/// Concise text shown by the normal `run` command. Full canonical renderings +/// remain available through the requested/resolved helpers above and below. +pub(crate) fn calculation_summary(prepared: &PreparedCalculation, geometry_path: &Path) -> String { + let request = prepared.request(); + let molecule = prepared.get_molecule(); + let requested_method = match request.hf().method.value { + config::HfMethod::Auto => "Auto", + config::HfMethod::Rhf => "RHF", + config::HfMethod::Uhf => "UHF", + }; + let method = prepared.hf_method().to_string(); + let method = if requested_method == "Auto" { + format!("{method} (requested: {requested_method})") + } else { + method + }; + let coordinates = match request.molecule().units { + Units::Angstrom => "Bohr (input: Angstrom)", + Units::Bohr => "Bohr", + }; + + format!( + "Calculation\n Geometry {}\n Atoms {}\n Charge {}\n Multiplicity {}\n Coordinates {coordinates}\n HF {method}\n Basis {} ({} functions)", + geometry_path.display(), + molecule.geometry().atoms.len(), + molecule.charge(), + molecule.multiplicity().get(), + prepared.basis_name(), + prepared.get_basis().nbasis(), + ) +} + impl ResolvedCalculation { #[cfg(test)] fn combined(&self) -> String { @@ -161,10 +226,6 @@ pub(crate) fn resolved_calculation( }) } -pub(crate) fn source_geometry_heading(unit: Units) -> String { - format!("Original geometry (XYZ source, {})", unit_name(unit)) -} - fn unit_name(unit: Units) -> &'static str { match unit { Units::Bohr => "Bohr", @@ -190,7 +251,11 @@ fn geometry_xyz(geometry: &Geometry, comment: &str) -> String { mod tests { use super::*; use crate::runfile::parser::parse_runfile; - use rustiq_core::{basis::BasisFile, calculation::CalculationBuilder}; + use approx::assert_abs_diff_eq; + use rustiq_core::{ + basis::BasisFile, + calculation::{CalculationBuilder, CalculationExecution}, + }; fn basis() -> BasisFile { BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap() @@ -208,6 +273,80 @@ mod tests { .unwrap() } + #[test] + fn source_provenance_keeps_toml_and_xyz_text_exact() { + let toml = "# Original comment\n[global]\nbasis = 'sto-3g' # Original quote and spacing\n"; + let xyz = + "2\nGeometry comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n"; + let source = SourceProvenance::new( + "calculation.toml".into(), + toml.into(), + "../molecule.xyz".into(), + xyz.into(), + ); + + assert_eq!(source.calculation, toml); + assert_eq!(source.geometry, xyz); + assert_eq!(source.calculation_name, "calculation.toml"); + assert_eq!(source.geometry_path, Path::new("../molecule.xyz")); + } + + #[test] + fn summary_shows_effective_values_and_only_meaningful_requested_differences() { + let geometry = + Geometry::from_source("molecule.xyz", "2\nH2\nH 0 0 -0.37\nH 0 0 0.37\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + let summary = calculation_summary(&prepared, Path::new("../molecule.xyz")); + + assert!(summary.contains("Geometry ../molecule.xyz")); + assert!(summary.contains("Atoms 2")); + assert!(summary.contains("Charge 0")); + assert!(summary.contains("Multiplicity 1")); + assert!(summary.contains("Coordinates Bohr (input: Angstrom)")); + assert!(summary.contains("HF RHF (requested: Auto)")); + assert!(summary.contains("Basis STO-3G (2 functions)")); + assert!(!summary.contains("requested: 0")); + assert!(!summary.contains("requested: sto-3g")); + assert!(!summary.contains("H 0 0")); + } + + #[test] + fn copied_canonical_requested_and_resolved_pairs_run_equivalently() { + let geometry = + Geometry::from_source("input.xyz", "2\nH2\nH 0 0 -0.37\nH 0 0 0.37\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + let requested = requested_calculation(prepared.request()).unwrap(); + let resolved = resolved_calculation(&prepared).unwrap(); + let temp = tempfile::tempdir().unwrap(); + + let original_energy = prepared.execute().unwrap().hf.summary().scf.total_energy; + for (name, toml, xyz) in [ + ("requested", requested.toml, requested.xyz), + ("resolved", resolved.configuration, resolved.geometry), + ] { + let directory = temp.path().join(name); + std::fs::create_dir(&directory).unwrap(); + let toml_path = directory.join("calculation.toml"); + let xyz_path = directory.join("molecule.xyz"); + std::fs::write(&toml_path, toml).unwrap(); + std::fs::write(&xyz_path, xyz).unwrap(); + + let input = std::fs::read_to_string(&toml_path).unwrap(); + let parsed = parse_runfile(toml_path.display().to_string(), &input).unwrap(); + let replay_geometry = Geometry::from_path(&xyz_path).unwrap(); + let basis = basis(); + let replay = CalculationBuilder::new(&replay_geometry, &basis) + .with_basis_label(parsed.runfile.global.basis) + .with_molecule_config(parsed.molecule_config) + .with_hf(parsed.hf_config.unwrap_or_default()) + .with_mp2(parsed.mp2_config) + .prepare() + .unwrap(); + let replay_energy = replay.execute().unwrap().hf.summary().scf.total_energy; + assert_abs_diff_eq!(replay_energy, original_energy, epsilon = 1e-10); + } + } + #[test] fn canonical_views_render_without_source_files_and_preserve_coordinate_precision() { let temp = tempfile::tempdir().unwrap(); diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index 8c1dc96..348d13c 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -156,22 +156,34 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); - assert!(stdout.contains("Original calculation (TOML source)")); - assert!(stdout.contains("Original geometry (XYZ source, Angstrom)")); + assert!(stdout.contains("Calculation\n Geometry ../molecule.xyz")); + assert!(stdout.contains(" Atoms 2")); + assert!(stdout.contains(" Charge 0")); + assert!(stdout.contains(" Multiplicity 1")); + assert!(stdout.contains(" Coordinates Bohr (input: Angstrom)")); + assert!(stdout.contains(" HF RHF (requested: Auto)")); + assert!(stdout.contains(" Basis STO-3G (2 functions)")); assert!(stdout.contains("Requested calculation (canonical TOML)")); - assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); assert!(stdout.contains("method = \"Auto\"")); assert!(stdout.contains("max_iterations = 100")); - assert!(stdout.contains("Resolved configuration (canonical TOML)")); - assert!(stdout.contains("method = \"Rhf\"")); - assert!(stdout.contains("Resolved calculation")); - assert!(stdout.contains("Coordinates Bohr")); - assert!(stdout.contains("HF method RHF")); - assert!(stdout.contains("Basis STO-3G (2 functions)")); + assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); + assert_eq!(stdout.matches("H 0 0 -0.37").count(), 1); + assert_eq!(stdout.matches("H 0 0 0.37").count(), 1); + for hidden_section in [ + "Original calculation (TOML source)", + "Original geometry (XYZ source", + "Resolved configuration (canonical TOML)", + "Resolved geometry (canonical XYZ", + ] { + assert!( + !stdout.contains(hidden_section), + "unexpected {hidden_section}" + ); + } } #[test] -fn test_cli_prints_original_toml_and_xyz_for_copying() { +fn test_cli_shows_canonical_request_without_original_source_dump() { let temp_root = temp_root("cli-original-input"); prepare_basis_store(&temp_root); let input_dir = temp_root.join("input"); @@ -187,66 +199,18 @@ fn test_cli_prints_original_toml_and_xyz_for_copying() { assert_success(&output); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); - let toml_section = stdout - .split_once("Original calculation (TOML source)\n") - .unwrap() - .1 - .split_once("Original geometry (XYZ source, Angstrom)\n") - .unwrap() - .0; - assert_eq!(toml_section, toml); - let xyz_section = stdout - .split_once("Original geometry (XYZ source, Angstrom)\n") - .unwrap() - .1 - .split_once("Requested calculation (canonical TOML)\n") - .unwrap() - .0; - assert_eq!(xyz_section, format!("{xyz}\n")); - - // Both semantic TOML/XYZ pairs must also be usable as standalone inputs. - for (name, toml_heading, xyz_heading) in [ - ( - "requested", - "Requested calculation (canonical TOML)\n", - "Requested geometry (canonical XYZ, Angstrom)\n", - ), - ( - "resolved", - "Resolved configuration (canonical TOML)\n", - "Resolved geometry (canonical XYZ, Bohr)\n", - ), - ] { - let after_heading = stdout.split_once(toml_heading).unwrap().1; - let (canonical_toml, after_xyz_heading) = after_heading.split_once(xyz_heading).unwrap(); - let atom_count = after_xyz_heading - .lines() - .next() - .unwrap() - .parse::() - .unwrap(); - let canonical_xyz = after_xyz_heading - .lines() - .take(atom_count + 2) - .collect::>() - .join("\n"); - let copied_dir = temp_root.join(name); - fs::create_dir_all(&copied_dir).unwrap(); - let copied_toml = copied_dir.join("calculation.toml"); - fs::write(&copied_toml, canonical_toml).unwrap(); - fs::write(copied_dir.join("molecule.xyz"), canonical_xyz).unwrap(); - let repeated = - run_rustiq_with_data_home(&["run", copied_toml.to_str().unwrap()], &temp_root); - assert_success(&repeated); - let repeated_stdout = String::from_utf8(repeated.stdout).unwrap(); - let energy_line = |text: &str| { - text.lines() - .find(|line| line.starts_with("Total Energy (including nuclear repulsion):")) - .unwrap() - .to_owned() - }; - assert_eq!(energy_line(&repeated_stdout), energy_line(&stdout)); - } + assert!(stdout.contains("Requested calculation (canonical TOML)")); + assert!(stdout.contains("basis = \"sto-3g\"")); + assert!(stdout.contains("geometry = \"molecule.xyz\"")); + assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); + assert!(stdout.contains("H 0 0 -0.3700000001234568")); + assert!(stdout.contains("Calculation\n")); + assert!(stdout.contains("Geometry molecule.xyz")); + assert!(!stdout.contains("Keep the original path and formatting")); + assert!(!stdout.contains("Hydrogen molecule -- original comment")); + assert!(!stdout.contains("relative to this file")); + assert!(!stdout.contains("Resolved configuration (canonical TOML)")); + assert!(!stdout.contains("Resolved geometry (canonical XYZ")); } #[test]