From 3f7b7f347c5fa5b54ecbee02930c731df1ebf29c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 13:12:32 -0400 Subject: [PATCH 1/3] wai-quantum: rectangle space groups, and the sector referee on the sector's own basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetry::rectangle projects onto the translations of an lx × ly torus with its half turn and axis reflections, in one of their four one-dimensional representations (RectIrrep), at momentum 0 or π along each direction. SpinModel::exact_ground_energy_in now works on one state per orbit of the group, Σ_g χ(g)|g·r⟩ normalised for each least configuration r that no element of character −1 fixes, up to 24 spins. The projector referee it replaces is kept in the tests, and the two agree to 1e-9 in all 24 sectors of the 4 × 3 J1-J2 torus, on a 10-site ring, and on an Ising ring in a transverse field. The identity alone gives the plain diagonalisation; the flip alone splits it in two. The CLI and nqs_ground take --reflections a1|a2|b1|b2, and hold a projected network to its sector up to 24 spins. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/examples/nqs_ground.rs | 26 +- wai/wai-quantum/src/quantum_nqs.rs | 325 ++++++++++++++++++++++--- wai/wai-rs/src/bin/wai_quantum.rs | 29 ++- 3 files changed, 328 insertions(+), 52 deletions(-) diff --git a/wai/wai-quantum/examples/nqs_ground.rs b/wai/wai-quantum/examples/nqs_ground.rs index 91d7573ca..21e8cb1c7 100644 --- a/wai/wai-quantum/examples/nqs_ground.rs +++ b/wai/wai-quantum/examples/nqs_ground.rs @@ -7,12 +7,15 @@ //! --model heisenberg|j1j2|ising --lx L --ly L [--j2 J] [--h H] [--periodic] //! [--complex] [--marshall] [--alpha A] [--steps N] [--samples S] [--lr R] [--shift λ] //! [--init W] [--exact] [--guide ε] -//! [--project [--kpi] [--point-group a1|a2|b1|b2] [--odd]] [--dmrg-bond D] +//! [--project [--kpi] [--point-group a1|a2|b1|b2 | --reflections a1|a2|b1|b2] +//! [--odd]] [--dmrg-bond D] //! [--threads T] [--exact-final] //! //! `--project` projects onto translations of the periodic lattice at //! momentum 0 (or π with `--kpi`), with `--point-group` on a square lattice -//! onto a representation of its rotations and reflections too, and onto even +//! onto a representation of its rotations and reflections too, with +//! `--reflections` on any lattice onto one of its half turn and axis +//! reflections (`RectIrrep`), and onto even //! (or `--odd`) parity under a global spin flip. Up to 20 spins, the //! reference is then the exact ground energy in that sector. `--exact-final` //! also evaluates the trained network with every configuration enumerated @@ -88,22 +91,31 @@ fn main() { Some("b2") => PointIrrep::B2, other => panic!("--point-group: a1, a2, b1 or b2, not {other:?}"), }); + let reflections = a.iter().position(|x| x == "--reflections").map(|i| match a.get(i + 1).map(String::as_str) { + Some("a1") => RectIrrep::A1, + Some("a2") => RectIrrep::A2, + Some("b1") => RectIrrep::B1, + Some("b2") => RectIrrep::B2, + other => panic!("--reflections: a1, a2, b1 or b2, not {other:?}"), + }); let symmetry = a.iter().any(|x| x == "--project").then(|| { assert!(periodic, "--project needs --periodic"); let kpi = a.iter().any(|x| x == "--kpi"); - let base = match point { - Some(irrep) => { + let base = match (point, reflections) { + (Some(_), Some(_)) => panic!("--point-group or --reflections, not both"), + (Some(irrep), None) => { assert_eq!(lx, ly, "--point-group needs a square lattice"); Symmetry::square(lx, kpi, irrep) } - None if ly == 1 => Symmetry::ring(n, kpi), - None => Symmetry::torus(lx, ly, kpi, kpi), + (None, Some(irrep)) => Symmetry::rectangle(lx, ly, kpi, kpi, irrep), + (None, None) if ly == 1 => Symmetry::ring(n, kpi), + (None, None) => Symmetry::torus(lx, ly, kpi, kpi), }; base.with_spin_flip(a.iter().any(|x| x == "--odd")) }); // A projected network is held to its own sector's ground energy. let reference = match &symmetry { - Some(sym) if n <= 20 => { + Some(sym) if n <= 24 => { let e = model.exact_ground_energy_in(sector, sym).expect("the model is invariant under the group, and the sector is not empty"); println!("in its sector: {e:.8}"); e diff --git a/wai/wai-quantum/src/quantum_nqs.rs b/wai/wai-quantum/src/quantum_nqs.rs index f2440f3f7..c9a96efd1 100644 --- a/wai/wai-quantum/src/quantum_nqs.rs +++ b/wai/wai-quantum/src/quantum_nqs.rs @@ -24,7 +24,9 @@ //! π along each direction), on a square lattice its whole space group //! ([`Symmetry::square`]: the translations with the eight rotations and //! reflections, in one of their four one-dimensional representations, -//! [`PointIrrep`]), and, optionally, the flip of every spin (even or odd). +//! [`PointIrrep`]), on any lattice the translations with its half turn and +//! axis reflections ([`Symmetry::rectangle`], [`RectIrrep`]), and, +//! optionally, the flip of every spin (even or odd). //! [`Symmetry::check`] proves the elements a group and the characters //! multiplicative, without which the sum projects onto nothing in //! particular. Ratios and log-derivatives become weighted averages over the @@ -46,10 +48,10 @@ //! - **Referees.** [`SpinModel::exact_ground_energy`] is Lanczos on the //! amplitudes of a magnetisation sector, or all `2ⁿ` (at most 22 spins). //! [`SpinModel::exact_ground_energy_in`] is the same within one symmetry -//! sector: Lanczos on `P H P`, re-projected after every product, so a -//! projected network is held to its own sector's answer. It refuses a -//! group that is not one and a model the group does not preserve -//! ([`SpinModel::commutes_with`]). [`SpinModel::opsum`] gives the model to +//! sector, on the sector's own basis (one state per orbit of the group, +//! up to 24 spins), so a projected network is held to its own sector's +//! answer. It refuses a group that is not one and a model the group does +//! not preserve ([`SpinModel::commutes_with`]). [`SpinModel::opsum`] gives the model to //! DMRG on its exact MPO, for lattices past exact diagonalisation. //! //! Chains draw from their own seeded generators, and every sum runs in a @@ -94,6 +96,15 @@ //! starts on the ferromagnet (`E = n/4`, variance below `10⁻³`) and is //! within `10⁻²` of it after 40 steps; from `±0.1` it reaches the ground //! state to `10⁻³` in 200. +//! - **Two sector referees agree.** The orbit basis gives the same energy, +//! to `10⁻⁹`, as Lanczos on `P H P` re-projected after every product +//! (kept in the tests): in all 24 sectors of the 4 × 3 J₁–J₂ torus (four +//! representations, both momenta along x, no flip, an even one and an odd +//! one), on a 10-site ring, and on an Ising ring in a transverse field, +//! where the magnetisation is not conserved. With the identity alone it is +//! the plain diagonalisation; with the flip alone one half holds the +//! ground state and the other lies above. Every rectangle group on 4 × 3 +//! and 6 × 4 closes and multiplies its characters. //! - **The sector referee.** On a 10-site ring the sector of momentum π and //! odd parity holds the ground state to `10⁻⁹`, and the sector of momentum //! 0 and even parity lies above it. On the 4 × 4 lattice at `J₂ = J₁/2`, @@ -475,37 +486,12 @@ impl SpinModel { Some(lanczos(&apply, &start, 60, 30, 1e-9, false).0) } - /// Whether every element of `sym` maps the model onto itself: each bond - /// and field to one of the same strength, and under a flip of every spin - /// no `σᶻ` field, which the flip reverses. - pub fn commutes_with(&self, sym: &Symmetry) -> bool { - let n = self.n; - if sym.perms.iter().any(|p| p.len() != n) { - return false; - } - let bonds = |terms: &[(usize, usize, f64)], p: &[usize]| { - let mut b: Vec<(usize, usize, u64)> = terms.iter().map(|&(i, j, c)| (p[i].min(p[j]), p[i].max(p[j]), c.to_bits())).collect(); - b.sort_unstable(); - b - }; - let fields = |terms: &[(usize, f64)], p: &[usize]| { - let mut f: Vec<(usize, u64)> = terms.iter().map(|&(i, c)| (p[i], c.to_bits())).collect(); - f.sort_unstable(); - f - }; - let identity: Vec = (0..n).collect(); - let (exchange, zz, x, z) = (bonds(&self.exchange, &identity), bonds(&self.zz, &identity), fields(&self.x, &identity), fields(&self.z, &identity)); - sym.perms.iter().zip(&sym.flips).all(|(p, &flip)| bonds(&self.exchange, p) == exchange && bonds(&self.zz, p) == zz && fields(&self.x, p) == x && fields(&self.z, p) == z && !(flip && !self.z.is_empty())) - } - - /// The exact ground energy within the sector `sym` projects onto (at most - /// 22 sites), and in the magnetisation sector when given: Lanczos on - /// `P H P` with `P = (1/|G|) Σ_g χ(g) g`, from a start in the sector and - /// projected again after every product, so rounding cannot carry it into - /// a lower sector. `None` when `sym` is not a group with characters, when - /// the model is not invariant under it, when a flip would leave the - /// magnetisation sector, or when the sector is empty. - pub fn exact_ground_energy_in(&self, magnetisation: Option, sym: &Symmetry) -> Option { + /// The projector referee the orbit basis replaced, kept to check it: + /// Lanczos on `P H P`, `P = (1/|G|) Σ_g χ(g) g`, re-projected after + /// every product, on the magnetisation sector's amplitudes (at most 22 + /// sites). + #[cfg(test)] + fn projected_ground_energy(&self, magnetisation: Option, sym: &Symmetry) -> Option { let n = self.n; if n == 0 || n > 22 || sym.check().is_err() || !self.commutes_with(sym) { return None; @@ -557,6 +543,150 @@ impl SpinModel { }; Some(lanczos(&apply, &start, 60, 30, 1e-9, false).0) } + + /// Whether every element of `sym` maps the model onto itself: each bond + /// and field to one of the same strength, and under a flip of every spin + /// no `σᶻ` field, which the flip reverses. + pub fn commutes_with(&self, sym: &Symmetry) -> bool { + let n = self.n; + if sym.perms.iter().any(|p| p.len() != n) { + return false; + } + let bonds = |terms: &[(usize, usize, f64)], p: &[usize]| { + let mut b: Vec<(usize, usize, u64)> = terms.iter().map(|&(i, j, c)| (p[i].min(p[j]), p[i].max(p[j]), c.to_bits())).collect(); + b.sort_unstable(); + b + }; + let fields = |terms: &[(usize, f64)], p: &[usize]| { + let mut f: Vec<(usize, u64)> = terms.iter().map(|&(i, c)| (p[i], c.to_bits())).collect(); + f.sort_unstable(); + f + }; + let identity: Vec = (0..n).collect(); + let (exchange, zz, x, z) = (bonds(&self.exchange, &identity), bonds(&self.zz, &identity), fields(&self.x, &identity), fields(&self.z, &identity)); + sym.perms.iter().zip(&sym.flips).all(|(p, &flip)| bonds(&self.exchange, p) == exchange && bonds(&self.zz, p) == zz && fields(&self.x, p) == x && fields(&self.z, p) == z && !(flip && !self.z.is_empty())) + } + + /// The exact ground energy within the sector `sym` projects onto (at most + /// 24 sites), and in the magnetisation sector when given, by Lanczos on + /// the sector's own basis: for each orbit of the group, the state + /// `Σ_g χ(g) |g·r⟩` of its least configuration `r`, normalised, unless an + /// element of character −1 fixes `r` and the state vanishes. `None` when + /// `sym` is not a group with characters, when the model is not invariant + /// under it, when a flip would leave the magnetisation sector, or when + /// the sector is empty. + pub fn exact_ground_energy_in(&self, magnetisation: Option, sym: &Symmetry) -> Option { + let n = self.n; + if n == 0 || n > 24 || sym.check().is_err() || !self.commutes_with(sym) { + return None; + } + if magnetisation.is_some() && !self.conserves_magnetisation() { + return None; + } + if magnetisation.is_some_and(|m| m != 0) && sym.flips.iter().any(|&f| f) { + return None; + } + let bit = |i: usize| 1usize << (n - 1 - i); + // g·x: its spin at i is x's at perms[g][i], flipped if g flips. + let image = |g: usize, x: usize| -> usize { + let mut y = 0usize; + for (i, &pi) in sym.perms[g].iter().enumerate() { + if (x & bit(pi) != 0) != sym.flips[g] { + y |= bit(i); + } + } + y + }; + // The least configuration of x's orbit, and the character of an + // element taking x to it. + let least = |x: usize| -> (usize, f64) { + let (mut r, mut chi) = (x, 1.0); + for g in 0..sym.len() { + let y = image(g, x); + if y < r { + r = y; + chi = sym.chars[g]; + } + } + (r, chi) + }; + // Representatives, ascending, each with Σ χ over its stabiliser: + // |G| times that is its state's squared norm, and zero when an + // element of character −1 fixes it. + let mut reps: Vec = Vec::new(); + let mut weight: Vec = Vec::new(); + for x in 0..1usize << n { + if magnetisation.is_some_and(|m| n as i32 - 2 * x.count_ones() as i32 != m) { + continue; + } + let mut stab = 0.0; + let mut least_here = true; + for g in 0..sym.len() { + let y = image(g, x); + if y < x { + least_here = false; + break; + } + if y == x { + stab += sym.chars[g]; + } + } + if least_here && stab > 0.0 { + reps.push(x); + weight.push(stab); + } + } + if reps.is_empty() { + return None; + } + // H on the basis, column by column: H|r⟩ = Σ_x h_xr |x⟩, and the + // state of x's orbit carries it with χ(k) √(S_r′/S_r), x = k·r′. + let mut columns: Vec> = Vec::with_capacity(reps.len()); + for (a, &r) in reps.iter().enumerate() { + let spin = |i: usize| if r & bit(i) == 0 { 1.0 } else { -1.0 }; + let mut diag = 0.0; + for &(i, j, c) in &self.zz { + diag += c * spin(i) * spin(j); + } + for &(i, c) in &self.z { + diag += c * spin(i); + } + let mut col = vec![(a, diag)]; + let mut reach = |x: usize, h: f64| { + let (r2, chi) = least(x); + if let Ok(b) = reps.binary_search(&r2) { + col.push((b, h * chi * (weight[b] / weight[a]).sqrt())); + } + }; + for &(i, j, c) in &self.exchange { + if spin(i) != spin(j) { + reach(r ^ bit(i) ^ bit(j), 2.0 * c); + } + } + for &(i, c) in &self.x { + reach(r ^ bit(i), c); + } + col.sort_by_key(|&(b, _)| b); + let mut merged: Vec<(usize, f64)> = Vec::with_capacity(col.len()); + for (b, v) in col { + match merged.last_mut() { + Some(last) if last.0 == b => last.1 += v, + _ => merged.push((b, v)), + } + } + columns.push(merged); + } + let apply = |inp: &[f64], out: &mut [f64]| { + out.iter_mut().for_each(|o| *o = 0.0); + for (col, &v) in columns.iter().zip(inp) { + for &(b, h) in col { + out[b] += h * v; + } + } + }; + let start: Vec = reps.iter().map(|&x| 1.0 + ((x as u64).wrapping_mul(2_654_435_761) % 1000) as f64 / 1000.0).collect(); + Some(lanczos(&apply, &start, 60, 30, 1e-9, false).0) + } } // --------------------------------------------------------------------------- @@ -677,6 +807,38 @@ impl PointIrrep { } } +/// One of the four one-dimensional representations of a rectangle's +/// symmetries (the half turn, and the reflections in its two axes), by its +/// character on each. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RectIrrep { + /// Even under the half turn and both reflections. + A1, + /// Even under the half turn, odd under both reflections. + A2, + /// Odd under the half turn and the reflection `y → −y`, even under + /// `x → −x`. + B1, + /// Odd under the half turn and the reflection `x → −x`, even under + /// `y → −y`. + B2, +} + +impl RectIrrep { + /// The character of reflecting `x → −x` if `flip_x` and `y → −y` if + /// `flip_y` (both: the half turn). + pub fn character(self, flip_x: bool, flip_y: bool) -> f64 { + let sx = if flip_x { -1.0 } else { 1.0 }; + let sy = if flip_y { -1.0 } else { 1.0 }; + match self { + RectIrrep::A1 => 1.0, + RectIrrep::A2 => sx * sy, + RectIrrep::B1 => sy, + RectIrrep::B2 => sx, + } + } +} + /// A symmetry group of the lattice: site permutations, each possibly /// followed by flipping every spin, each with a real character. The /// projection `Ψ(s) = Σ_g χ(g) ψ(g·s)` picks the sector: a momentum of 0 or @@ -796,6 +958,45 @@ impl Symmetry { Symmetry::new(perms, chars) } + /// The space group of an `lx × ly` torus with its rectangle's symmetries: + /// every translation, each composed with the identity, the reflections + /// `x → −x` and `y → −y` about a site, or both (the half turn), in one of + /// their four one-dimensional representations. Momentum 0 or π along + /// each direction, all of which the reflections leave alone. + pub fn rectangle(lx: usize, ly: usize, kx_pi: bool, ky_pi: bool, irrep: RectIrrep) -> Symmetry { + // At length 2 a reflection moves every site as the identity does. + assert!(lx >= 3 && ly >= 3, "a rectangle's reflections are distinct from length 3"); + assert!(!kx_pi || lx.is_multiple_of(2), "momentum π along an odd length"); + assert!(!ky_pi || ly.is_multiple_of(2), "momentum π along an odd length"); + let mut perms = Vec::new(); + let mut chars = Vec::new(); + for ty in 0..ly { + for tx in 0..lx { + for flip_x in [false, true] { + for flip_y in [false, true] { + perms.push( + (0..lx * ly) + .map(|i| { + let (mut x, mut y) = (i % lx, i / lx); + if flip_x { + x = (lx - x) % lx; + } + if flip_y { + y = (ly - y) % ly; + } + ((y + ty) % ly) * lx + (x + tx) % lx + }) + .collect(), + ); + let odd = (kx_pi && tx % 2 == 1) ^ (ky_pi && ty % 2 == 1); + chars.push(if odd { -1.0 } else { 1.0 } * irrep.character(flip_x, flip_y)); + } + } + } + } + Symmetry::new(perms, chars) + } + /// Whether the elements form a group under composition and every /// character is `±1` and multiplies as the elements do: what makes /// `Σ_g χ(g) ψ(g·s)` a projection onto one sector, not onto a mixture or @@ -2059,6 +2260,18 @@ mod tests { } } } + for (lx, ly) in [(4, 3), (6, 4)] { + for (kx, ky) in [(false, false), (true, false), (false, true), (true, true)] { + if (kx && lx % 2 == 1) || (ky && ly % 2 == 1) { + continue; + } + for irrep in [RectIrrep::A1, RectIrrep::A2, RectIrrep::B1, RectIrrep::B2] { + let sym = Symmetry::rectangle(lx, ly, kx, ky, irrep); + assert_eq!(sym.len(), 4 * lx * ly); + assert_eq!(sym.check(), Ok(()), "{lx}×{ly} {kx} {ky} {irrep:?}"); + } + } + } // Characters that do not multiply, and elements that do not close. let ring = Symmetry::ring(4, false); assert!(Symmetry::new(ring.perms.clone(), vec![1.0, -1.0, 1.0, 1.0]).check().is_err()); @@ -2197,6 +2410,46 @@ mod tests { assert!(!field.commutes_with(&Symmetry::ring(n, false).with_spin_flip(false))); } + #[test] + fn the_orbit_basis_agrees_with_the_projector() { + let close = |a: Option, b: Option, what: &str| match (a, b) { + (Some(x), Some(y)) => assert!((x - y).abs() < 1e-9 * y.abs().max(1.0), "{what}: {x} vs {y}"), + (x, y) => assert_eq!(x.is_some(), y.is_some(), "{what}: {x:?} vs {y:?}"), + }; + // Every sector of a 4 × 3 torus at J₂ = J₁/2: four representations, + // both momenta along x, and no flip, an even one or an odd one. + let (lx, ly, n) = (4, 3, 12); + let model = SpinModel::j1j2(n, &square_edges(lx, ly, true), &square_diagonals(lx, ly, true), 1.0, 0.5); + for kx in [false, true] { + for irrep in [RectIrrep::A1, RectIrrep::A2, RectIrrep::B1, RectIrrep::B2] { + let base = Symmetry::rectangle(lx, ly, kx, false, irrep); + for sym in [base.clone(), base.clone().with_spin_flip(false), base.with_spin_flip(true)] { + close(model.exact_ground_energy_in(Some(0), &sym), model.projected_ground_energy(Some(0), &sym), &format!("4×3 {kx} {irrep:?} {}", sym.len())); + } + } + } + // A 10-site ring, and an Ising ring in a transverse field, where the + // magnetisation is not conserved. + let ring = SpinModel::heisenberg(10, &chain_edges(10, true), 1.0); + let ising = SpinModel::ising(8, &chain_edges(8, true), 1.0, 0.7); + for k in [false, true] { + for odd in [false, true] { + let sym = Symmetry::ring(10, k).with_spin_flip(odd); + close(ring.exact_ground_energy_in(Some(0), &sym), ring.projected_ground_energy(Some(0), &sym), "ring"); + let sym = Symmetry::ring(8, k).with_spin_flip(odd); + close(ising.exact_ground_energy_in(None, &sym), ising.projected_ground_energy(None, &sym), "ising"); + } + } + // The identity alone is no reduction, and the flip alone splits the + // spectrum in two, one half holding the ground state. + let identity = Symmetry::new(vec![(0..n).collect()], vec![1.0]); + let all = model.exact_ground_energy(Some(0)).unwrap(); + close(model.exact_ground_energy_in(Some(0), &identity), Some(all), "identity"); + let even = model.exact_ground_energy_in(Some(0), &identity.clone().with_spin_flip(false)).unwrap(); + let odd = model.exact_ground_energy_in(Some(0), &identity.with_spin_flip(true)).unwrap(); + assert!((even.min(odd) - all).abs() < 1e-9 && even.max(odd) > all + 1e-6, "{even} {odd} {all}"); + } + #[test] fn the_frustrated_square_ground_state_is_fully_symmetric() { // 4 × 4 at J₂ = J₁/2: the ground state is even under every rotation diff --git a/wai/wai-rs/src/bin/wai_quantum.rs b/wai/wai-rs/src/bin/wai_quantum.rs index 3239d1cf5..130c56094 100644 --- a/wai/wai-rs/src/bin/wai_quantum.rs +++ b/wai/wai-rs/src/bin/wai_quantum.rs @@ -174,7 +174,8 @@ fn usage() -> ! { wai-quantum chem --fcidump FILE [--bond D] [--sweeps S] [--penalty P] [--no-fci] [--json]\n\n\ NEURAL QUANTUM STATES (RBM, variational Monte Carlo, stochastic reconfiguration):\n \ wai-quantum nqs [--model heisenberg|j1j2|ising] [--lx L] [--ly L] [--j2 J] [--h H] [--periodic]\n \ - [--complex] [--project [--kpi] [--point-group a1|a2|b1|b2] [--odd]] [--alpha A] [--steps N] [--samples S] [--lr R]\n \ + [--complex] [--project [--kpi] [--point-group a1|a2|b1|b2 | --reflections a1|a2|b1|b2]\n \ + [--odd]] [--alpha A] [--steps N] [--samples S] [--lr R]\n \ [--init W] [--guide ε] [--exact] [--seed S] [--json]\n\n\ OPTIMISATION (simulated bifurcation, annealing, exact):\n \ wai-quantum qio (--gset FILE | --sk N [--instance S]) [--method dsb|bsb|hdsb|hbsb|sa|exact]\n \ @@ -1205,6 +1206,13 @@ fn main() { "b2" => PointIrrep::B2, _ => die("--point-group: a1, a2, b1 or b2"), }); + let reflections = opt(args, "--reflections", "").map(|g| match g.as_str() { + "a1" => RectIrrep::A1, + "a2" => RectIrrep::A2, + "b1" => RectIrrep::B1, + "b2" => RectIrrep::B2, + _ => die("--reflections: a1, a2, b1 or b2"), + }); let symmetry = flag(args, "--project").then(|| { if !periodic { die("--project needs --periodic"); @@ -1213,16 +1221,19 @@ fn main() { if kpi && (lx % 2 == 1 || (ly > 1 && ly % 2 == 1)) { die("--kpi needs even lengths"); } - let base = match point { - Some(_) if lx != ly || lx < 3 => die("--point-group needs a square lattice, --lx = --ly ≥ 3"), - Some(irrep) => Symmetry::square(lx, kpi, irrep), - None if ly == 1 => Symmetry::ring(n, kpi), - None => Symmetry::torus(lx, ly, kpi, kpi), + let base = match (point, reflections) { + (Some(_), Some(_)) => die("--point-group or --reflections, not both"), + (Some(_), None) if lx != ly || lx < 3 => die("--point-group needs a square lattice, --lx = --ly ≥ 3"), + (Some(irrep), None) => Symmetry::square(lx, kpi, irrep), + (None, Some(_)) if lx < 3 || ly < 3 => die("--reflections needs both lengths ≥ 3"), + (None, Some(irrep)) => Symmetry::rectangle(lx, ly, kpi, kpi, irrep), + (None, None) if ly == 1 => Symmetry::ring(n, kpi), + (None, None) => Symmetry::torus(lx, ly, kpi, kpi), }; base.with_spin_flip(flag(args, "--odd")) }); - if point.is_some() && symmetry.is_none() { - die("--point-group needs --project"); + if (point.is_some() || reflections.is_some()) && symmetry.is_none() { + die("--point-group and --reflections need --project"); } // Parameters start uniform in ±init. A projected network that // starts near zero is nearly the projection of a constant, the @@ -1234,7 +1245,7 @@ fn main() { cfg.guide = num(args, "--guide", "", if symmetry.is_some() { 0.1 } else { 0.0 }); // The exact ground energy in the projection's own sector. let in_sector = match &symmetry { - Some(sym) if n <= 20 => model.exact_ground_energy_in(sector, sym), + Some(sym) if n <= 24 => model.exact_ground_energy_in(sector, sym), _ => None, }; fn run(model: SpinModel, rbm: Rbm, cfg: VmcConfig, steps: usize) -> (usize, Estimate, Estimate) { -- 2.54.0 (Apple Git-157) From e7d4380ebab403a9f387bec14fdd1e8f14a49480 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 14:17:42 -0400 Subject: [PATCH 2/3] nqs_ground: a projected network up to 24 spins is held to its sector alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full reference was computed first and then replaced by the sector's; past 20 spins that first step was a DMRG run (136 s on the 6 × 4 torus, and 3 % off the exact energy) whose answer was thrown away. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum/examples/nqs_ground.rs | 41 ++++++++++++-------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/wai/wai-quantum/examples/nqs_ground.rs b/wai/wai-quantum/examples/nqs_ground.rs index 21e8cb1c7..6d23a1b13 100644 --- a/wai/wai-quantum/examples/nqs_ground.rs +++ b/wai/wai-quantum/examples/nqs_ground.rs @@ -69,21 +69,6 @@ fn main() { other => panic!("{other}"), }; let sector = if model.conserves_magnetisation() { Some(0) } else { None }; - let t0 = std::time::Instant::now(); - let (reference, how) = match if n <= 20 { model.exact_ground_energy(sector) } else { None } { - Some(e) => (e, "exact diagonalisation".to_string()), - None => { - let bond = arg(&a, "--dmrg-bond", 128usize); - let r = dmrg_mpo(&model.opsum().mpo(), Mps::random(n, 8, 1), &DmrgConfig { max_bond: bond, sweeps: 6, threads, ..DmrgConfig::default() }); - (r.energy, format!("DMRG, bond {bond}, discarded {:.1e}", r.discarded)) - } - }; - println!("{kind} {lx}×{ly}{}: reference {reference:.8} ({how}, {:.1}s)", if periodic { " periodic" } else { "" }, t0.elapsed().as_secs_f64()); - let alpha: usize = arg(&a, "--alpha", 2); - let mut cfg = VmcConfig { samples: arg(&a, "--samples", 1000), chains: 16, magnetisation: sector, lr: arg(&a, "--lr", 0.02), shift: arg(&a, "--shift", 1e-3), exact: a.iter().any(|x| x == "--exact"), threads, ..VmcConfig::default() }; - let steps: usize = arg(&a, "--steps", 600); - let marshall = a.iter().any(|x| x == "--marshall"); - let exact_final = a.iter().any(|x| x == "--exact-final"); let point = a.iter().position(|x| x == "--point-group").map(|i| match a.get(i + 1).map(String::as_str) { Some("a1") => PointIrrep::A1, Some("a2") => PointIrrep::A2, @@ -113,15 +98,27 @@ fn main() { }; base.with_spin_flip(a.iter().any(|x| x == "--odd")) }); + let t0 = std::time::Instant::now(); // A projected network is held to its own sector's ground energy. - let reference = match &symmetry { - Some(sym) if n <= 24 => { - let e = model.exact_ground_energy_in(sector, sym).expect("the model is invariant under the group, and the sector is not empty"); - println!("in its sector: {e:.8}"); - e - } - _ => reference, + let in_sector = match &symmetry { + Some(sym) if n <= 24 => Some(model.exact_ground_energy_in(sector, sym).expect("the model is invariant under the group, and the sector is not empty")), + _ => None, }; + let (reference, how) = match (in_sector, if n <= 20 && in_sector.is_none() { model.exact_ground_energy(sector) } else { None }) { + (Some(e), _) => (e, "exact diagonalisation in the projection's sector".to_string()), + (None, Some(e)) => (e, "exact diagonalisation".to_string()), + (None, None) => { + let bond = arg(&a, "--dmrg-bond", 128usize); + let r = dmrg_mpo(&model.opsum().mpo(), Mps::random(n, 8, 1), &DmrgConfig { max_bond: bond, sweeps: 6, threads, ..DmrgConfig::default() }); + (r.energy, format!("DMRG, bond {bond}, discarded {:.1e}", r.discarded)) + } + }; + println!("{kind} {lx}×{ly}{}: reference {reference:.8} ({how}, {:.1}s)", if periodic { " periodic" } else { "" }, t0.elapsed().as_secs_f64()); + let alpha: usize = arg(&a, "--alpha", 2); + let mut cfg = VmcConfig { samples: arg(&a, "--samples", 1000), chains: 16, magnetisation: sector, lr: arg(&a, "--lr", 0.02), shift: arg(&a, "--shift", 1e-3), exact: a.iter().any(|x| x == "--exact"), threads, ..VmcConfig::default() }; + let steps: usize = arg(&a, "--steps", 600); + let marshall = a.iter().any(|x| x == "--marshall"); + let exact_final = a.iter().any(|x| x == "--exact-final"); // Parameters start uniform in ±init. A projected network that starts // near zero is nearly the projection of a constant, the ferromagnet: an // eigenstate, which reconfiguration barely moves. -- 2.54.0 (Apple Git-157) From 890ed855c67ffafdeb7a2668f2bf008ec8c60fb5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 19:47:12 -0400 Subject: [PATCH 3/3] wai-quantum 0.3.40: a sector larger than the network, held to its exact answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the periodic 6 × 4 J1-J2 lattice at J2 = J1/2 the orbit referee places the ground state (−12.54059845, equal to the full 24-spin diagonalisation) in the sector even under the half turn and both reflections at momentum zero, with even parity: 15 578 configurations up to symmetry, against a complex α = 1 network's 624 parameters. After 600 steps, energies over every configuration: translations guided 1.06e-3; rectangle group unguided 8.32e-3; rectangle group guided 5.57e-4. On the 6 × 6 open Heisenberg lattice (unprojected, α = 2, 800 steps) the guide doubled the error, so unprojected networks keep sampling |ψ|²; the docs say so. Co-Authored-By: Claude Opus 5.5 --- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum/Cargo.lock | 2 +- wai/wai-quantum/Cargo.toml | 2 +- wai/wai-quantum/src/quantum_nqs.rs | 22 +++++++++++++++++++--- wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 7 files changed, 25 insertions(+), 9 deletions(-) diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 75709e7e7..625ed43de 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index f98d230e3..2cb27fbee 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -589,7 +589,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 9bf4e3356..a656adf20 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -415,7 +415,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 2e7f95322..43805e287 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/src/quantum_nqs.rs b/wai/wai-quantum/src/quantum_nqs.rs index c9a96efd1..b90328150 100644 --- a/wai/wai-quantum/src/quantum_nqs.rs +++ b/wai/wai-quantum/src/quantum_nqs.rs @@ -220,8 +220,9 @@ //! configurations whose probabilities total `2.4·10⁻³`. Guided at `0.1`, //! the same network, start and steps reach the ground energy to //! `1.7·10⁻¹²`. Projected networks in the CLI and `nqs_ground` now sample -//! with a guide of `0.1`. Unprojected ones gain nothing from it and sample -//! `|ψ|²`. +//! with a guide of `0.1`. Unprojected ones gain nothing from it (on the +//! 6 × 6 open Heisenberg lattice, 800 steps at `α = 2`, it doubled the +//! error) and sample `|ψ|²`. //! //! The same trap caught the referee once. Lanczos stopped a restart once its //! lowest Ritz pair had converged, which is safe from DMRG's warm starts. @@ -261,7 +262,22 @@ //! - The sector is small: 107 configurations up to its symmetries, against //! the network's 288 parameters. Reaching its ground state to rounding //! says the training finds what the network can hold. It says nothing of -//! a sector larger than the network. +//! a sector larger than the network, which the next point is. +//! - **A sector larger than the network.** On the periodic 6 × 4 lattice at +//! `J₂ = J₁/2`, exact diagonalisation gives `−12.54059845`, in the sector +//! even under its half turn and both reflections at momentum zero, with +//! even parity: 15 578 configurations up to its symmetries, against a +//! complex `α = 1` network's 624 parameters. After 600 steps of 1000 +//! samples, energies over every configuration: +//! +//! | projection, even parity | guide | relative error | +//! |-------------------------|-------|----------------| +//! | translations | `0.1` | `1.06·10⁻³` | +//! | rectangle group, A₁ | none | `8.32·10⁻³` | +//! | rectangle group, A₁ | `0.1` | `5.57·10⁻⁴` | +//! +//! The guide is worth a factor of fifteen here and the reflections a +//! factor of two. What remains is the network's. //! - **One-dimensional representations only.** Characters are `±1`: momenta //! 0 and π, and the four one-dimensional representations of the square's //! rotations and reflections. The two-dimensional one and every other diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 5b3c2b035..227ce0684 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -3900,7 +3900,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 34a753416..fa1c13c27 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.39" +version = "0.3.40" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157)