From 13af704b2b613fff91c59cd6c7cbb2695a308269 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Fri, 15 May 2026 15:19:51 -0400 Subject: [PATCH 01/23] Fix SpinGlassPEPS integration architecture docs (#35) --- docs/DOCUMENTATION.md | 1 + docs/make.jl | 1 + docs/src/spinglasspeps_integration.md | 227 ++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 docs/src/spinglasspeps_integration.md diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index c033f26..e9a0a7e 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -8,6 +8,7 @@ The documentation is built using [Documenter.jl](https://github.com/JuliaDocs/Do - **Home** (`docs/src/index.md`): Overview, installation, quick start, and features - **Examples** (`docs/src/examples.md`): Practical examples showing how to use TenSolver.jl +- **SpinGlassPEPS Integration** (`docs/src/spinglasspeps_integration.md`): Planned architecture for the optional structured PEPS backend - **API Reference** (`docs/src/api.md`): Complete API documentation with docstrings Internal developer notes live outside the generated user documentation: diff --git a/docs/make.jl b/docs/make.jl index 3d112c4..43980db 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,6 +14,7 @@ makedocs( pages = [ "Home" => "index.md", "Examples" => "examples.md", + "SpinGlassPEPS Integration" => "spinglasspeps_integration.md", "API Reference" => "api.md", ], repo = Documenter.Remotes.GitHub("SECQUOIA", "TenSolver.jl"), diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md new file mode 100644 index 0000000..06acd6f --- /dev/null +++ b/docs/src/spinglasspeps_integration.md @@ -0,0 +1,227 @@ +# SpinGlassPEPS Integration Architecture + +This page records the planned boundary between TenSolver.jl and the +SpinGlassPEPS.jl work described in +[arXiv:2411.16431](https://arxiv.org/abs/2411.16431). It is a design note for +the implementation stack, not a description of behavior available in the +current TenSolver release. + +The central decision is that SpinGlassPEPS should be integrated as an optional +structured-graph backend. It should not replace TenSolver's current +ITensor-based DMRG backend, become a hard runtime dependency, or change the +default behavior of [`minimize`](@ref), [`maximize`](@ref), or the JuMP +optimizer. + +## Current TenSolver Boundary + +TenSolver currently accepts Boolean optimization models: + +- QUBO matrices, with optional linear and constant terms. +- PUBO polynomials through MultivariatePolynomials.jl. +- QUBODrivers/JuMP models that are converted to QUBO data before solving. + +The current solver path tensorizes the objective into an ITensors.jl MPO over +Boolean variables represented as two-dimensional qudit sites. It then applies +DMRG through ITensorMPS.jl and returns: + +- the best sampled objective value; and +- a [`Solution`](@ref), which wraps the MPS and per-iteration convergence + traces. + +This backend is general with respect to the variable ordering and QUBO/PUBO +interaction pattern. It does not require a graph layout such as a grid, +Pegasus, or Zephyr topology. + +## Proposed PEPS Backend Boundary + +SpinGlassPEPS.jl targets a different solver boundary. The package itself is an +umbrella package that reexports component packages including: + +- SpinGlassNetworks.jl for Ising graph construction and lattice/cluster rules. +- SpinGlassEngine.jl for Potts Hamiltonians, branch-and-bound search, low-energy + spectra, and droplet reconstruction. +- SpinGlassTensors.jl for PEPS construction and boundary-MPS contraction. +- SpinGlassExhaustive.jl for exhaustive utilities. + +The algorithm builds a finite-temperature PEPS representation of a Boltzmann +distribution for a clustered Ising/Potts Hamiltonian. It uses approximate +boundary-MPS contractions to estimate conditional probabilities and a +branch-and-bound sweep to retain the most probable low-energy configurations. +It is designed for quasi-two-dimensional graph layouts with useful locality, +including square/king-grid style layouts and, at larger scale, Pegasus and +Zephyr processor topologies. + +That shape makes it complementary to TenSolver's current DMRG backend rather +than a drop-in replacement. The PEPS path should be selected only when the +caller can provide, or TenSolver can infer, a supported structured layout. + +## Supported Problem Classes + +The initial backend split should be: + +- `DMRG`: the default backend for general QUBO/PUBO inputs and arbitrary + variable orderings. +- `PEPS`: an optional backend for structured Ising/Potts graph inputs that can + be embedded into a supported quasi-two-dimensional layout. + +The first PEPS implementation should start with small square-grid and king-grid +layouts. Those are easier to validate, have smaller clusters, and exercise the +same data-model boundary without immediately requiring the memory footprint and +GPU-sensitive execution path needed for large Pegasus or Zephyr instances. + +Pegasus and Zephyr should be treated as later targets. The paper and +SpinGlassPEPS documentation describe large unit cells, up to 24 spins for +Pegasus and 16 spins for Zephyr, and emphasize that sparse structures, local +dimensional reduction, contraction settings, and GPU execution can become +important. TenSolver should not expose those as a default path before the bridge +has small-layout correctness tests and clear user-facing configuration. + +Dense arbitrary QUBOs should remain on the current DMRG path unless an explicit +embedding or layout mapping is provided. PEPS should not be advertised as a +universal improvement for all QUBOs. + +## Dependency Policy + +SpinGlassPEPS should not be added as a hard dependency in the first +implementation steps. + +There are three reasons for this: + +- TenSolver currently supports Julia 1.10, while SpinGlassPEPS 1.5.0 declares + Julia 1.11 compatibility. +- The SpinGlassPEPS component stack brings a larger numerical dependency + footprint, including packages used for tensor operations, truncated + decompositions, CPU/GPU execution, and documentation/examples. +- Most TenSolver users who call the existing DMRG backend should not pay load + time, installation, or GPU compatibility costs for an optional structured + backend. + +The preferred integration shape is a Julia package extension or a small bridge +package that is loaded only when the SpinGlassPEPS component packages are +available. The core TenSolver package should define the stable data and result +interfaces that the bridge implements. The bridge should depend on the +SpinGlassPEPS components directly when that is cleaner than depending on the +umbrella reexport package. + +## Data Model Boundary + +TenSolver's public inputs are Boolean optimization models. The PEPS solver +expects structured spin-glass data. The bridge must therefore make the +conversion explicit. + +The expected data flow is: + +1. Normalize TenSolver input to a Boolean QUBO representation. +2. Convert Boolean variables `x in {0, 1}` to Ising spins `s in {-1, +1}` using + one documented convention. +3. Build an Ising graph with local fields and pair couplings. +4. Attach a supported layout or cluster assignment rule. +5. Build the corresponding Potts Hamiltonian and PEPS network. +6. Run boundary-MPS contraction plus branch-and-bound search. +7. Decode returned Potts/Ising states back to Boolean vectors. +8. Adapt results to TenSolver and QUBOTools result types. + +The QUBO-to-Ising conversion is important enough to land as its own PR before +the backend bridge. It should include exact round-trip and energy-preservation +tests for small instances. The implementation should make sign and constant +offset conventions visible because mistakes there can silently return plausible +but wrong energies. + +Layout metadata should also be explicit. A PEPS backend call should know whether +the variables are in row-major square-grid order, king-grid order, or a later +Pegasus/Zephyr indexing convention. TenSolver should not guess a topology from a +dense QUBO matrix unless a future API defines that inference clearly. + +## Result Model Boundary + +The current TenSolver result is intentionally compact: `energy, Solution` from +the direct API and a QUBOTools `SampleSet` from the optimizer API. + +SpinGlassPEPS can produce richer output, including ranked energies, states, +probabilities, largest discarded probability during branch-and-bound, lattice +transformation information, retained singular-value diagnostics, and droplet +metadata. + +The bridge should preserve this information without leaking SpinGlassPEPS +internal types as the default public result. A practical result boundary is: + +- Direct TenSolver calls return the same high-level shape as today for ordinary + users. +- PEPS-specific metadata is stored in a backend metadata object or result field + that can be inspected by advanced users. +- QUBODrivers/JuMP calls continue to return a QUBOTools `SampleSet`, with PEPS + metadata stored under namespaced metadata keys. + +The first bridge should preserve at least: + +- ranked Boolean states and their objective values; +- PEPS probabilities when available; +- largest discarded probability when available; +- chosen layout and lattice transformation; +- branch-and-bound and contraction parameters; and +- droplet metadata when requested. + +## Backend Selection + +The default backend must remain the current DMRG implementation. + +Later PRs should add a small backend-selection interface without forcing users +to learn the SpinGlassPEPS API. The intended behavior is: + +- no backend argument means current DMRG behavior; +- `backend = :dmrg` selects the current path explicitly; +- `backend = :peps` selects the optional structured backend and errors clearly + if the bridge package or extension is not loaded; and +- QUBODrivers/JuMP receives equivalent raw optimizer attributes for backend and + PEPS parameters. + +Any PEPS selection API must validate that the problem includes enough topology +metadata for the structured backend. If the topology is missing or unsupported, +the error should explain whether to provide a layout, use the DMRG backend, or +install/load the optional PEPS bridge. + +## Stacked PR Plan + +The integration should be implemented as a sequence of stacked PRs: + +1. Add this design document and link it from the documentation navigation. +2. Add QUBO/Ising conversion utilities with exact energy-preservation tests. +3. Introduce a backend interface while keeping the current DMRG backend as the + default implementation. +4. Add an optional SpinGlassPEPS-backed structured solver path for direct + structured inputs. +5. Expose the PEPS backend through QUBODrivers/JuMP attributes, including + layout and contraction/search parameters. +6. Add user documentation, examples, and benchmark scripts that compare the DMRG + and PEPS paths on appropriate problem families. + +Each PR should remain useful on its own. The early PRs should avoid new runtime +dependencies and should preserve the current public behavior exactly. + +## Non-Goals + +This integration should not: + +- vendor SpinGlassPEPS internals into TenSolver; +- replace the existing `minimize` or `maximize` behavior; +- require GPU packages for installing or loading TenSolver; +- promise that PEPS is better for arbitrary dense QUBOs; +- infer complex hardware layouts without explicit metadata; or +- hide approximate-contraction limitations behind a generic solver name. + +## Risks + +The main technical risks are: + +- Boolean-to-spin convention mistakes, especially sign and constant-offset + errors. +- Topology mismatch between TenSolver variable order and the PEPS layout. +- Julia-version compatibility between TenSolver and SpinGlassPEPS. +- Increased dependency and load-time footprint if the bridge is not optional. +- Approximate contraction instability at high inverse temperature. +- Overstating sampling diversity from a branch-and-bound method that returns a + limited retained state set. + +The implementation stack should address these risks through exact small-instance +conversion tests, explicit topology metadata, optional dependency loading, and +documentation that describes the PEPS backend as a structured heuristic solver. From 43c748d124d71ece8a291feab768eebb97cfafa8 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Fri, 15 May 2026 17:03:19 -0400 Subject: [PATCH 02/23] Fix QUBO-Ising conversion utilities (#36) --- docs/src/spinglasspeps_integration.md | 8 +++++++ src/TenSolver.jl | 3 +++ src/ising.jl | 30 ++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index 06acd6f..291b721 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -127,6 +127,14 @@ tests for small instances. The implementation should make sign and constant offset conventions visible because mistakes there can silently return plausible but wrong energies. +TenSolver uses the Boolean/spin convention `x_i = (s_i + 1) / 2` and +`s_i = 2x_i - 1`. The conversion utilities preserve TenSolver's current QUBO +truth objective, `dot(x, Q, x) + dot(l, x) + c`; for non-symmetric matrices the +effective pair coefficient is therefore `Q[i, j] + Q[j, i]`. The Ising model +stores one coupling per unordered pair plus an explicit constant offset, so +`ising_energy(qubo_to_ising(Q, l, c), bool_to_spin(x))` matches the Boolean +objective for every bitstring `x`. + Layout metadata should also be explicit. A PEPS backend call should know whether the variables are in row-major square-grid order, king-grid order, or a later Pegasus/Zephyr indexing convention. TenSolver should not guess a topology from a diff --git a/src/TenSolver.jl b/src/TenSolver.jl index db99682..400c660 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -22,6 +22,9 @@ include("projection_mpo.jl") include("solution.jl") export sample +include("ising.jl") +export IsingModel, bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo, ising_energy + include("solver.jl") export minimize, maximize export DMRGBackend diff --git a/src/ising.jl b/src/ising.jl index 3c9cab6..3222e9c 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -1,4 +1,4 @@ -import SparseArrays: dropzeros!, sparse +import SparseArrays: SparseMatrixCSC, findnz, sparse, dropzeros! function conversion_type(Q::AbstractMatrix, l, c) T = promote_type(eltype(Q), isnothing(l) ? eltype(Q) : eltype(l), typeof(c)) @@ -166,3 +166,31 @@ end function ising_to_qubo(J::AbstractMatrix, h::AbstractVector, offset::Real=0) return ising_to_qubo(ising_form(J, h, offset)) end + + +""" + ising_energy(J, h, c, s) + +Evaluate an Ising Model at a spin vector `s_i in {-1, +1}`. +""" + +function ising_energy(J::AbstractMatrix, h::AbstractVector, s::AbstractVector, offset::Real=0) + check_ising_dimensions(J, h) + length(s) == length(h) || throw(DimensionMismatch("Spin vector length must match the Ising model size. Encountered length $(length(s)) and field length $(length(h)).")) + + s = checked_spin_state(s) + T = promote_type(eltype(J), eltype(h), typeof(offset), eltype(s)) + + energy = T(offset) + sum(T(h[i]) * T(s[i]) for i in eachindex(h)) + + for j in 2:length(h) + for i in 1:(j - 1) + coupling = T(J[i, j]) + T(J[j, i]) + if !iszero(coupling) + energy += coupling * T(s[i]) * T(s[j]) + end + end + end + + return energy +end From cfa2ac89d412618c009b6d34007329077a229c0b Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sat, 16 May 2026 11:04:25 -0400 Subject: [PATCH 03/23] Fix IsingModel coupling canonicalization (#42) --- src/ising.jl | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ising.jl b/src/ising.jl index 3222e9c..0fc8ce4 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -1,4 +1,33 @@ -import SparseArrays: SparseMatrixCSC, findnz, sparse, dropzeros! +import SparseArrays: findnz, sparse, dropzeros! + +function canonical_ising_couplings(J::AbstractMatrix, ::Type{T}) where {T} + couplings = Dict{Tuple{Int, Int}, T}() + rows, cols, vals = findnz(sparse(T.(J))) + + for k in eachindex(vals) + i = rows[k] + j = cols[k] + i == j && continue + + a, b = minmax(i, j) + key = (a, b) + couplings[key] = get(couplings, key, zero(T)) + vals[k] + end + + out_rows = Int[] + out_cols = Int[] + out_vals = T[] + for (i, j) in sort!(collect(keys(couplings))) + coupling = couplings[(i, j)] + if !iszero(coupling) + push!(out_rows, i) + push!(out_cols, j) + push!(out_vals, coupling) + end + end + + return sparse(out_rows, out_cols, out_vals, size(J, 1), size(J, 2)) +end function conversion_type(Q::AbstractMatrix, l, c) T = promote_type(eltype(Q), isnothing(l) ? eltype(Q) : eltype(l), typeof(c)) From b1d56049aa8ead5c46bc002edd0c3f9d02382c3d Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Fri, 15 May 2026 17:29:04 -0400 Subject: [PATCH 04/23] Fix solver backend interface (#37) --- docs/src/spinglasspeps_integration.md | 15 +++++++++------ src/TenSolver.jl | 2 +- test/backend.jl | 1 + test/runtests.jl | 1 + 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index 291b721..353ba9d 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -173,15 +173,18 @@ The first bridge should preserve at least: The default backend must remain the current DMRG implementation. -Later PRs should add a small backend-selection interface without forcing users -to learn the SpinGlassPEPS API. The intended behavior is: +TenSolver exposes a small backend-selection interface without forcing users to +learn the SpinGlassPEPS API. The current behavior is: - no backend argument means current DMRG behavior; - `backend = :dmrg` selects the current path explicitly; -- `backend = :peps` selects the optional structured backend and errors clearly - if the bridge package or extension is not loaded; and -- QUBODrivers/JuMP receives equivalent raw optimizer attributes for backend and - PEPS parameters. +- `backend = DMRGBackend()` selects the current path explicitly through the + backend-object interface; and +- `backend = :peps` errors clearly until a later optional bridge package or + extension provides the structured backend. + +Later PRs should add QUBODrivers/JuMP raw optimizer attributes for backend and +PEPS parameters. Any PEPS selection API must validate that the problem includes enough topology metadata for the structured backend. If the topology is missing or unsupported, diff --git a/src/TenSolver.jl b/src/TenSolver.jl index 400c660..e854687 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -27,7 +27,7 @@ export IsingModel, bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo, isi include("solver.jl") export minimize, maximize -export DMRGBackend +export AbstractTenSolverBackend, DMRGBackend # Convergence logging include("log.jl") diff --git a/test/backend.jl b/test/backend.jl index 1fa7a4d..1172490 100644 --- a/test/backend.jl +++ b/test/backend.jl @@ -1,3 +1,4 @@ +<<<<<<< HEAD struct TestSymbolBackend <: TenSolver.AbstractTenSolverBackend end struct MissingMethodBackend <: TenSolver.AbstractTenSolverBackend end diff --git a/test/runtests.jl b/test/runtests.jl index 7d34b60..3b374df 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -20,6 +20,7 @@ include(filepath("backend.jl")) # Ising <-> QUBO utilities include(filepath("ising_conversion.jl")) +include(filepath("backend.jl")) # Binary constraint API include(filepath("constraints.jl")) From 6763bac1359a25213dea2e6b78628ce638b80fb8 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sat, 16 May 2026 11:55:17 -0400 Subject: [PATCH 05/23] Fix symbolic backend extension dispatch (#43) --- test/backend.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/test/backend.jl b/test/backend.jl index 1172490..1fa7a4d 100644 --- a/test/backend.jl +++ b/test/backend.jl @@ -1,4 +1,3 @@ -<<<<<<< HEAD struct TestSymbolBackend <: TenSolver.AbstractTenSolverBackend end struct MissingMethodBackend <: TenSolver.AbstractTenSolverBackend end From f5bde73558f9088374089c951533fee81839c073 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Fri, 15 May 2026 18:13:38 -0400 Subject: [PATCH 06/23] Add optional SpinGlassPEPS backend (#38) --- Project.toml | 11 ++ docs/src/api.md | 12 ++ docs/src/spinglasspeps_integration.md | 31 +++- ext/TenSolverSpinGlassPEPSExt.jl | 244 ++++++++++++++++++++++++++ src/TenSolver.jl | 5 +- src/solution.jl | 47 ++++- src/solver.jl | 160 +++++++++++++++++ test/peps_backend.jl | 106 +++++++++++ test/runtests.jl | 1 + 9 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 ext/TenSolverSpinGlassPEPSExt.jl create mode 100644 test/peps_backend.jl diff --git a/Project.toml b/Project.toml index ad9af5b..bcbb28c 100644 --- a/Project.toml +++ b/Project.toml @@ -14,6 +14,14 @@ QUBODrivers = "a3f166f7-2cd3-47b6-9e1e-6fbfe0449eb0" QUBOTools = "60eb5b62-0a39-4ddc-84c5-97d2adff9319" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +[weakdeps] +SpinGlassEngine = "0563570f-ea1b-4080-8a64-041ac6565a4e" +SpinGlassNetworks = "b7f6bd3e-55dc-4da6-96a9-ef9dbec6ac19" +SpinGlassTensors = "7584fc6a-5a23-4eeb-8277-827aab0146ea" + +[extensions] +TenSolverSpinGlassPEPSExt = ["SpinGlassEngine", "SpinGlassNetworks", "SpinGlassTensors"] + [compat] Combinatorics = "1" ITensorMPS = "0.3, 0.4" @@ -24,4 +32,7 @@ Printf = "1" QUBODrivers = "0.6.1" QUBOTools = "0.13, 0.14, 0.15, 0.16" SparseArrays = "1.10" +SpinGlassEngine = "1.6" +SpinGlassNetworks = "1.4" +SpinGlassTensors = "1.3" julia = "1.10" diff --git a/docs/src/api.md b/docs/src/api.md index 12bfb17..e96dc9d 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -7,6 +7,7 @@ This page documents the public API of TenSolver.jl. ```@docs TenSolver.minimize TenSolver.maximize +TenSolver.solve_ising ``` ## Solver Backends @@ -15,12 +16,14 @@ TenSolver.maximize TenSolver.AbstractTenSolverBackend TenSolver.DMRGBackend TenSolver.normalize_backend +TenSolver.PEPSBackend ``` ## Solution ```@docs TenSolver.Solution +TenSolver.PEPSSolution ``` ## Sampling Functions @@ -54,6 +57,7 @@ TenSolver.constraint_sites ```@docs Base.in(::AbstractVector, ::TenSolver.Solution) +Base.in(::AbstractVector, ::TenSolver.PEPSSolution) ``` ## Internal Functions @@ -89,6 +93,14 @@ TenSolver.project_hamiltonian TenSolver.project_state ``` +### PEPS Backend + + +```@docs +TenSolver.SquareGrid +TenSolver.KingGrid +``` + ## Index ```@index diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index 353ba9d..967153a 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -179,9 +179,34 @@ learn the SpinGlassPEPS API. The current behavior is: - no backend argument means current DMRG behavior; - `backend = :dmrg` selects the current path explicitly; - `backend = DMRGBackend()` selects the current path explicitly through the - backend-object interface; and -- `backend = :peps` errors clearly until a later optional bridge package or - extension provides the structured backend. + backend-object interface; +- `backend = PEPSBackend(SquareGrid(m, n); ...)` or + `backend = PEPSBackend(KingGrid(m, n); ...)` selects the optional structured + backend when `SpinGlassNetworks`, `SpinGlassEngine`, and `SpinGlassTensors` + are installed and loaded; and +- a `PEPSBackend` errors clearly when the optional component packages are not + available. + +The direct PEPS path is implemented as a Julia package extension. The core +package defines the stable backend, topology, `solve_ising`, and +`PEPSSolution` interfaces, while `TenSolverSpinGlassPEPSExt` owns the +SpinGlass component imports and calls. This keeps ordinary TenSolver installs +on the existing dependency footprint. + +The extension is intentionally experimental while the upstream dependency stack +settles. In local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, +and SpinGlassTensors 1.3, the current registered component compat bounds do not +resolve with TenSolver's ITensors/QUBOTools environment. The source bridge and +gated tests are kept in this stack step so the TenSolver boundary is concrete, +but CI does not require the SpinGlass component stack. + +The initial supported structured topologies are one-spin-per-site and +multi-spin-per-site square/king grids through `SquareGrid(m, n[, t])` and +`KingGrid(m, n[, t])`. QUBO inputs are converted through [`qubo_to_ising`](@ref) +before the PEPS extension builds a SpinGlassNetworks Ising graph, clusters it +with `super_square_lattice`, constructs the Potts Hamiltonian, runs +`MpsContractor` plus `low_energy_spectrum`, and decodes retained states back to +TenSolver Boolean vectors. Later PRs should add QUBODrivers/JuMP raw optimizer attributes for backend and PEPS parameters. diff --git a/ext/TenSolverSpinGlassPEPSExt.jl b/ext/TenSolverSpinGlassPEPSExt.jl new file mode 100644 index 0000000..907688c --- /dev/null +++ b/ext/TenSolverSpinGlassPEPSExt.jl @@ -0,0 +1,244 @@ +module TenSolverSpinGlassPEPSExt + +import SparseArrays: findnz + +import TenSolver +import TenSolver: + IsingModel, + KingGrid, + PEPSBackend, + PEPSSolution, + SquareGrid, + ising_energy, + spin_to_bool + +import SpinGlassEngine +import SpinGlassNetworks +import SpinGlassTensors + +function _peps_type(::IsingModel{T}) where {T} + return typeof(float(one(T))) +end + +function _ising_instance(model::IsingModel{T}) where {T} + S = _peps_type(model) + instance = Dict{Tuple{Int, Int}, S}() + + for i in eachindex(model.h) + instance[(i, i)] = S(model.h[i]) + end + + rows, cols, vals = findnz(model.J) + for k in eachindex(vals) + i = rows[k] + j = cols[k] + if i < j && !iszero(vals[k]) + instance[(i, j)] = get(instance, (i, j), zero(S)) + S(vals[k]) + end + end + + return instance +end + +function _check_topology_size(topology, model::IsingModel) + expected = TenSolver._topology_size(topology) + actual = length(model.h) + actual == expected || + throw(DimensionMismatch("PEPS topology $(repr(topology)) expects $expected spins, but the Ising model has $actual spins.")) +end + +_edge_supported(::SquareGrid, a::Tuple, b::Tuple) = abs(a[1] - b[1]) + abs(a[2] - b[2]) == 1 +_edge_supported(::KingGrid, a::Tuple, b::Tuple) = maximum(abs.(a .- b)) == 1 + +function _check_layout_edges(topology, model::IsingModel, lattice) + rows, cols, vals = findnz(model.J) + for k in eachindex(vals) + i = rows[k] + j = cols[k] + if i < j && !iszero(vals[k]) + ci = lattice[i] + cj = lattice[j] + if ci != cj && !_edge_supported(topology, ci, cj) + throw(ArgumentError( + "Ising coupling ($i, $j) is not compatible with $(repr(topology)). " * + "Use a compatible structured topology or backend = :dmrg." + )) + end + end + end +end + +function _potts_hamiltonian(backend::PEPSBackend, ig, lattice) + if isnothing(backend.local_dimension) + return SpinGlassNetworks.potts_hamiltonian( + ig; + spectrum = SpinGlassNetworks.full_spectrum, + cluster_assignment_rule = lattice, + ) + end + + return SpinGlassNetworks.potts_hamiltonian( + ig, + backend.local_dimension; + spectrum = SpinGlassNetworks.full_spectrum, + cluster_assignment_rule = lattice, + ) +end + +function _transformations(transformations) + transformations === :all && return SpinGlassEngine.all_lattice_transformations + transformations === :identity && return (SpinGlassEngine.rotation(0),) + if transformations isa Symbol + throw(ArgumentError("Unsupported PEPS transformations $(repr(transformations)). Use :all, :identity, a transformation, or a collection of transformations.")) + end + transformations isa Tuple && return transformations + transformations isa AbstractVector && return Tuple(transformations) + return (transformations,) +end + +function _strategy(backend::PEPSBackend) + backend.contraction in (:auto, :svd, :svd_truncate) && return SpinGlassEngine.SVDTruncate + backend.contraction === :zipper && return SpinGlassEngine.Zipper + throw(ArgumentError("Unsupported PEPS contraction $(repr(backend.contraction)).")) +end + +function _network(topology::SquareGrid, potts_h, transform, ::Type{T}) where {T} + return SpinGlassEngine.PEPSNetwork{ + SpinGlassEngine.SquareSingleNode{SpinGlassEngine.GaugesEnergy}, + SpinGlassEngine.Dense, + T, + }(topology.m, topology.n, potts_h, transform) +end + +function _network(topology::KingGrid, potts_h, transform, ::Type{T}) where {T} + return SpinGlassEngine.PEPSNetwork{ + SpinGlassEngine.KingSingleNode{SpinGlassEngine.GaugesEnergy}, + SpinGlassEngine.Dense, + T, + }(topology.m, topology.n, potts_h, transform) +end + +function _decoded_records(model::IsingModel, potts_h, sol, transform) + records = NamedTuple[] + for i in eachindex(sol.states) + decoded = SpinGlassNetworks.decode_potts_hamiltonian_state(potts_h, sol.states[i]) + spins = [decoded[j] for j in eachindex(model.h)] + state = spin_to_bool(spins) + push!(records, (; + state, + spins, + energy = ising_energy(model, spins), + probability = sol.probabilities[i], + transformation = transform, + raw_energy = sol.energies[i], + )) + end + return records +end + +function _deduplicated_records(records) + sort!(records; by = r -> (r.energy, -r.probability)) + + deduped = NamedTuple[] + seen = Set{Any}() + for record in records + key = Tuple(record.state) + if !(key in seen) + push!(deduped, record) + push!(seen, key) + end + end + + return deduped +end + +function _metadata(backend::PEPSBackend, records, raw_results) + best = first(records) + raw = raw_results[best.transformation] + return Dict{String, Any}( + "backend" => "SpinGlassPEPS", + "topology" => TenSolver._topology_name(backend.topology), + "topology_size" => TenSolver._topology_tuple(backend.topology), + "beta" => backend.beta, + "bond_dim" => backend.bond_dim, + "max_states" => backend.max_states, + "cutoff_prob" => backend.cutoff_prob, + "onGPU" => backend.onGPU, + "contraction" => String(backend.contraction), + "num_sweeps" => backend.num_sweeps, + "graduate_truncation" => backend.graduate_truncation, + "local_dimension" => backend.local_dimension, + "transformations_tried" => collect(string.(keys(raw_results))), + "selected_transformation" => string(best.transformation), + "spin_glass_energies" => collect(raw.solution.energies), + "spin_glass_probabilities" => collect(raw.solution.probabilities), + "largest_discarded_probability" => raw.solution.largest_discarded_probability, + ) +end + +function TenSolver._solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = nothing, verbosity = 1, kwargs...) + if !isempty(kwargs) + names = join(string.(keys(kwargs)), ", ") + throw(ArgumentError("Unsupported PEPS backend keyword(s): $names. Configure PEPSBackend instead.")) + end + + _check_topology_size(backend.topology, model) + + S = _peps_type(model) + instance = _ising_instance(model) + ig = SpinGlassNetworks.ising_graph(S, instance) + lattice = SpinGlassNetworks.super_square_lattice(TenSolver._topology_tuple(backend.topology)) + _check_layout_edges(backend.topology, model, lattice) + potts_h = _potts_hamiltonian(backend, ig, lattice) + params = SpinGlassEngine.MpsParameters{S}(; + bond_dim = backend.bond_dim, + num_sweeps = backend.num_sweeps, + ) + search_params = SpinGlassEngine.SearchParameters(; + max_states = backend.max_states, + cutoff_prob = backend.cutoff_prob, + ) + strategy = _strategy(backend) + + records = NamedTuple[] + raw_results = Dict{Any, Any}() + for transform in _transformations(backend.transformations) + try + net = _network(backend.topology, potts_h, transform, S) + ctr = SpinGlassEngine.MpsContractor( + strategy, + net, + params; + onGPU = backend.onGPU, + beta = S(backend.beta), + graduate_truncation = backend.graduate_truncation, + ) + merge_strategy = SpinGlassEngine.merge_branches(ctr; merge_prob = :none) + sol, info = SpinGlassEngine.low_energy_spectrum( + ctr, + search_params, + merge_strategy; + no_cache = backend.no_cache, + ) + + raw_results[transform] = (; solution = sol, info) + append!(records, _decoded_records(model, potts_h, sol, transform)) + finally + SpinGlassEngine.clear_memoize_cache() + end + end + + isempty(records) && throw(ArgumentError("SpinGlassPEPS did not return any states.")) + records = _deduplicated_records(records) + states = [record.state for record in records] + energies = S[record.energy for record in records] + probabilities = S[record.probability for record in records] + metadata = _metadata(backend, records, raw_results) + raw = (; results = raw_results) + + verbosity > 0 && @info "SpinGlassPEPS backend finished" energy = first(energies) states = length(states) + + return first(energies), PEPSSolution{S}(states, energies, probabilities, metadata, raw) +end + +end # module diff --git a/src/TenSolver.jl b/src/TenSolver.jl index e854687..cabc830 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -21,13 +21,14 @@ include("projection_mpo.jl") include("solution.jl") export sample +export Solution, PEPSSolution include("ising.jl") export IsingModel, bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo, ising_energy include("solver.jl") -export minimize, maximize -export AbstractTenSolverBackend, DMRGBackend +export minimize, maximize, solve_ising +export AbstractTenSolverBackend, DMRGBackend, PEPSBackend, SquareGrid, KingGrid # Convergence logging include("log.jl") diff --git a/src/solution.jl b/src/solution.jl index 9d46d61..a163c65 100644 --- a/src/solution.jl +++ b/src/solution.jl @@ -72,6 +72,23 @@ function original_order(bs, permutation) return x end +""" + PEPSSolution + +Result returned by the optional SpinGlassPEPS backend. + +The states are decoded to TenSolver Boolean vectors, and `energies` are +objective values in the original TenSolver convention. Backend-specific +diagnostics live in `metadata` and the raw extension result is stored in `raw`. +""" +struct PEPSSolution{T <: Real} + states :: Vector{Vector{Int}} + energies :: Vector{T} + probabilities :: Vector{T} + metadata :: Dict{String, Any} + raw :: Any +end + # Sample from |ψ> in the {0, 1} world instead of 1-based Julia index world. """ sample(psi) @@ -92,6 +109,14 @@ end sample(psi::Solution, n :: Integer) = [sample(psi) for _ in 1:n] +function sample(psi::PEPSSolution) + isempty(psi.states) && throw(ArgumentError("Cannot sample an empty PEPS solution.")) + idx = isempty(psi.probabilities) ? firstindex(psi.states) : argmax(psi.probabilities) + return copy(psi.states[idx]) +end + +sample(psi::PEPSSolution, n :: Integer) = [sample(psi) for _ in 1:n] + """ in(xs, psi::Solution [; cutoff) @@ -99,7 +124,16 @@ Whether the vector `xs` has a positive probability of being sampleable from `psi When setting `cutoff`, it will be used as the minimum probability considered positive. Always `false` for infeasible solutions. """ -function Base.in(bs, psi::Solution; cutoff = 1e-8) +function Base.in(bs::AbstractVector, psi::Solution; cutoff = 1e-8) + return prob(psi, bs) > cutoff +end + +""" + in(xs, psi::PEPSSolution [; cutoff]) + +Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. +""" +function Base.in(bs::AbstractVector, psi::PEPSSolution; cutoff = 1e-8) return prob(psi, bs) > cutoff end @@ -107,6 +141,17 @@ function prob(psi::Solution{T}, bs) where {T} return is_feasible(psi) ? abs2(coeff(psi, bs)) : zero(T) end +function prob(psi::PEPSSolution{T}, bs) where {T} + target = collect(Int, bs) + p = zero(T) + for (state, probability) in zip(psi.states, psi.probabilities) + if state == target + p += probability + end + end + return p +end + function coeff(psi::Solution, bs) tn = psi.tensor sites = siteinds(tn) diff --git a/src/solver.jl b/src/solver.jl index 3b4f0ef..5b667e7 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -58,6 +58,135 @@ normalize_backend(backend) = throw(backend_error(backend)) include("backends/dmrg.jl") const default_backend = DMRGBackend() +abstract type AbstractStructuredTopology end + +""" + SquareGrid(m, n[, spins_per_site]) + +Structured square-grid topology for optional PEPS solves. + +Variables are assumed to be ordered according to SpinGlassNetworks' +`super_square_lattice((m, n, spins_per_site))` convention. +""" +struct SquareGrid <: AbstractStructuredTopology + m :: Int + n :: Int + spins_per_site :: Int + + function SquareGrid(m::Integer, n::Integer, spins_per_site::Integer=1) + m > 0 || throw(ArgumentError("SquareGrid requires m > 0. Got $m.")) + n > 0 || throw(ArgumentError("SquareGrid requires n > 0. Got $n.")) + spins_per_site > 0 || throw(ArgumentError("SquareGrid requires spins_per_site > 0. Got $spins_per_site.")) + return new(Int(m), Int(n), Int(spins_per_site)) + end +end + +""" + KingGrid(m, n[, spins_per_site]) + +Structured king-grid topology for optional PEPS solves. It uses the same +variable ordering as [`SquareGrid`](@ref), but the PEPS compatibility graph +also allows diagonal interactions between neighboring grid cells. +""" +struct KingGrid <: AbstractStructuredTopology + m :: Int + n :: Int + spins_per_site :: Int + + function KingGrid(m::Integer, n::Integer, spins_per_site::Integer=1) + m > 0 || throw(ArgumentError("KingGrid requires m > 0. Got $m.")) + n > 0 || throw(ArgumentError("KingGrid requires n > 0. Got $n.")) + spins_per_site > 0 || throw(ArgumentError("KingGrid requires spins_per_site > 0. Got $spins_per_site.")) + return new(Int(m), Int(n), Int(spins_per_site)) + end +end + +_topology_size(topology::AbstractStructuredTopology) = topology.m * topology.n * topology.spins_per_site +_topology_tuple(topology::AbstractStructuredTopology) = (topology.m, topology.n, topology.spins_per_site) +_topology_name(::SquareGrid) = "square" +_topology_name(::KingGrid) = "king" + +""" + PEPSBackend(topology; kwargs...) + +Select the optional SpinGlassPEPS structured backend. + +The backend is implemented by the `TenSolverSpinGlassPEPSExt` package extension, +which loads only when `SpinGlassNetworks`, `SpinGlassEngine`, and +`SpinGlassTensors` are available. Without those packages this backend errors +clearly and the default DMRG backend remains unchanged. + +The extension is experimental because the current registered SpinGlass +component dependency stack may not resolve in the same environment as +TenSolver's ITensors/QUBOTools stack. + +Supported core options are: + +- `topology`: a [`SquareGrid`](@ref) or [`KingGrid`](@ref). +- `beta`: inverse temperature used by the PEPS contractor. +- `bond_dim`: boundary MPS bond dimension. +- `max_states`: branch-and-bound state width. +- `cutoff_prob`: branch-and-bound probability cutoff. +- `onGPU`: whether SpinGlassPEPS should use GPU execution. +- `contraction`: `:auto`, `:svd`, `:svd_truncate`, or `:zipper`. +- `transformations`: `:all`, `:identity`, a single transformation, or a + collection of SpinGlassEngine lattice transformations. +""" +struct PEPSBackend{T <: AbstractStructuredTopology, S} <: AbstractTenSolverBackend + topology :: T + beta :: Float64 + bond_dim :: Int + max_states :: Int + cutoff_prob :: Float64 + onGPU :: Bool + contraction :: Symbol + num_sweeps :: Int + graduate_truncation :: Bool + transformations :: S + local_dimension :: Union{Nothing, Int} + no_cache :: Bool +end + +function PEPSBackend(topology::AbstractStructuredTopology; + beta::Real = 2.0, + bond_dim::Integer = 16, + max_states::Integer = 2^8, + cutoff_prob::Real = 1e-4, + onGPU::Bool = false, + contraction::Symbol = :auto, + num_sweeps::Integer = 1, + graduate_truncation::Bool = true, + transformations = :all, + local_dimension::Union{Nothing, Integer} = nothing, + no_cache::Bool = false) + beta > 0 && isfinite(beta) || throw(ArgumentError("PEPSBackend requires finite beta > 0. Got $beta.")) + bond_dim >= 1 || throw(ArgumentError("PEPSBackend requires bond_dim >= 1. Got $bond_dim.")) + max_states >= 1 || throw(ArgumentError("PEPSBackend requires max_states >= 1. Got $max_states.")) + cutoff_prob >= 0 || throw(ArgumentError("PEPSBackend requires cutoff_prob >= 0. Got $cutoff_prob.")) + num_sweeps >= 1 || throw(ArgumentError("PEPSBackend requires num_sweeps >= 1. Got $num_sweeps.")) + contraction in (:auto, :svd, :svd_truncate, :zipper) || + throw(ArgumentError("Unsupported PEPS contraction $(repr(contraction)). Use :auto, :svd, :svd_truncate, or :zipper.")) + if !isnothing(local_dimension) + local_dimension >= 1 || throw(ArgumentError("PEPSBackend requires local_dimension >= 1 when provided. Got $local_dimension.")) + end + + return PEPSBackend{typeof(topology), typeof(transformations)}( + topology, + Float64(beta), + Int(bond_dim), + Int(max_states), + Float64(cutoff_prob), + onGPU, + contraction, + Int(num_sweeps), + graduate_truncation, + transformations, + isnothing(local_dimension) ? nothing : Int(local_dimension), + no_cache, + ) +end + + """ minimize(Q::Matrix[, l::Vector[, c::Number ; device, cutoff, kwargs...) @@ -149,6 +278,37 @@ function minimize(backend::AbstractTenSolverBackend, args...; kwargs...) throw(backend_error(backend)) end +""" + solve_ising(model; backend = DMRGBackend(), kwargs...) + solve_ising(J, h[, offset]; backend = DMRGBackend(), kwargs...) + +Solve an Ising model with spins `s_i in {-1, +1}`. + +The returned solution still samples TenSolver Boolean vectors using +`x_i = (s_i + 1) / 2`. The default DMRG path converts the Ising model back to a +QUBO and calls [`minimize`](@ref). Optional structured backends, such as +[`PEPSBackend`](@ref), implement this boundary directly. +""" +function solve_ising end + +function solve_ising(model::IsingModel; backend=default_backend, kwargs...) + return _solve_ising(_normalize_backend(backend), model; kwargs...) +end + +function solve_ising(J::AbstractMatrix, h::AbstractVector, offset::Real=0; backend=default_backend, kwargs...) + return solve_ising(IsingModel(J, h, offset); backend, kwargs...) +end + +function _solve_ising(backend::AbstractTenSolverBackend, model::IsingModel; kwargs...) + throw(_backend_error(backend)) +end + +function _solve_ising(::DMRGBackend, model::IsingModel; kwargs...) + qubo = ising_to_qubo(model) + return _minimize(default_backend, qubo.Q, qubo.l, qubo.c; kwargs...) +end + + """ minimize(Q::Matrix, c::Number; kwargs...) diff --git a/test/peps_backend.jl b/test/peps_backend.jl new file mode 100644 index 0000000..589e88b --- /dev/null +++ b/test/peps_backend.jl @@ -0,0 +1,106 @@ +@testset "PEPS backend core" begin + @test SquareGrid(2, 3).m == 2 + @test SquareGrid(2, 3).n == 3 + @test SquareGrid(2, 3).spins_per_site == 1 + @test KingGrid(2, 3, 2).spins_per_site == 2 + @test_throws ArgumentError SquareGrid(0, 1) + @test_throws ArgumentError KingGrid(1, 0) + + backend = PEPSBackend( + SquareGrid(1, 1); + beta = 1.5, + bond_dim = 4, + max_states = 2, + cutoff_prob = 0.0, + onGPU = false, + contraction = :svd, + transformations = :identity, + ) + + @test backend.topology == SquareGrid(1, 1) + @test backend.beta == 1.5 + @test backend.bond_dim == 4 + @test backend.max_states == 2 + @test backend.cutoff_prob == 0.0 + @test backend.contraction == :svd + @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); beta = 0) + @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); bond_dim = 0) + @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); max_states = 0) + @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); contraction = :unknown) + + Q = reshape([-1.0], 1, 1) + peps_error = try + minimize(Q; backend, verbosity = 0) + catch err + err + end + @test peps_error isa ArgumentError + @test occursin("PEPSBackend is not available", sprint(showerror, peps_error)) + @test occursin("SpinGlassNetworks", sprint(showerror, peps_error)) + + model = IsingModel(zeros(1, 1), [-1.0]) + energy, solution = solve_ising(model; backend = :dmrg, verbosity = 0) + @test energy ≈ -1.0 + @test sample(solution) == [1] + + peps_solution = PEPSSolution{Float64}( + [[1, 0], [0, 1]], + [-2.0, -1.0], + [0.75, 0.25], + Dict{String, Any}("backend" => "SpinGlassPEPS"), + nothing, + ) + @test sample(peps_solution) == [1, 0] + @test sample(peps_solution, 2) == [[1, 0], [1, 0]] + @test [1, 0] in peps_solution + @test !([0, 0] in peps_solution) + @test TenSolver.prob(peps_solution, [0, 1]) ≈ 0.25 +end + +@testset "Optional SpinGlassPEPS extension" begin + has_spinglasspeps_components = all(pkg -> !isnothing(Base.find_package(pkg)), ( + "SpinGlassNetworks", + "SpinGlassEngine", + "SpinGlassTensors", + )) + + if !has_spinglasspeps_components + @test_skip "SpinGlassPEPS component packages are not available in this environment." + else + import SpinGlassNetworks + import SpinGlassEngine + import SpinGlassTensors + + backend = PEPSBackend( + SquareGrid(2, 2); + beta = 2.0, + bond_dim = 4, + max_states = 4, + cutoff_prob = 0.0, + onGPU = false, + contraction = :svd, + transformations = :identity, + ) + + Q = [ + -1.0 0.5 0.0 0.0 + 0.0 -0.5 0.0 0.0 + 0.0 0.0 -0.25 0.25 + 0.0 0.0 0.0 -0.75 + ] + l = [0.0, 0.25, -0.25, 0.0] + c = 0.125 + objective(x) = dot(x, Q, x) + dot(l, x) + c + exact_energy, _ = brute_force(objective, Int, 4) + + energy, solution = minimize(Q, l, c; backend, verbosity = 0) + state = sample(solution) + + @test energy ≈ exact_energy atol = 1e-6 + @test objective(state) ≈ energy atol = 1e-6 + @test solution.metadata["backend"] == "SpinGlassPEPS" + @test solution.metadata["topology"] == "square" + @test solution.metadata["selected_transformation"] == string(SpinGlassEngine.rotation(0)) + @test first(solution.energies) ≈ energy atol = 1e-6 + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 3b374df..3072e48 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -21,6 +21,7 @@ include(filepath("backend.jl")) # Ising <-> QUBO utilities include(filepath("ising_conversion.jl")) include(filepath("backend.jl")) +include(filepath("peps_backend.jl")) # Binary constraint API include(filepath("constraints.jl")) From c9e18fa1eb13acee49a029f51bd077d669d720cf Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sat, 16 May 2026 22:05:07 -0400 Subject: [PATCH 07/23] Keep PEPS backend scaffolding internal (#44) --- docs/src/api.md | 2 - docs/src/spinglasspeps_integration.md | 49 +++++++++---------- src/TenSolver.jl | 4 +- src/solution.jl | 45 +++++++++++------ src/solver.jl | 63 ++++++++---------------- test/peps_backend.jl | 70 +++++++++++++++++++-------- 6 files changed, 122 insertions(+), 111 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index e96dc9d..098d6f3 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -23,7 +23,6 @@ TenSolver.PEPSBackend ```@docs TenSolver.Solution -TenSolver.PEPSSolution ``` ## Sampling Functions @@ -57,7 +56,6 @@ TenSolver.constraint_sites ```@docs Base.in(::AbstractVector, ::TenSolver.Solution) -Base.in(::AbstractVector, ::TenSolver.PEPSSolution) ``` ## Internal Functions diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index 967153a..bb98e62 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -179,34 +179,31 @@ learn the SpinGlassPEPS API. The current behavior is: - no backend argument means current DMRG behavior; - `backend = :dmrg` selects the current path explicitly; - `backend = DMRGBackend()` selects the current path explicitly through the - backend-object interface; -- `backend = PEPSBackend(SquareGrid(m, n); ...)` or - `backend = PEPSBackend(KingGrid(m, n); ...)` selects the optional structured - backend when `SpinGlassNetworks`, `SpinGlassEngine`, and `SpinGlassTensors` - are installed and loaded; and -- a `PEPSBackend` errors clearly when the optional component packages are not - available. - -The direct PEPS path is implemented as a Julia package extension. The core -package defines the stable backend, topology, `solve_ising`, and -`PEPSSolution` interfaces, while `TenSolverSpinGlassPEPSExt` owns the -SpinGlass component imports and calls. This keeps ordinary TenSolver installs -on the existing dependency footprint. - -The extension is intentionally experimental while the upstream dependency stack -settles. In local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, -and SpinGlassTensors 1.3, the current registered component compat bounds do not + backend-object interface; and +- unavailable backend symbols error clearly without changing default DMRG + behavior. + +This stack step keeps the direct PEPS path as non-public scaffolding. The core +package contains internal backend, topology, `solve_ising`, and result +boundaries, while `TenSolverSpinGlassPEPSExt` owns the SpinGlass component +imports and calls. This keeps ordinary TenSolver installs on the existing +dependency footprint and avoids documenting an activation path that cannot be +tested from registered packages. + +The extension remains gated while the upstream dependency stack settles. In +local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, and +SpinGlassTensors 1.3, the current registered component compat bounds do not resolve with TenSolver's ITensors/QUBOTools environment. The source bridge and gated tests are kept in this stack step so the TenSolver boundary is concrete, -but CI does not require the SpinGlass component stack. - -The initial supported structured topologies are one-spin-per-site and -multi-spin-per-site square/king grids through `SquareGrid(m, n[, t])` and -`KingGrid(m, n[, t])`. QUBO inputs are converted through [`qubo_to_ising`](@ref) -before the PEPS extension builds a SpinGlassNetworks Ising graph, clusters it -with `super_square_lattice`, constructs the Potts Hamiltonian, runs -`MpsContractor` plus `low_energy_spectrum`, and decodes retained states back to -TenSolver Boolean vectors. +but the PEPS backend types are not exported or listed in the public API until +CI can exercise the SpinGlass component stack. + +The initial internal structured topology scaffolding covers one-spin-per-site +and multi-spin-per-site square/king grids. QUBO inputs are converted through +[`qubo_to_ising`](@ref) before the PEPS extension builds a SpinGlassNetworks +Ising graph, clusters it with `super_square_lattice`, constructs the Potts +Hamiltonian, runs `MpsContractor` plus `low_energy_spectrum`, and decodes +retained states back to TenSolver Boolean vectors. Later PRs should add QUBODrivers/JuMP raw optimizer attributes for backend and PEPS parameters. diff --git a/src/TenSolver.jl b/src/TenSolver.jl index cabc830..4bfa98c 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -21,14 +21,14 @@ include("projection_mpo.jl") include("solution.jl") export sample -export Solution, PEPSSolution +export Solution include("ising.jl") export IsingModel, bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo, ising_energy include("solver.jl") export minimize, maximize, solve_ising -export AbstractTenSolverBackend, DMRGBackend, PEPSBackend, SquareGrid, KingGrid +export AbstractTenSolverBackend, DMRGBackend # Convergence logging include("log.jl") diff --git a/src/solution.jl b/src/solution.jl index a163c65..4658a6d 100644 --- a/src/solution.jl +++ b/src/solution.jl @@ -72,15 +72,13 @@ function original_order(bs, permutation) return x end -""" - PEPSSolution - -Result returned by the optional SpinGlassPEPS backend. - -The states are decoded to TenSolver Boolean vectors, and `energies` are -objective values in the original TenSolver convention. Backend-specific -diagnostics live in `metadata` and the raw extension result is stored in `raw`. -""" +# Internal result scaffold for the optional SpinGlassPEPS extension. +# +# This type is not exported while the optional SpinGlassPEPS component stack is +# not covered by CI. The states are decoded to TenSolver Boolean vectors, and +# `energies` are objective values in the original TenSolver convention. +# Backend-specific diagnostics live in `metadata` and the raw extension result +# is stored in `raw`. struct PEPSSolution{T <: Real} states :: Vector{Vector{Int}} energies :: Vector{T} @@ -111,8 +109,27 @@ sample(psi::Solution, n :: Integer) = [sample(psi) for _ in 1:n] function sample(psi::PEPSSolution) isempty(psi.states) && throw(ArgumentError("Cannot sample an empty PEPS solution.")) - idx = isempty(psi.probabilities) ? firstindex(psi.states) : argmax(psi.probabilities) - return copy(psi.states[idx]) + if isempty(psi.probabilities) + return copy(first(psi.states)) + end + length(psi.probabilities) == length(psi.states) || + throw(ArgumentError("PEPS solution probabilities must match the number of retained states.")) + any(probability -> probability < 0, psi.probabilities) && + throw(ArgumentError("PEPS solution probabilities must be nonnegative.")) + + total = sum(psi.probabilities) + total > 0 || throw(ArgumentError("PEPS solution probabilities must have positive total weight.")) + + threshold = rand() * total + cumulative = zero(total) + for (state, probability) in zip(psi.states, psi.probabilities) + cumulative += probability + if threshold <= cumulative + return copy(state) + end + end + + return copy(last(psi.states)) end sample(psi::PEPSSolution, n :: Integer) = [sample(psi) for _ in 1:n] @@ -128,11 +145,7 @@ function Base.in(bs::AbstractVector, psi::Solution; cutoff = 1e-8) return prob(psi, bs) > cutoff end -""" - in(xs, psi::PEPSSolution [; cutoff]) - -Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. -""" +# Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. function Base.in(bs::AbstractVector, psi::PEPSSolution; cutoff = 1e-8) return prob(psi, bs) > cutoff end diff --git a/src/solver.jl b/src/solver.jl index 5b667e7..da0f0f2 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -60,14 +60,10 @@ const default_backend = DMRGBackend() abstract type AbstractStructuredTopology end -""" - SquareGrid(m, n[, spins_per_site]) - -Structured square-grid topology for optional PEPS solves. - -Variables are assumed to be ordered according to SpinGlassNetworks' -`super_square_lattice((m, n, spins_per_site))` convention. -""" +# Structured square-grid topology for optional PEPS solves. +# +# Variables are assumed to be ordered according to SpinGlassNetworks' +# `super_square_lattice((m, n, spins_per_site))` convention. struct SquareGrid <: AbstractStructuredTopology m :: Int n :: Int @@ -81,13 +77,9 @@ struct SquareGrid <: AbstractStructuredTopology end end -""" - KingGrid(m, n[, spins_per_site]) - -Structured king-grid topology for optional PEPS solves. It uses the same -variable ordering as [`SquareGrid`](@ref), but the PEPS compatibility graph -also allows diagonal interactions between neighboring grid cells. -""" +# Structured king-grid topology for optional PEPS solves. It uses the same +# variable ordering as `SquareGrid`, but the PEPS compatibility graph also +# allows diagonal interactions between neighboring grid cells. struct KingGrid <: AbstractStructuredTopology m :: Int n :: Int @@ -106,32 +98,16 @@ _topology_tuple(topology::AbstractStructuredTopology) = (topology.m, topology.n, _topology_name(::SquareGrid) = "square" _topology_name(::KingGrid) = "king" -""" - PEPSBackend(topology; kwargs...) - -Select the optional SpinGlassPEPS structured backend. - -The backend is implemented by the `TenSolverSpinGlassPEPSExt` package extension, -which loads only when `SpinGlassNetworks`, `SpinGlassEngine`, and -`SpinGlassTensors` are available. Without those packages this backend errors -clearly and the default DMRG backend remains unchanged. - -The extension is experimental because the current registered SpinGlass -component dependency stack may not resolve in the same environment as -TenSolver's ITensors/QUBOTools stack. - -Supported core options are: - -- `topology`: a [`SquareGrid`](@ref) or [`KingGrid`](@ref). -- `beta`: inverse temperature used by the PEPS contractor. -- `bond_dim`: boundary MPS bond dimension. -- `max_states`: branch-and-bound state width. -- `cutoff_prob`: branch-and-bound probability cutoff. -- `onGPU`: whether SpinGlassPEPS should use GPU execution. -- `contraction`: `:auto`, `:svd`, `:svd_truncate`, or `:zipper`. -- `transformations`: `:all`, `:identity`, a single transformation, or a - collection of SpinGlassEngine lattice transformations. -""" +# Internal scaffold for the optional SpinGlassPEPS structured backend. +# +# The backend is implemented by the `TenSolverSpinGlassPEPSExt` package +# extension, which loads only when `SpinGlassNetworks`, `SpinGlassEngine`, and +# `SpinGlassTensors` are available. Without those packages this backend errors +# clearly and the default DMRG backend remains unchanged. +# +# This constructor is intentionally not exported while the current registered +# SpinGlass component dependency stack does not resolve in the same environment +# as TenSolver's ITensors/QUBOTools stack and CI cannot exercise the extension. struct PEPSBackend{T <: AbstractStructuredTopology, S} <: AbstractTenSolverBackend topology :: T beta :: Float64 @@ -186,7 +162,6 @@ function PEPSBackend(topology::AbstractStructuredTopology; ) end - """ minimize(Q::Matrix[, l::Vector[, c::Number ; device, cutoff, kwargs...) @@ -286,8 +261,8 @@ Solve an Ising model with spins `s_i in {-1, +1}`. The returned solution still samples TenSolver Boolean vectors using `x_i = (s_i + 1) / 2`. The default DMRG path converts the Ising model back to a -QUBO and calls [`minimize`](@ref). Optional structured backends, such as -[`PEPSBackend`](@ref), implement this boundary directly. +QUBO and calls [`minimize`](@ref). Optional structured backends can implement +this boundary directly. """ function solve_ising end diff --git a/test/peps_backend.jl b/test/peps_backend.jl index 589e88b..43d27bd 100644 --- a/test/peps_backend.jl +++ b/test/peps_backend.jl @@ -1,13 +1,18 @@ @testset "PEPS backend core" begin - @test SquareGrid(2, 3).m == 2 - @test SquareGrid(2, 3).n == 3 - @test SquareGrid(2, 3).spins_per_site == 1 - @test KingGrid(2, 3, 2).spins_per_site == 2 - @test_throws ArgumentError SquareGrid(0, 1) - @test_throws ArgumentError KingGrid(1, 0) + @test !(:PEPSBackend in names(TenSolver)) + @test !(:SquareGrid in names(TenSolver)) + @test !(:KingGrid in names(TenSolver)) + @test !(:PEPSSolution in names(TenSolver)) - backend = PEPSBackend( - SquareGrid(1, 1); + @test TenSolver.SquareGrid(2, 3).m == 2 + @test TenSolver.SquareGrid(2, 3).n == 3 + @test TenSolver.SquareGrid(2, 3).spins_per_site == 1 + @test TenSolver.KingGrid(2, 3, 2).spins_per_site == 2 + @test_throws ArgumentError TenSolver.SquareGrid(0, 1) + @test_throws ArgumentError TenSolver.KingGrid(1, 0) + + backend = TenSolver.PEPSBackend( + TenSolver.SquareGrid(1, 1); beta = 1.5, bond_dim = 4, max_states = 2, @@ -17,16 +22,16 @@ transformations = :identity, ) - @test backend.topology == SquareGrid(1, 1) + @test backend.topology == TenSolver.SquareGrid(1, 1) @test backend.beta == 1.5 @test backend.bond_dim == 4 @test backend.max_states == 2 @test backend.cutoff_prob == 0.0 @test backend.contraction == :svd - @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); beta = 0) - @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); bond_dim = 0) - @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); max_states = 0) - @test_throws ArgumentError PEPSBackend(SquareGrid(1, 1); contraction = :unknown) + @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); beta = 0) + @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); bond_dim = 0) + @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); max_states = 0) + @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); contraction = :unknown) Q = reshape([-1.0], 1, 1) peps_error = try @@ -43,18 +48,41 @@ @test energy ≈ -1.0 @test sample(solution) == [1] - peps_solution = PEPSSolution{Float64}( + peps_solution = TenSolver.PEPSSolution{Float64}( [[1, 0], [0, 1]], [-2.0, -1.0], - [0.75, 0.25], + [0.0, 1.0], Dict{String, Any}("backend" => "SpinGlassPEPS"), nothing, ) - @test sample(peps_solution) == [1, 0] - @test sample(peps_solution, 2) == [[1, 0], [1, 0]] - @test [1, 0] in peps_solution + @test sample(peps_solution) == [0, 1] + @test sample(peps_solution, 2) == [[0, 1], [0, 1]] + @test [0, 1] in peps_solution + @test !([1, 0] in peps_solution) @test !([0, 0] in peps_solution) - @test TenSolver.prob(peps_solution, [0, 1]) ≈ 0.25 + @test TenSolver.prob(peps_solution, [0, 1]) ≈ 1.0 + + @test_throws ArgumentError sample(TenSolver.PEPSSolution{Float64}( + [[1, 0], [0, 1]], + [-2.0, -1.0], + [1.0], + Dict{String, Any}(), + nothing, + )) + @test_throws ArgumentError sample(TenSolver.PEPSSolution{Float64}( + [[1, 0], [0, 1]], + [-2.0, -1.0], + [1.0, -0.5], + Dict{String, Any}(), + nothing, + )) + @test_throws ArgumentError sample(TenSolver.PEPSSolution{Float64}( + [[1, 0], [0, 1]], + [-2.0, -1.0], + [0.0, 0.0], + Dict{String, Any}(), + nothing, + )) end @testset "Optional SpinGlassPEPS extension" begin @@ -71,8 +99,8 @@ end import SpinGlassEngine import SpinGlassTensors - backend = PEPSBackend( - SquareGrid(2, 2); + backend = TenSolver.PEPSBackend( + TenSolver.SquareGrid(2, 2); beta = 2.0, bond_dim = 4, max_states = 4, From 376fa6ff2e924efe5f42a3cdc824ad1c311d89b6 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sat, 16 May 2026 22:40:19 -0400 Subject: [PATCH 08/23] Add solve_ising API regression coverage (#44) --- docs/src/spinglasspeps_integration.md | 11 ++++++----- test/backend.jl | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index bb98e62..c70a38c 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -184,11 +184,12 @@ learn the SpinGlassPEPS API. The current behavior is: behavior. This stack step keeps the direct PEPS path as non-public scaffolding. The core -package contains internal backend, topology, `solve_ising`, and result -boundaries, while `TenSolverSpinGlassPEPSExt` owns the SpinGlass component -imports and calls. This keeps ordinary TenSolver installs on the existing -dependency footprint and avoids documenting an activation path that cannot be -tested from registered packages. +package contains internal backend, topology, and result boundaries, while the +exported `solve_ising` function is the public Ising boundary that optional +structured backends may implement. `TenSolverSpinGlassPEPSExt` owns the +SpinGlass component imports and calls. This keeps ordinary TenSolver installs +on the existing dependency footprint and avoids documenting an activation path +that cannot be tested from registered packages. The extension remains gated while the upstream dependency stack settles. In local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, and diff --git a/test/backend.jl b/test/backend.jl index 1fa7a4d..7445817 100644 --- a/test/backend.jl +++ b/test/backend.jl @@ -30,6 +30,30 @@ end @test TenSolver.sample(psi) == [1] end + @testset "solve_ising preserves pair couplings and offsets" begin + J = [0.0 0.5; 1.0 0.0] + h = [-0.25, 0.75] + offset = 2.0 + model = IsingModel(J, h, offset) + spin_states = [[s1, s2] for s1 in (-1, 1) for s2 in (-1, 1)] + energies = [TenSolver.ising_energy(model, spin) for spin in spin_states] + expected_energy, expected_index = findmin(energies) + expected_spin = spin_states[expected_index] + + for solve_call in ( + () -> solve_ising(model; backend=:dmrg, verbosity=0), + () -> solve_ising(J, h, offset; backend=:dmrg, verbosity=0), + ) + energy, solution = solve_call() + sample_bits = TenSolver.sample(solution) + sample_spin = TenSolver.bool_to_spin(sample_bits) + + @test energy ≈ expected_energy + @test sample_spin == expected_spin + @test TenSolver.ising_energy(model, sample_spin) ≈ expected_energy + end + end + @testset "Symbol backends can be provided by extensions" begin Q = reshape([1.0], 1, 1) E, payload = minimize(Q; backend=:test_symbol_backend, verbosity=0, cutoff=1e-6) From cdd97af3c18c63a63fb6b3d7f5572eb89a5b0779 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Tue, 2 Jun 2026 06:17:58 -0400 Subject: [PATCH 09/23] Use QUBOTools forms for Ising conversions --- docs/src/api.md | 2 +- docs/src/spinglasspeps_integration.md | 14 ++--- src/TenSolver.jl | 2 +- src/ising.jl | 78 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 098d6f3..7d3a0be 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -7,7 +7,6 @@ This page documents the public API of TenSolver.jl. ```@docs TenSolver.minimize TenSolver.maximize -TenSolver.solve_ising ``` ## Solver Backends @@ -97,6 +96,7 @@ TenSolver.project_state ```@docs TenSolver.SquareGrid TenSolver.KingGrid +TenSolver.solve_ising ``` ## Index diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index c70a38c..a8169d1 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -128,11 +128,12 @@ offset conventions visible because mistakes there can silently return plausible but wrong energies. TenSolver uses the Boolean/spin convention `x_i = (s_i + 1) / 2` and -`s_i = 2x_i - 1`. The conversion utilities preserve TenSolver's current QUBO -truth objective, `dot(x, Q, x) + dot(l, x) + c`; for non-symmetric matrices the -effective pair coefficient is therefore `Q[i, j] + Q[j, i]`. The Ising model -stores one coupling per unordered pair plus an explicit constant offset, so -`ising_energy(qubo_to_ising(Q, l, c), bool_to_spin(x))` matches the Boolean +`s_i = 2x_i - 1`. The conversion utilities are adapters into QUBOTools forms, +not a separate conversion implementation. They preserve TenSolver's current +QUBO truth objective, `dot(x, Q, x) + dot(l, x) + c`; for non-symmetric +matrices the effective pair coefficient is therefore `Q[i, j] + Q[j, i]`. +`qubo_to_ising(Q, l, c)` returns a sparse QUBOTools form in `SpinDomain`, so +`QUBOTools.value(bool_to_spin(x), qubo_to_ising(Q, l, c))` matches the Boolean objective for every bitstring `x`. Layout metadata should also be explicit. A PEPS backend call should know whether @@ -219,7 +220,8 @@ install/load the optional PEPS bridge. The integration should be implemented as a sequence of stacked PRs: 1. Add this design document and link it from the documentation navigation. -2. Add QUBO/Ising conversion utilities with exact energy-preservation tests. +2. Add QUBOTools-backed QUBO/Ising conversion adapters with exact + energy-preservation tests. 3. Introduce a backend interface while keeping the current DMRG backend as the default implementation. 4. Add an optional SpinGlassPEPS-backed structured solver path for direct diff --git a/src/TenSolver.jl b/src/TenSolver.jl index 4bfa98c..c2d5a31 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -24,7 +24,7 @@ export sample export Solution include("ising.jl") -export IsingModel, bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo, ising_energy +export bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo include("solver.jl") export minimize, maximize, solve_ising diff --git a/src/ising.jl b/src/ising.jl index 0fc8ce4..d56d951 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -42,16 +42,28 @@ function check_qubo_dimensions(Q::AbstractMatrix, l) end end +<<<<<<< HEAD function check_ising_dimensions(J::AbstractMatrix, h::AbstractVector) +======= +function _check_ising_dimensions(J::AbstractMatrix, h::AbstractVector) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) issquare(J) || throw(DimensionMismatch("The Ising coupling matrix must be square. Encountered dimensions $(size(J)).")) size(J, 1) == length(h) || throw(DimensionMismatch("The Ising field vector length must match the coupling matrix size. Encountered dimensions $(size(J)) and length $(length(h)).")) end +<<<<<<< HEAD function check_spin_convention(convention) convention === :spin || throw(ArgumentError("Only the `:spin` convention is supported. Use x = (s + 1) / 2 and s = 2x - 1.")) end function checked_bool_state(x::AbstractVector) +======= +function _check_spin_convention(convention) + convention === :spin || throw(ArgumentError("Only the `:spin` convention is supported. Use x = (s + 1) / 2 and s = 2x - 1.")) +end + +function _checked_bool_state(x::AbstractVector) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) return map(x) do xi if iszero(xi) 0 @@ -63,7 +75,11 @@ function checked_bool_state(x::AbstractVector) end end +<<<<<<< HEAD function checked_spin_state(s::AbstractVector) +======= +function _checked_spin_state(s::AbstractVector) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) return map(s) do si if si == -1 -1 @@ -75,13 +91,18 @@ function checked_spin_state(s::AbstractVector) end end +<<<<<<< HEAD function drop_form_zeros!(form::QUBOTools.AbstractForm) +======= +function _drop_form_zeros!(form::QUBOTools.AbstractForm) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) _, l, Q, _, _, _, _ = form dropzeros!(l) dropzeros!(Q) return form end +<<<<<<< HEAD function qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real) check_qubo_dimensions(Q, l) @@ -90,10 +111,21 @@ function qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real L = isnothing(l) ? zeros(T, n) : collect(T, l) return drop_form_zeros!( +======= +function _qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real) + _check_qubo_dimensions(Q, l) + + n = size(Q, 1) + T = _conversion_type(Q, l, c) + L = isnothing(l) ? zeros(T, n) : collect(T, l) + + return _drop_form_zeros!( +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.SparseForm{T}(n, L, sparse(T.(Q)), one(T), T(c); sense = :min, domain = :bool), ) end +<<<<<<< HEAD function ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) check_ising_dimensions(J, h) @@ -101,11 +133,24 @@ function ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) T = conversion_type(J, h, offset) return drop_form_zeros!( +======= +function _ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) + _check_ising_dimensions(J, h) + + n = size(J, 1) + T = _conversion_type(J, h, offset) + + return _drop_form_zeros!( +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.SparseForm{T}(n, collect(T, h), sparse(T.(J)), one(T), T(offset); sense = :min, domain = :spin), ) end +<<<<<<< HEAD function check_form_domain(form::QUBOTools.AbstractForm, domain, label::AbstractString) +======= +function _check_form_domain(form::QUBOTools.AbstractForm, domain, label::AbstractString) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.domain(form) === domain || throw(ArgumentError("$label conversion expected a QUBOTools form in domain $domain. Encountered $(QUBOTools.domain(form)).")) end @@ -116,7 +161,11 @@ Convert a Boolean bit vector `x_i in {0, 1}` to Ising spins `s_i in {-1, +1}` using QUBOTools' `BoolDomain => SpinDomain` cast. """ function bool_to_spin(x::AbstractVector) +<<<<<<< HEAD return QUBOTools.cast(QUBOTools.BoolDomain => QUBOTools.SpinDomain, checked_bool_state(x)) +======= + return QUBOTools.cast(QUBOTools.BoolDomain => QUBOTools.SpinDomain, _checked_bool_state(x)) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -126,7 +175,11 @@ Convert an Ising spin vector `s_i in {-1, +1}` to Boolean bits `x_i in {0, 1}` using QUBOTools' `SpinDomain => BoolDomain` cast. """ function spin_to_bool(s::AbstractVector) +<<<<<<< HEAD return QUBOTools.cast(QUBOTools.SpinDomain => QUBOTools.BoolDomain, checked_spin_state(s)) +======= + return QUBOTools.cast(QUBOTools.SpinDomain => QUBOTools.BoolDomain, _checked_spin_state(s)) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -144,10 +197,13 @@ For non-symmetric matrices, the QUBOTools form constructor preserves TenSolver's `dot(x, Q, x)` convention by storing the effective pair coefficient `Q[i, j] + Q[j, i]` once in the upper triangle. +<<<<<<< HEAD The Boolean-to-spin conversion introduces halves and quarters. Integer coefficient inputs therefore return floating-point forms, while rational inputs preserve exact rational arithmetic. +======= +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) The returned form includes the constant offset, so ```julia @@ -157,6 +213,7 @@ dot(x, Q, x) + dot(l, x) + c == QUBOTools.value(bool_to_spin(x), qubo_to_ising(Q for every Boolean vector `x`. """ function qubo_to_ising(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}=nothing, c::Real=0; convention::Symbol=:spin) +<<<<<<< HEAD check_spin_convention(convention) return qubo_to_ising(qubo_form(Q, l, c); convention) end @@ -165,6 +222,16 @@ function qubo_to_ising(form::QUBOTools.AbstractForm; convention::Symbol=:spin) check_spin_convention(convention) check_form_domain(form, QUBOTools.BoolDomain, "QUBO-to-Ising") return drop_form_zeros!(QUBOTools.cast(QUBOTools.SpinDomain, form)) +======= + _check_spin_convention(convention) + return qubo_to_ising(_qubo_form(Q, l, c); convention) +end + +function qubo_to_ising(form::QUBOTools.AbstractForm; convention::Symbol=:spin) + _check_spin_convention(convention) + _check_form_domain(form, QUBOTools.BoolDomain, "QUBO-to-Ising") + return _drop_form_zeros!(QUBOTools.cast(QUBOTools.SpinDomain, form)) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -182,6 +249,7 @@ dot(s, J, s) + dot(h, s) + offset with `s_i in {-1, +1}`. QUBOTools folds diagonal quadratic spin terms into the constant offset and stores each off-diagonal unordered pair once in the upper triangle. +<<<<<<< HEAD As with [`qubo_to_ising`](@ref), integer coefficient inputs return floating-point forms when the conversion introduces fractional coefficients, @@ -222,4 +290,14 @@ function ising_energy(J::AbstractMatrix, h::AbstractVector, s::AbstractVector, o end return energy +======= +""" +function ising_to_qubo(form::QUBOTools.AbstractForm) + _check_form_domain(form, QUBOTools.SpinDomain, "Ising-to-QUBO") + return _drop_form_zeros!(QUBOTools.cast(QUBOTools.BoolDomain, form)) +end + +function ising_to_qubo(J::AbstractMatrix, h::AbstractVector, offset::Real=0) + return ising_to_qubo(_ising_form(J, h, offset)) +>>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end From 7790dba9c00bf89794f80558c12fd83be4b55217 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sun, 14 Jun 2026 06:59:21 -0400 Subject: [PATCH 10/23] Clarify Ising conversion numeric promotion --- src/ising.jl | 78 ---------------------------------------------------- 1 file changed, 78 deletions(-) diff --git a/src/ising.jl b/src/ising.jl index d56d951..0fc8ce4 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -42,28 +42,16 @@ function check_qubo_dimensions(Q::AbstractMatrix, l) end end -<<<<<<< HEAD function check_ising_dimensions(J::AbstractMatrix, h::AbstractVector) -======= -function _check_ising_dimensions(J::AbstractMatrix, h::AbstractVector) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) issquare(J) || throw(DimensionMismatch("The Ising coupling matrix must be square. Encountered dimensions $(size(J)).")) size(J, 1) == length(h) || throw(DimensionMismatch("The Ising field vector length must match the coupling matrix size. Encountered dimensions $(size(J)) and length $(length(h)).")) end -<<<<<<< HEAD function check_spin_convention(convention) convention === :spin || throw(ArgumentError("Only the `:spin` convention is supported. Use x = (s + 1) / 2 and s = 2x - 1.")) end function checked_bool_state(x::AbstractVector) -======= -function _check_spin_convention(convention) - convention === :spin || throw(ArgumentError("Only the `:spin` convention is supported. Use x = (s + 1) / 2 and s = 2x - 1.")) -end - -function _checked_bool_state(x::AbstractVector) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) return map(x) do xi if iszero(xi) 0 @@ -75,11 +63,7 @@ function _checked_bool_state(x::AbstractVector) end end -<<<<<<< HEAD function checked_spin_state(s::AbstractVector) -======= -function _checked_spin_state(s::AbstractVector) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) return map(s) do si if si == -1 -1 @@ -91,18 +75,13 @@ function _checked_spin_state(s::AbstractVector) end end -<<<<<<< HEAD function drop_form_zeros!(form::QUBOTools.AbstractForm) -======= -function _drop_form_zeros!(form::QUBOTools.AbstractForm) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) _, l, Q, _, _, _, _ = form dropzeros!(l) dropzeros!(Q) return form end -<<<<<<< HEAD function qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real) check_qubo_dimensions(Q, l) @@ -111,21 +90,10 @@ function qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real L = isnothing(l) ? zeros(T, n) : collect(T, l) return drop_form_zeros!( -======= -function _qubo_form(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}, c::Real) - _check_qubo_dimensions(Q, l) - - n = size(Q, 1) - T = _conversion_type(Q, l, c) - L = isnothing(l) ? zeros(T, n) : collect(T, l) - - return _drop_form_zeros!( ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.SparseForm{T}(n, L, sparse(T.(Q)), one(T), T(c); sense = :min, domain = :bool), ) end -<<<<<<< HEAD function ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) check_ising_dimensions(J, h) @@ -133,24 +101,11 @@ function ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) T = conversion_type(J, h, offset) return drop_form_zeros!( -======= -function _ising_form(J::AbstractMatrix, h::AbstractVector, offset::Real) - _check_ising_dimensions(J, h) - - n = size(J, 1) - T = _conversion_type(J, h, offset) - - return _drop_form_zeros!( ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.SparseForm{T}(n, collect(T, h), sparse(T.(J)), one(T), T(offset); sense = :min, domain = :spin), ) end -<<<<<<< HEAD function check_form_domain(form::QUBOTools.AbstractForm, domain, label::AbstractString) -======= -function _check_form_domain(form::QUBOTools.AbstractForm, domain, label::AbstractString) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) QUBOTools.domain(form) === domain || throw(ArgumentError("$label conversion expected a QUBOTools form in domain $domain. Encountered $(QUBOTools.domain(form)).")) end @@ -161,11 +116,7 @@ Convert a Boolean bit vector `x_i in {0, 1}` to Ising spins `s_i in {-1, +1}` using QUBOTools' `BoolDomain => SpinDomain` cast. """ function bool_to_spin(x::AbstractVector) -<<<<<<< HEAD return QUBOTools.cast(QUBOTools.BoolDomain => QUBOTools.SpinDomain, checked_bool_state(x)) -======= - return QUBOTools.cast(QUBOTools.BoolDomain => QUBOTools.SpinDomain, _checked_bool_state(x)) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -175,11 +126,7 @@ Convert an Ising spin vector `s_i in {-1, +1}` to Boolean bits `x_i in {0, 1}` using QUBOTools' `SpinDomain => BoolDomain` cast. """ function spin_to_bool(s::AbstractVector) -<<<<<<< HEAD return QUBOTools.cast(QUBOTools.SpinDomain => QUBOTools.BoolDomain, checked_spin_state(s)) -======= - return QUBOTools.cast(QUBOTools.SpinDomain => QUBOTools.BoolDomain, _checked_spin_state(s)) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -197,13 +144,10 @@ For non-symmetric matrices, the QUBOTools form constructor preserves TenSolver's `dot(x, Q, x)` convention by storing the effective pair coefficient `Q[i, j] + Q[j, i]` once in the upper triangle. -<<<<<<< HEAD The Boolean-to-spin conversion introduces halves and quarters. Integer coefficient inputs therefore return floating-point forms, while rational inputs preserve exact rational arithmetic. -======= ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) The returned form includes the constant offset, so ```julia @@ -213,7 +157,6 @@ dot(x, Q, x) + dot(l, x) + c == QUBOTools.value(bool_to_spin(x), qubo_to_ising(Q for every Boolean vector `x`. """ function qubo_to_ising(Q::AbstractMatrix, l::Union{Nothing, AbstractVector}=nothing, c::Real=0; convention::Symbol=:spin) -<<<<<<< HEAD check_spin_convention(convention) return qubo_to_ising(qubo_form(Q, l, c); convention) end @@ -222,16 +165,6 @@ function qubo_to_ising(form::QUBOTools.AbstractForm; convention::Symbol=:spin) check_spin_convention(convention) check_form_domain(form, QUBOTools.BoolDomain, "QUBO-to-Ising") return drop_form_zeros!(QUBOTools.cast(QUBOTools.SpinDomain, form)) -======= - _check_spin_convention(convention) - return qubo_to_ising(_qubo_form(Q, l, c); convention) -end - -function qubo_to_ising(form::QUBOTools.AbstractForm; convention::Symbol=:spin) - _check_spin_convention(convention) - _check_form_domain(form, QUBOTools.BoolDomain, "QUBO-to-Ising") - return _drop_form_zeros!(QUBOTools.cast(QUBOTools.SpinDomain, form)) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end """ @@ -249,7 +182,6 @@ dot(s, J, s) + dot(h, s) + offset with `s_i in {-1, +1}`. QUBOTools folds diagonal quadratic spin terms into the constant offset and stores each off-diagonal unordered pair once in the upper triangle. -<<<<<<< HEAD As with [`qubo_to_ising`](@ref), integer coefficient inputs return floating-point forms when the conversion introduces fractional coefficients, @@ -290,14 +222,4 @@ function ising_energy(J::AbstractMatrix, h::AbstractVector, s::AbstractVector, o end return energy -======= -""" -function ising_to_qubo(form::QUBOTools.AbstractForm) - _check_form_domain(form, QUBOTools.SpinDomain, "Ising-to-QUBO") - return _drop_form_zeros!(QUBOTools.cast(QUBOTools.BoolDomain, form)) -end - -function ising_to_qubo(J::AbstractMatrix, h::AbstractVector, offset::Real=0) - return ising_to_qubo(_ising_form(J, h, offset)) ->>>>>>> a9f4c02 (Use QUBOTools forms for Ising conversions) end From 47b00955a3d9501931af2a606c86420d397a9c4c Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sun, 14 Jun 2026 07:08:45 -0400 Subject: [PATCH 11/23] Clarify backend extension errors --- src/solver.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/solver.jl b/src/solver.jl index da0f0f2..c381484 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -20,9 +20,10 @@ backends implement `minimize(::MyBackend, p::AbstractPolynomial; kwargs...)`. Extensions that support symbolic selection must also define `normalize_backend(::Val{:my_backend}) = MyBackend(...)`. -# See also +The default implementation is [`DMRGBackend`](@ref). -[`DMRGBackend`](@ref), [`normalize_backend`](@ref) +# See also +[`DMRGBackend`](@ref), [`normalize_backend`](@ref). """ abstract type AbstractTenSolverBackend end From a7f36c1620d31b7b2a49e9555b76289c45b4dc48 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Sun, 14 Jun 2026 07:24:32 -0400 Subject: [PATCH 12/23] Address PEPS backend review follow-ups --- ext/TenSolverSpinGlassPEPSExt.jl | 33 +++++++++++++++++++++++++------- src/TenSolver.jl | 1 - src/ising.jl | 19 ++++++++++++++++++ src/solver.jl | 4 ++-- test/backend.jl | 2 +- test/peps_backend.jl | 13 ++++++++++++- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/ext/TenSolverSpinGlassPEPSExt.jl b/ext/TenSolverSpinGlassPEPSExt.jl index 907688c..053f900 100644 --- a/ext/TenSolverSpinGlassPEPSExt.jl +++ b/ext/TenSolverSpinGlassPEPSExt.jl @@ -140,19 +140,23 @@ function _deduplicated_records(records) sort!(records; by = r -> (r.energy, -r.probability)) deduped = NamedTuple[] - seen = Set{Any}() + positions = Dict{Any, Int}() for record in records key = Tuple(record.state) - if !(key in seen) + index = get(positions, key, nothing) + if isnothing(index) push!(deduped, record) - push!(seen, key) + positions[key] = lastindex(deduped) + else + existing = deduped[index] + deduped[index] = (; existing..., probability = existing.probability + record.probability) end end return deduped end -function _metadata(backend::PEPSBackend, records, raw_results) +function _metadata(backend::PEPSBackend, records, raw_results, failures) best = first(records) raw = raw_results[best.transformation] return Dict{String, Any}( @@ -169,6 +173,7 @@ function _metadata(backend::PEPSBackend, records, raw_results) "graduate_truncation" => backend.graduate_truncation, "local_dimension" => backend.local_dimension, "transformations_tried" => collect(string.(keys(raw_results))), + "transformations_failed" => [string(failure.transformation) for failure in failures], "selected_transformation" => string(best.transformation), "spin_glass_energies" => collect(raw.solution.energies), "spin_glass_probabilities" => collect(raw.solution.probabilities), @@ -202,6 +207,7 @@ function TenSolver._solve_ising(backend::PEPSBackend, model::IsingModel; cutoff records = NamedTuple[] raw_results = Dict{Any, Any}() + failures = NamedTuple[] for transform in _transformations(backend.transformations) try net = _network(backend.topology, potts_h, transform, S) @@ -223,18 +229,31 @@ function TenSolver._solve_ising(backend::PEPSBackend, model::IsingModel; cutoff raw_results[transform] = (; solution = sol, info) append!(records, _decoded_records(model, potts_h, sol, transform)) + catch err + push!(failures, (; + transformation = transform, + error = sprint(showerror, err), + )) + verbosity > 0 && @warn "SpinGlassPEPS transformation failed" transformation = transform exception = (err, catch_backtrace()) finally SpinGlassEngine.clear_memoize_cache() end end - isempty(records) && throw(ArgumentError("SpinGlassPEPS did not return any states.")) + if isempty(records) + if isempty(failures) + throw(ArgumentError("SpinGlassPEPS did not return any states.")) + end + + failure_summary = join(("$(failure.transformation): $(failure.error)" for failure in failures), "; ") + throw(ArgumentError("SpinGlassPEPS did not return any states. Failed transformations: $failure_summary")) + end records = _deduplicated_records(records) states = [record.state for record in records] energies = S[record.energy for record in records] probabilities = S[record.probability for record in records] - metadata = _metadata(backend, records, raw_results) - raw = (; results = raw_results) + metadata = _metadata(backend, records, raw_results, failures) + raw = (; results = raw_results, failures) verbosity > 0 && @info "SpinGlassPEPS backend finished" energy = first(energies) states = length(states) diff --git a/src/TenSolver.jl b/src/TenSolver.jl index c2d5a31..fcb2ea5 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -21,7 +21,6 @@ include("projection_mpo.jl") include("solution.jl") export sample -export Solution include("ising.jl") export bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo diff --git a/src/ising.jl b/src/ising.jl index 0fc8ce4..97f1885 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -167,6 +167,23 @@ function qubo_to_ising(form::QUBOTools.AbstractForm; convention::Symbol=:spin) return drop_form_zeros!(QUBOTools.cast(QUBOTools.SpinDomain, form)) end +function ising_energy(model::IsingModel{T}, s::AbstractVector) where {T} + length(s) == length(model.h) || throw(DimensionMismatch("Spin vector length must match the Ising model size. Encountered length $(length(s)) and model size $(length(model.h)).")) + spin_to_bool(s) + + energy = model.offset + sum(model.h[i] * T(s[i]) for i in eachindex(model.h)) + rows, cols, vals = findnz(model.J) + for k in eachindex(vals) + i = rows[k] + j = cols[k] + if i < j + energy += vals[k] * T(s[i]) * T(s[j]) + end + end + + return energy +end + """ ising_to_qubo(form) ising_to_qubo(J, h[, offset]) @@ -192,6 +209,8 @@ function ising_to_qubo(form::QUBOTools.AbstractForm) return drop_form_zeros!(QUBOTools.cast(QUBOTools.BoolDomain, form)) end +ising_to_qubo(model::IsingModel) = ising_to_qubo(_ising_form(model.J, model.h, model.offset)) + function ising_to_qubo(J::AbstractMatrix, h::AbstractVector, offset::Real=0) return ising_to_qubo(ising_form(J, h, offset)) end diff --git a/src/solver.jl b/src/solver.jl index c381484..f9cb695 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -280,8 +280,8 @@ function _solve_ising(backend::AbstractTenSolverBackend, model::IsingModel; kwar end function _solve_ising(::DMRGBackend, model::IsingModel; kwargs...) - qubo = ising_to_qubo(model) - return _minimize(default_backend, qubo.Q, qubo.l, qubo.c; kwargs...) + Q, l, c = _scaled_form_parts(ising_to_qubo(model)) + return _minimize(default_backend, Q, l, c; kwargs...) end diff --git a/test/backend.jl b/test/backend.jl index 7445817..ba3a7f8 100644 --- a/test/backend.jl +++ b/test/backend.jl @@ -34,7 +34,7 @@ end J = [0.0 0.5; 1.0 0.0] h = [-0.25, 0.75] offset = 2.0 - model = IsingModel(J, h, offset) + model = TenSolver.IsingModel(J, h, offset) spin_states = [[s1, s2] for s1 in (-1, 1) for s2 in (-1, 1)] energies = [TenSolver.ising_energy(model, spin) for spin in spin_states] expected_energy, expected_index = findmin(energies) diff --git a/test/peps_backend.jl b/test/peps_backend.jl index 43d27bd..4fe9930 100644 --- a/test/peps_backend.jl +++ b/test/peps_backend.jl @@ -1,4 +1,5 @@ @testset "PEPS backend core" begin + @test !(:Solution in names(TenSolver)) @test !(:PEPSBackend in names(TenSolver)) @test !(:SquareGrid in names(TenSolver)) @test !(:KingGrid in names(TenSolver)) @@ -43,7 +44,7 @@ @test occursin("PEPSBackend is not available", sprint(showerror, peps_error)) @test occursin("SpinGlassNetworks", sprint(showerror, peps_error)) - model = IsingModel(zeros(1, 1), [-1.0]) + model = TenSolver.IsingModel(zeros(1, 1), [-1.0]) energy, solution = solve_ising(model; backend = :dmrg, verbosity = 0) @test energy ≈ -1.0 @test sample(solution) == [1] @@ -62,6 +63,16 @@ @test !([0, 0] in peps_solution) @test TenSolver.prob(peps_solution, [0, 1]) ≈ 1.0 + duplicate_state_solution = TenSolver.PEPSSolution{Float64}( + [[1, 0], [0, 1], [1, 0]], + [-2.0, -1.0, -2.0], + [0.2, 0.3, 0.4], + Dict{String, Any}("backend" => "SpinGlassPEPS"), + nothing, + ) + @test TenSolver.prob(duplicate_state_solution, [1, 0]) ≈ 0.6 + @test TenSolver.prob(duplicate_state_solution, [0, 1]) ≈ 0.3 + @test_throws ArgumentError sample(TenSolver.PEPSSolution{Float64}( [[1, 0], [0, 1]], [-2.0, -1.0], From 31428c610bfa95cff65da46af202d5db33f0e298 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 22 Jun 2026 20:37:04 -0400 Subject: [PATCH 13/23] Clarify PEPS scaffolding issue scope --- docs/src/spinglasspeps_integration.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md index a8169d1..604c044 100644 --- a/docs/src/spinglasspeps_integration.md +++ b/docs/src/spinglasspeps_integration.md @@ -200,6 +200,11 @@ gated tests are kept in this stack step so the TenSolver boundary is concrete, but the PEPS backend types are not exported or listed in the public API until CI can exercise the SpinGlass component stack. +Until that activation path is exercised, this stack step is scaffolding rather +than the final completion of the PEPS backend issue. The closing PR should +include a passing small structured-grid CPU solve through the optional +SpinGlass component stack. + The initial internal structured topology scaffolding covers one-spin-per-site and multi-spin-per-site square/king grids. QUBO inputs are converted through [`qubo_to_ising`](@ref) before the PEPS extension builds a SpinGlassNetworks @@ -224,8 +229,9 @@ The integration should be implemented as a sequence of stacked PRs: energy-preservation tests. 3. Introduce a backend interface while keeping the current DMRG backend as the default implementation. -4. Add an optional SpinGlassPEPS-backed structured solver path for direct - structured inputs. +4. Add internal optional SpinGlassPEPS-backed structured solver scaffolding for + direct structured inputs, without closing the issue until the real extension + path is exercised. 5. Expose the PEPS backend through QUBODrivers/JuMP attributes, including layout and contraction/search parameters. 6. Add user documentation, examples, and benchmark scripts that compare the DMRG From cacbc2f55b6b4b95204b6e15e1dfa37812fc9e68 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 13 Jul 2026 02:57:11 -0400 Subject: [PATCH 14/23] Align solve_ising backend dispatch with no-underscore convention Rename _solve_ising to solve_ising methods, matching how minimize(::AbstractTenSolverBackend, ...) dispatches on main after the repo-wide removal of leading underscores from unexported helpers. Extension-private helpers in TenSolverSpinGlassPEPSExt keep their names since the optional stack cannot be exercised by tests yet. --- ext/TenSolverSpinGlassPEPSExt.jl | 2 +- src/solver.jl | 30 ++++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/ext/TenSolverSpinGlassPEPSExt.jl b/ext/TenSolverSpinGlassPEPSExt.jl index 053f900..a964521 100644 --- a/ext/TenSolverSpinGlassPEPSExt.jl +++ b/ext/TenSolverSpinGlassPEPSExt.jl @@ -181,7 +181,7 @@ function _metadata(backend::PEPSBackend, records, raw_results, failures) ) end -function TenSolver._solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = nothing, verbosity = 1, kwargs...) +function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = nothing, verbosity = 1, kwargs...) if !isempty(kwargs) names = join(string.(keys(kwargs)), ", ") throw(ArgumentError("Unsupported PEPS backend keyword(s): $names. Configure PEPSBackend instead.")) diff --git a/src/solver.jl b/src/solver.jl index f9cb695..9c5b1ec 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -254,6 +254,24 @@ function minimize(backend::AbstractTenSolverBackend, args...; kwargs...) throw(backend_error(backend)) end +function minimize( + backend::PEPSBackend, + Q::AbstractMatrix{T}, + l::Union{AbstractVector{T}, Nothing}=nothing, + c::T=zero(T) + ; + cutoff=1e-8, + preprocess::Bool=false, + kwargs..., +) where T + preprocess && throw(ArgumentError("PEPSBackend does not support preprocess=true because the topology fixes the variable order. Use backend = :dmrg for preprocessed QUBO solves.")) + return solve_ising(backend, IsingModel(qubo_to_ising(Q, l, c)); cutoff, kwargs...) +end + +function minimize(backend::PEPSBackend, p::AbstractPolynomial; kwargs...) + throw(ArgumentError("PEPSBackend does not support polynomial inputs directly. Convert to a structured QUBO or call solve_ising with a supported topology.")) +end + """ solve_ising(model; backend = DMRGBackend(), kwargs...) solve_ising(J, h[, offset]; backend = DMRGBackend(), kwargs...) @@ -268,20 +286,20 @@ this boundary directly. function solve_ising end function solve_ising(model::IsingModel; backend=default_backend, kwargs...) - return _solve_ising(_normalize_backend(backend), model; kwargs...) + return solve_ising(normalize_backend(backend), model; kwargs...) end function solve_ising(J::AbstractMatrix, h::AbstractVector, offset::Real=0; backend=default_backend, kwargs...) return solve_ising(IsingModel(J, h, offset); backend, kwargs...) end -function _solve_ising(backend::AbstractTenSolverBackend, model::IsingModel; kwargs...) - throw(_backend_error(backend)) +function solve_ising(backend::AbstractTenSolverBackend, model::IsingModel; kwargs...) + throw(backend_error(backend)) end -function _solve_ising(::DMRGBackend, model::IsingModel; kwargs...) - Q, l, c = _scaled_form_parts(ising_to_qubo(model)) - return _minimize(default_backend, Q, l, c; kwargs...) +function solve_ising(::DMRGBackend, model::IsingModel; kwargs...) + Q, l, c = scaled_form_parts(ising_to_qubo(model)) + return minimize(default_backend, Q, l, c; kwargs...) end From 673cd6300c9337c07ae50c242efcd11ef680ad29 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 13 Jul 2026 02:59:45 -0400 Subject: [PATCH 15/23] Fix stale _ising_form reference after main sync ising_to_qubo(::IsingModel) still called the pre-rename _ising_form helper, which main renamed to ising_form. Caught by the backend interface test suite (solve_ising pair-coupling regression). --- src/ising.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ising.jl b/src/ising.jl index 97f1885..32787e9 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -209,7 +209,7 @@ function ising_to_qubo(form::QUBOTools.AbstractForm) return drop_form_zeros!(QUBOTools.cast(QUBOTools.BoolDomain, form)) end -ising_to_qubo(model::IsingModel) = ising_to_qubo(_ising_form(model.J, model.h, model.offset)) +ising_to_qubo(model::IsingModel) = ising_to_qubo(ising_form(model.J, model.h, model.offset)) function ising_to_qubo(J::AbstractMatrix, h::AbstractVector, offset::Real=0) return ising_to_qubo(ising_form(J, h, offset)) From f244b53e41e72c08104634ce7a7f8eefec1cee64 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:51:34 -0400 Subject: [PATCH 16/23] Restore IsingModel struct lost in rebase; drop duplicated ising include The rebase onto main dropped the IsingModel struct and its validating constructor (with diagonal-coupling folding into the offset) while five call sites still referenced the type, so the package no longer precompiled. It also left src/ising.jl included twice in TenSolver.jl, which duplicated every docstring and broke the Documentation build. --- src/TenSolver.jl | 3 --- src/ising.jl | 25 ++++++++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/TenSolver.jl b/src/TenSolver.jl index fcb2ea5..0cddd84 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -22,9 +22,6 @@ include("projection_mpo.jl") include("solution.jl") export sample -include("ising.jl") -export bool_to_spin, spin_to_bool, qubo_to_ising, ising_to_qubo - include("solver.jl") export minimize, maximize, solve_ising export AbstractTenSolverBackend, DMRGBackend diff --git a/src/ising.jl b/src/ising.jl index 32787e9..542dbd6 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -1,13 +1,32 @@ -import SparseArrays: findnz, sparse, dropzeros! +import SparseArrays: SparseMatrixCSC, findnz, sparse, dropzeros! + +struct IsingModel{T <: Real} + J :: SparseMatrixCSC{T, Int} + h :: Vector{T} + offset :: T +end + +function IsingModel(J::AbstractMatrix{<:Real}, h::AbstractVector{<:Real}, offset::Real=0) + issquare(J) || throw(DimensionMismatch("The Ising coupling matrix must be square. Encountered dimensions $(size(J)).")) + size(J, 1) == length(h) || throw(DimensionMismatch("The Ising field vector length must match the coupling matrix size. Encountered dimensions $(size(J)) and length $(length(h)).")) + + T = promote_type(eltype(J), eltype(h), typeof(offset)) + couplings, diagonal_offset = canonical_ising_couplings(J, T) + return IsingModel{T}(couplings, collect(T, h), T(offset) + diagonal_offset) +end function canonical_ising_couplings(J::AbstractMatrix, ::Type{T}) where {T} couplings = Dict{Tuple{Int, Int}, T}() + diagonal_offset = zero(T) rows, cols, vals = findnz(sparse(T.(J))) for k in eachindex(vals) i = rows[k] j = cols[k] - i == j && continue + if i == j + diagonal_offset += vals[k] + continue + end a, b = minmax(i, j) key = (a, b) @@ -26,7 +45,7 @@ function canonical_ising_couplings(J::AbstractMatrix, ::Type{T}) where {T} end end - return sparse(out_rows, out_cols, out_vals, size(J, 1), size(J, 2)) + return sparse(out_rows, out_cols, out_vals, size(J, 1), size(J, 2)), diagonal_offset end function conversion_type(Q::AbstractMatrix, l, c) From ca15a9106d201967948e4c409fefb7557d411c82 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:52:00 -0400 Subject: [PATCH 17/23] Merge duplicated SpinGlassPEPS design doc into docs/internal The rebase left the design note in both docs/src and docs/internal. Keep the newer content under docs/internal (it is a developer note, not user documentation) and drop it from the public docs nav. --- docs/DOCUMENTATION.md | 1 - docs/internal/spinglasspeps_integration.md | 40 ++- docs/make.jl | 1 - docs/src/spinglasspeps_integration.md | 269 --------------------- 4 files changed, 35 insertions(+), 276 deletions(-) delete mode 100644 docs/src/spinglasspeps_integration.md diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index e9a0a7e..c033f26 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -8,7 +8,6 @@ The documentation is built using [Documenter.jl](https://github.com/JuliaDocs/Do - **Home** (`docs/src/index.md`): Overview, installation, quick start, and features - **Examples** (`docs/src/examples.md`): Practical examples showing how to use TenSolver.jl -- **SpinGlassPEPS Integration** (`docs/src/spinglasspeps_integration.md`): Planned architecture for the optional structured PEPS backend - **API Reference** (`docs/src/api.md`): Complete API documentation with docstrings Internal developer notes live outside the generated user documentation: diff --git a/docs/internal/spinglasspeps_integration.md b/docs/internal/spinglasspeps_integration.md index b8c4fa7..604c044 100644 --- a/docs/internal/spinglasspeps_integration.md +++ b/docs/internal/spinglasspeps_integration.md @@ -9,7 +9,8 @@ current TenSolver release. The central decision is that SpinGlassPEPS should be integrated as an optional structured-graph backend. It should not replace TenSolver's current ITensor-based DMRG backend, become a hard runtime dependency, or change the -default behavior of [`minimize`](@ref), [`maximize`](@ref), or the JuMP optimizer. +default behavior of [`minimize`](@ref), [`maximize`](@ref), or the JuMP +optimizer. ## Current TenSolver Boundary @@ -180,8 +181,36 @@ learn the SpinGlassPEPS API. The current behavior is: - `backend = :dmrg` selects the current path explicitly; - `backend = DMRGBackend()` selects the current path explicitly through the backend-object interface; and -- `backend = :peps` errors clearly until a later optional bridge package or - extension provides the structured backend. +- unavailable backend symbols error clearly without changing default DMRG + behavior. + +This stack step keeps the direct PEPS path as non-public scaffolding. The core +package contains internal backend, topology, and result boundaries, while the +exported `solve_ising` function is the public Ising boundary that optional +structured backends may implement. `TenSolverSpinGlassPEPSExt` owns the +SpinGlass component imports and calls. This keeps ordinary TenSolver installs +on the existing dependency footprint and avoids documenting an activation path +that cannot be tested from registered packages. + +The extension remains gated while the upstream dependency stack settles. In +local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, and +SpinGlassTensors 1.3, the current registered component compat bounds do not +resolve with TenSolver's ITensors/QUBOTools environment. The source bridge and +gated tests are kept in this stack step so the TenSolver boundary is concrete, +but the PEPS backend types are not exported or listed in the public API until +CI can exercise the SpinGlass component stack. + +Until that activation path is exercised, this stack step is scaffolding rather +than the final completion of the PEPS backend issue. The closing PR should +include a passing small structured-grid CPU solve through the optional +SpinGlass component stack. + +The initial internal structured topology scaffolding covers one-spin-per-site +and multi-spin-per-site square/king grids. QUBO inputs are converted through +[`qubo_to_ising`](@ref) before the PEPS extension builds a SpinGlassNetworks +Ising graph, clusters it with `super_square_lattice`, constructs the Potts +Hamiltonian, runs `MpsContractor` plus `low_energy_spectrum`, and decodes +retained states back to TenSolver Boolean vectors. Later PRs should add QUBODrivers/JuMP raw optimizer attributes for backend and PEPS parameters. @@ -200,8 +229,9 @@ The integration should be implemented as a sequence of stacked PRs: energy-preservation tests. 3. Introduce a backend interface while keeping the current DMRG backend as the default implementation. -4. Add an optional SpinGlassPEPS-backed structured solver path for direct - structured inputs. +4. Add internal optional SpinGlassPEPS-backed structured solver scaffolding for + direct structured inputs, without closing the issue until the real extension + path is exercised. 5. Expose the PEPS backend through QUBODrivers/JuMP attributes, including layout and contraction/search parameters. 6. Add user documentation, examples, and benchmark scripts that compare the DMRG diff --git a/docs/make.jl b/docs/make.jl index 43980db..3d112c4 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,7 +14,6 @@ makedocs( pages = [ "Home" => "index.md", "Examples" => "examples.md", - "SpinGlassPEPS Integration" => "spinglasspeps_integration.md", "API Reference" => "api.md", ], repo = Documenter.Remotes.GitHub("SECQUOIA", "TenSolver.jl"), diff --git a/docs/src/spinglasspeps_integration.md b/docs/src/spinglasspeps_integration.md deleted file mode 100644 index 604c044..0000000 --- a/docs/src/spinglasspeps_integration.md +++ /dev/null @@ -1,269 +0,0 @@ -# SpinGlassPEPS Integration Architecture - -This page records the planned boundary between TenSolver.jl and the -SpinGlassPEPS.jl work described in -[arXiv:2411.16431](https://arxiv.org/abs/2411.16431). It is a design note for -the implementation stack, not a description of behavior available in the -current TenSolver release. - -The central decision is that SpinGlassPEPS should be integrated as an optional -structured-graph backend. It should not replace TenSolver's current -ITensor-based DMRG backend, become a hard runtime dependency, or change the -default behavior of [`minimize`](@ref), [`maximize`](@ref), or the JuMP -optimizer. - -## Current TenSolver Boundary - -TenSolver currently accepts Boolean optimization models: - -- QUBO matrices, with optional linear and constant terms. -- PUBO polynomials through MultivariatePolynomials.jl. -- QUBODrivers/JuMP models that are converted to QUBO data before solving. - -The current solver path tensorizes the objective into an ITensors.jl MPO over -Boolean variables represented as two-dimensional qudit sites. It then applies -DMRG through ITensorMPS.jl and returns: - -- the best sampled objective value; and -- a [`Solution`](@ref), which wraps the MPS and per-iteration convergence - traces. - -This backend is general with respect to the variable ordering and QUBO/PUBO -interaction pattern. It does not require a graph layout such as a grid, -Pegasus, or Zephyr topology. - -## Proposed PEPS Backend Boundary - -SpinGlassPEPS.jl targets a different solver boundary. The package itself is an -umbrella package that reexports component packages including: - -- SpinGlassNetworks.jl for Ising graph construction and lattice/cluster rules. -- SpinGlassEngine.jl for Potts Hamiltonians, branch-and-bound search, low-energy - spectra, and droplet reconstruction. -- SpinGlassTensors.jl for PEPS construction and boundary-MPS contraction. -- SpinGlassExhaustive.jl for exhaustive utilities. - -The algorithm builds a finite-temperature PEPS representation of a Boltzmann -distribution for a clustered Ising/Potts Hamiltonian. It uses approximate -boundary-MPS contractions to estimate conditional probabilities and a -branch-and-bound sweep to retain the most probable low-energy configurations. -It is designed for quasi-two-dimensional graph layouts with useful locality, -including square/king-grid style layouts and, at larger scale, Pegasus and -Zephyr processor topologies. - -That shape makes it complementary to TenSolver's current DMRG backend rather -than a drop-in replacement. The PEPS path should be selected only when the -caller can provide, or TenSolver can infer, a supported structured layout. - -## Supported Problem Classes - -The initial backend split should be: - -- `DMRG`: the default backend for general QUBO/PUBO inputs and arbitrary - variable orderings. -- `PEPS`: an optional backend for structured Ising/Potts graph inputs that can - be embedded into a supported quasi-two-dimensional layout. - -The first PEPS implementation should start with small square-grid and king-grid -layouts. Those are easier to validate, have smaller clusters, and exercise the -same data-model boundary without immediately requiring the memory footprint and -GPU-sensitive execution path needed for large Pegasus or Zephyr instances. - -Pegasus and Zephyr should be treated as later targets. The paper and -SpinGlassPEPS documentation describe large unit cells, up to 24 spins for -Pegasus and 16 spins for Zephyr, and emphasize that sparse structures, local -dimensional reduction, contraction settings, and GPU execution can become -important. TenSolver should not expose those as a default path before the bridge -has small-layout correctness tests and clear user-facing configuration. - -Dense arbitrary QUBOs should remain on the current DMRG path unless an explicit -embedding or layout mapping is provided. PEPS should not be advertised as a -universal improvement for all QUBOs. - -## Dependency Policy - -SpinGlassPEPS should not be added as a hard dependency in the first -implementation steps. - -There are three reasons for this: - -- TenSolver currently supports Julia 1.10, while SpinGlassPEPS 1.5.0 declares - Julia 1.11 compatibility. -- The SpinGlassPEPS component stack brings a larger numerical dependency - footprint, including packages used for tensor operations, truncated - decompositions, CPU/GPU execution, and documentation/examples. -- Most TenSolver users who call the existing DMRG backend should not pay load - time, installation, or GPU compatibility costs for an optional structured - backend. - -The preferred integration shape is a Julia package extension or a small bridge -package that is loaded only when the SpinGlassPEPS component packages are -available. The core TenSolver package should define the stable data and result -interfaces that the bridge implements. The bridge should depend on the -SpinGlassPEPS components directly when that is cleaner than depending on the -umbrella reexport package. - -## Data Model Boundary - -TenSolver's public inputs are Boolean optimization models. The PEPS solver -expects structured spin-glass data. The bridge must therefore make the -conversion explicit. - -The expected data flow is: - -1. Normalize TenSolver input to a Boolean QUBO representation. -2. Convert Boolean variables `x in {0, 1}` to Ising spins `s in {-1, +1}` using - one documented convention. -3. Build an Ising graph with local fields and pair couplings. -4. Attach a supported layout or cluster assignment rule. -5. Build the corresponding Potts Hamiltonian and PEPS network. -6. Run boundary-MPS contraction plus branch-and-bound search. -7. Decode returned Potts/Ising states back to Boolean vectors. -8. Adapt results to TenSolver and QUBOTools result types. - -The QUBO-to-Ising conversion is important enough to land as its own PR before -the backend bridge. It should include exact round-trip and energy-preservation -tests for small instances. The implementation should make sign and constant -offset conventions visible because mistakes there can silently return plausible -but wrong energies. - -TenSolver uses the Boolean/spin convention `x_i = (s_i + 1) / 2` and -`s_i = 2x_i - 1`. The conversion utilities are adapters into QUBOTools forms, -not a separate conversion implementation. They preserve TenSolver's current -QUBO truth objective, `dot(x, Q, x) + dot(l, x) + c`; for non-symmetric -matrices the effective pair coefficient is therefore `Q[i, j] + Q[j, i]`. -`qubo_to_ising(Q, l, c)` returns a sparse QUBOTools form in `SpinDomain`, so -`QUBOTools.value(bool_to_spin(x), qubo_to_ising(Q, l, c))` matches the Boolean -objective for every bitstring `x`. - -Layout metadata should also be explicit. A PEPS backend call should know whether -the variables are in row-major square-grid order, king-grid order, or a later -Pegasus/Zephyr indexing convention. TenSolver should not guess a topology from a -dense QUBO matrix unless a future API defines that inference clearly. - -## Result Model Boundary - -The current TenSolver result is intentionally compact: `energy, Solution` from -the direct API and a QUBOTools `SampleSet` from the optimizer API. - -SpinGlassPEPS can produce richer output, including ranked energies, states, -probabilities, largest discarded probability during branch-and-bound, lattice -transformation information, retained singular-value diagnostics, and droplet -metadata. - -The bridge should preserve this information without leaking SpinGlassPEPS -internal types as the default public result. A practical result boundary is: - -- Direct TenSolver calls return the same high-level shape as today for ordinary - users. -- PEPS-specific metadata is stored in a backend metadata object or result field - that can be inspected by advanced users. -- QUBODrivers/JuMP calls continue to return a QUBOTools `SampleSet`, with PEPS - metadata stored under namespaced metadata keys. - -The first bridge should preserve at least: - -- ranked Boolean states and their objective values; -- PEPS probabilities when available; -- largest discarded probability when available; -- chosen layout and lattice transformation; -- branch-and-bound and contraction parameters; and -- droplet metadata when requested. - -## Backend Selection - -The default backend must remain the current DMRG implementation. - -TenSolver exposes a small backend-selection interface without forcing users to -learn the SpinGlassPEPS API. The current behavior is: - -- no backend argument means current DMRG behavior; -- `backend = :dmrg` selects the current path explicitly; -- `backend = DMRGBackend()` selects the current path explicitly through the - backend-object interface; and -- unavailable backend symbols error clearly without changing default DMRG - behavior. - -This stack step keeps the direct PEPS path as non-public scaffolding. The core -package contains internal backend, topology, and result boundaries, while the -exported `solve_ising` function is the public Ising boundary that optional -structured backends may implement. `TenSolverSpinGlassPEPSExt` owns the -SpinGlass component imports and calls. This keeps ordinary TenSolver installs -on the existing dependency footprint and avoids documenting an activation path -that cannot be tested from registered packages. - -The extension remains gated while the upstream dependency stack settles. In -local checks against SpinGlassNetworks 1.4, SpinGlassEngine 1.6, and -SpinGlassTensors 1.3, the current registered component compat bounds do not -resolve with TenSolver's ITensors/QUBOTools environment. The source bridge and -gated tests are kept in this stack step so the TenSolver boundary is concrete, -but the PEPS backend types are not exported or listed in the public API until -CI can exercise the SpinGlass component stack. - -Until that activation path is exercised, this stack step is scaffolding rather -than the final completion of the PEPS backend issue. The closing PR should -include a passing small structured-grid CPU solve through the optional -SpinGlass component stack. - -The initial internal structured topology scaffolding covers one-spin-per-site -and multi-spin-per-site square/king grids. QUBO inputs are converted through -[`qubo_to_ising`](@ref) before the PEPS extension builds a SpinGlassNetworks -Ising graph, clusters it with `super_square_lattice`, constructs the Potts -Hamiltonian, runs `MpsContractor` plus `low_energy_spectrum`, and decodes -retained states back to TenSolver Boolean vectors. - -Later PRs should add QUBODrivers/JuMP raw optimizer attributes for backend and -PEPS parameters. - -Any PEPS selection API must validate that the problem includes enough topology -metadata for the structured backend. If the topology is missing or unsupported, -the error should explain whether to provide a layout, use the DMRG backend, or -install/load the optional PEPS bridge. - -## Stacked PR Plan - -The integration should be implemented as a sequence of stacked PRs: - -1. Add this design document and link it from the documentation navigation. -2. Add QUBOTools-backed QUBO/Ising conversion adapters with exact - energy-preservation tests. -3. Introduce a backend interface while keeping the current DMRG backend as the - default implementation. -4. Add internal optional SpinGlassPEPS-backed structured solver scaffolding for - direct structured inputs, without closing the issue until the real extension - path is exercised. -5. Expose the PEPS backend through QUBODrivers/JuMP attributes, including - layout and contraction/search parameters. -6. Add user documentation, examples, and benchmark scripts that compare the DMRG - and PEPS paths on appropriate problem families. - -Each PR should remain useful on its own. The early PRs should avoid new runtime -dependencies and should preserve the current public behavior exactly. - -## Non-Goals - -This integration should not: - -- vendor SpinGlassPEPS internals into TenSolver; -- replace the existing `minimize` or `maximize` behavior; -- require GPU packages for installing or loading TenSolver; -- promise that PEPS is better for arbitrary dense QUBOs; -- infer complex hardware layouts without explicit metadata; or -- hide approximate-contraction limitations behind a generic solver name. - -## Risks - -The main technical risks are: - -- Boolean-to-spin convention mistakes, especially sign and constant-offset - errors. -- Topology mismatch between TenSolver variable order and the PEPS layout. -- Julia-version compatibility between TenSolver and SpinGlassPEPS. -- Increased dependency and load-time footprint if the bridge is not optional. -- Approximate contraction instability at high inverse temperature. -- Overstating sampling diversity from a branch-and-bound method that returns a - limited retained state set. - -The implementation stack should address these risks through exact small-instance -conversion tests, explicit topology metadata, optional dependency loading, and -documentation that describes the PEPS backend as a structured heuristic solver. From b5615acdb2a0e2e2bffa6729fb8d36b7c2d16556 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:54:55 -0400 Subject: [PATCH 18/23] Move PEPS code to src/backends/peps.jl; drop underscore helper names Collect all PEPS-specific code (topologies, PEPSBackend, PEPSSolution, backend minimize methods) into src/backends/peps.jl, mirroring the DMRG backend layout. Rename the remaining underscore-prefixed helpers in the solver and the SpinGlassPEPS extension per review. Restore the backend_error(::PEPSBackend) method lost in the rebase and trim the preprocess error message to the backend-relevant part. --- ext/TenSolverSpinGlassPEPSExt.jl | 62 +++++----- src/backends/peps.jl | 197 +++++++++++++++++++++++++++++++ src/solution.jl | 58 --------- src/solver.jl | 122 +------------------ 4 files changed, 229 insertions(+), 210 deletions(-) create mode 100644 src/backends/peps.jl diff --git a/ext/TenSolverSpinGlassPEPSExt.jl b/ext/TenSolverSpinGlassPEPSExt.jl index a964521..b4f077e 100644 --- a/ext/TenSolverSpinGlassPEPSExt.jl +++ b/ext/TenSolverSpinGlassPEPSExt.jl @@ -16,12 +16,12 @@ import SpinGlassEngine import SpinGlassNetworks import SpinGlassTensors -function _peps_type(::IsingModel{T}) where {T} +function peps_float_type(::IsingModel{T}) where {T} return typeof(float(one(T))) end -function _ising_instance(model::IsingModel{T}) where {T} - S = _peps_type(model) +function ising_instance(model::IsingModel{T}) where {T} + S = peps_float_type(model) instance = Dict{Tuple{Int, Int}, S}() for i in eachindex(model.h) @@ -40,17 +40,17 @@ function _ising_instance(model::IsingModel{T}) where {T} return instance end -function _check_topology_size(topology, model::IsingModel) - expected = TenSolver._topology_size(topology) +function check_topology_size(topology, model::IsingModel) + expected = TenSolver.topology_size(topology) actual = length(model.h) actual == expected || throw(DimensionMismatch("PEPS topology $(repr(topology)) expects $expected spins, but the Ising model has $actual spins.")) end -_edge_supported(::SquareGrid, a::Tuple, b::Tuple) = abs(a[1] - b[1]) + abs(a[2] - b[2]) == 1 -_edge_supported(::KingGrid, a::Tuple, b::Tuple) = maximum(abs.(a .- b)) == 1 +edge_supported(::SquareGrid, a::Tuple, b::Tuple) = abs(a[1] - b[1]) + abs(a[2] - b[2]) == 1 +edge_supported(::KingGrid, a::Tuple, b::Tuple) = maximum(abs.(a .- b)) == 1 -function _check_layout_edges(topology, model::IsingModel, lattice) +function check_layout_edges(topology, model::IsingModel, lattice) rows, cols, vals = findnz(model.J) for k in eachindex(vals) i = rows[k] @@ -58,7 +58,7 @@ function _check_layout_edges(topology, model::IsingModel, lattice) if i < j && !iszero(vals[k]) ci = lattice[i] cj = lattice[j] - if ci != cj && !_edge_supported(topology, ci, cj) + if ci != cj && !edge_supported(topology, ci, cj) throw(ArgumentError( "Ising coupling ($i, $j) is not compatible with $(repr(topology)). " * "Use a compatible structured topology or backend = :dmrg." @@ -68,7 +68,7 @@ function _check_layout_edges(topology, model::IsingModel, lattice) end end -function _potts_hamiltonian(backend::PEPSBackend, ig, lattice) +function build_potts_hamiltonian(backend::PEPSBackend, ig, lattice) if isnothing(backend.local_dimension) return SpinGlassNetworks.potts_hamiltonian( ig; @@ -85,7 +85,7 @@ function _potts_hamiltonian(backend::PEPSBackend, ig, lattice) ) end -function _transformations(transformations) +function resolve_transformations(transformations) transformations === :all && return SpinGlassEngine.all_lattice_transformations transformations === :identity && return (SpinGlassEngine.rotation(0),) if transformations isa Symbol @@ -96,13 +96,13 @@ function _transformations(transformations) return (transformations,) end -function _strategy(backend::PEPSBackend) +function contraction_strategy(backend::PEPSBackend) backend.contraction in (:auto, :svd, :svd_truncate) && return SpinGlassEngine.SVDTruncate backend.contraction === :zipper && return SpinGlassEngine.Zipper throw(ArgumentError("Unsupported PEPS contraction $(repr(backend.contraction)).")) end -function _network(topology::SquareGrid, potts_h, transform, ::Type{T}) where {T} +function peps_network(topology::SquareGrid, potts_h, transform, ::Type{T}) where {T} return SpinGlassEngine.PEPSNetwork{ SpinGlassEngine.SquareSingleNode{SpinGlassEngine.GaugesEnergy}, SpinGlassEngine.Dense, @@ -110,7 +110,7 @@ function _network(topology::SquareGrid, potts_h, transform, ::Type{T}) where {T} }(topology.m, topology.n, potts_h, transform) end -function _network(topology::KingGrid, potts_h, transform, ::Type{T}) where {T} +function peps_network(topology::KingGrid, potts_h, transform, ::Type{T}) where {T} return SpinGlassEngine.PEPSNetwork{ SpinGlassEngine.KingSingleNode{SpinGlassEngine.GaugesEnergy}, SpinGlassEngine.Dense, @@ -118,7 +118,7 @@ function _network(topology::KingGrid, potts_h, transform, ::Type{T}) where {T} }(topology.m, topology.n, potts_h, transform) end -function _decoded_records(model::IsingModel, potts_h, sol, transform) +function decoded_records(model::IsingModel, potts_h, sol, transform) records = NamedTuple[] for i in eachindex(sol.states) decoded = SpinGlassNetworks.decode_potts_hamiltonian_state(potts_h, sol.states[i]) @@ -136,7 +136,7 @@ function _decoded_records(model::IsingModel, potts_h, sol, transform) return records end -function _deduplicated_records(records) +function deduplicated_records(records) sort!(records; by = r -> (r.energy, -r.probability)) deduped = NamedTuple[] @@ -156,13 +156,13 @@ function _deduplicated_records(records) return deduped end -function _metadata(backend::PEPSBackend, records, raw_results, failures) +function peps_metadata(backend::PEPSBackend, records, raw_results, failures) best = first(records) raw = raw_results[best.transformation] return Dict{String, Any}( "backend" => "SpinGlassPEPS", - "topology" => TenSolver._topology_name(backend.topology), - "topology_size" => TenSolver._topology_tuple(backend.topology), + "topology" => TenSolver.topology_name(backend.topology), + "topology_size" => TenSolver.topology_tuple(backend.topology), "beta" => backend.beta, "bond_dim" => backend.bond_dim, "max_states" => backend.max_states, @@ -187,14 +187,14 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = throw(ArgumentError("Unsupported PEPS backend keyword(s): $names. Configure PEPSBackend instead.")) end - _check_topology_size(backend.topology, model) + check_topology_size(backend.topology, model) - S = _peps_type(model) - instance = _ising_instance(model) + S = peps_float_type(model) + instance = ising_instance(model) ig = SpinGlassNetworks.ising_graph(S, instance) - lattice = SpinGlassNetworks.super_square_lattice(TenSolver._topology_tuple(backend.topology)) - _check_layout_edges(backend.topology, model, lattice) - potts_h = _potts_hamiltonian(backend, ig, lattice) + lattice = SpinGlassNetworks.super_square_lattice(TenSolver.topology_tuple(backend.topology)) + check_layout_edges(backend.topology, model, lattice) + potts_h = build_potts_hamiltonian(backend, ig, lattice) params = SpinGlassEngine.MpsParameters{S}(; bond_dim = backend.bond_dim, num_sweeps = backend.num_sweeps, @@ -203,14 +203,14 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = max_states = backend.max_states, cutoff_prob = backend.cutoff_prob, ) - strategy = _strategy(backend) + strategy = contraction_strategy(backend) records = NamedTuple[] raw_results = Dict{Any, Any}() failures = NamedTuple[] - for transform in _transformations(backend.transformations) + for transform in resolve_transformations(backend.transformations) try - net = _network(backend.topology, potts_h, transform, S) + net = peps_network(backend.topology, potts_h, transform, S) ctr = SpinGlassEngine.MpsContractor( strategy, net, @@ -228,7 +228,7 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = ) raw_results[transform] = (; solution = sol, info) - append!(records, _decoded_records(model, potts_h, sol, transform)) + append!(records, decoded_records(model, potts_h, sol, transform)) catch err push!(failures, (; transformation = transform, @@ -248,11 +248,11 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = failure_summary = join(("$(failure.transformation): $(failure.error)" for failure in failures), "; ") throw(ArgumentError("SpinGlassPEPS did not return any states. Failed transformations: $failure_summary")) end - records = _deduplicated_records(records) + records = deduplicated_records(records) states = [record.state for record in records] energies = S[record.energy for record in records] probabilities = S[record.probability for record in records] - metadata = _metadata(backend, records, raw_results, failures) + metadata = peps_metadata(backend, records, raw_results, failures) raw = (; results = raw_results, failures) verbosity > 0 && @info "SpinGlassPEPS backend finished" energy = first(energies) states = length(states) diff --git a/src/backends/peps.jl b/src/backends/peps.jl new file mode 100644 index 0000000..fef0de6 --- /dev/null +++ b/src/backends/peps.jl @@ -0,0 +1,197 @@ +#----------------------------------------------------------# +# Optional SpinGlassPEPS structured backend # +#----------------------------------------------------------# +# +# The solve itself is implemented by the `TenSolverSpinGlassPEPSExt` package +# extension, which loads only when `SpinGlassNetworks`, `SpinGlassEngine`, and +# `SpinGlassTensors` are available. Without those packages this backend errors +# clearly and the default DMRG backend remains unchanged. + +abstract type AbstractStructuredTopology end + +# Structured square-grid topology for optional PEPS solves. +# +# Variables are assumed to be ordered according to SpinGlassNetworks' +# `super_square_lattice((m, n, spins_per_site))` convention. +struct SquareGrid <: AbstractStructuredTopology + m :: Int + n :: Int + spins_per_site :: Int + + function SquareGrid(m::Integer, n::Integer, spins_per_site::Integer=1) + m > 0 || throw(ArgumentError("SquareGrid requires m > 0. Got $m.")) + n > 0 || throw(ArgumentError("SquareGrid requires n > 0. Got $n.")) + spins_per_site > 0 || throw(ArgumentError("SquareGrid requires spins_per_site > 0. Got $spins_per_site.")) + return new(Int(m), Int(n), Int(spins_per_site)) + end +end + +# Structured king-grid topology for optional PEPS solves. It uses the same +# variable ordering as `SquareGrid`, but the PEPS compatibility graph also +# allows diagonal interactions between neighboring grid cells. +struct KingGrid <: AbstractStructuredTopology + m :: Int + n :: Int + spins_per_site :: Int + + function KingGrid(m::Integer, n::Integer, spins_per_site::Integer=1) + m > 0 || throw(ArgumentError("KingGrid requires m > 0. Got $m.")) + n > 0 || throw(ArgumentError("KingGrid requires n > 0. Got $n.")) + spins_per_site > 0 || throw(ArgumentError("KingGrid requires spins_per_site > 0. Got $spins_per_site.")) + return new(Int(m), Int(n), Int(spins_per_site)) + end +end + +topology_size(topology::AbstractStructuredTopology) = topology.m * topology.n * topology.spins_per_site +topology_tuple(topology::AbstractStructuredTopology) = (topology.m, topology.n, topology.spins_per_site) +topology_name(::SquareGrid) = "square" +topology_name(::KingGrid) = "king" + +""" + PEPSBackend(topology; kwargs...) + +Select the optional SpinGlassPEPS structured backend for `minimize` and +`TenSolver.solve_ising`, targeting Ising models whose couplings fit the given +structured `topology` (`SquareGrid` or `KingGrid`). + +The solve is provided by the `TenSolverSpinGlassPEPSExt` package extension and +requires `SpinGlassNetworks`, `SpinGlassEngine`, and `SpinGlassTensors` to be +installed and loaded; without them, solves with this backend error clearly and +the default DMRG backend remains unchanged. + +This backend is experimental scaffolding: it is intentionally not exported +while the registered SpinGlass component dependency stack does not resolve in +the same environment as TenSolver's ITensors/QUBOTools stack, so CI cannot +exercise the extension. +""" +struct PEPSBackend{T <: AbstractStructuredTopology, S} <: AbstractTenSolverBackend + topology :: T + beta :: Float64 + bond_dim :: Int + max_states :: Int + cutoff_prob :: Float64 + onGPU :: Bool + contraction :: Symbol + num_sweeps :: Int + graduate_truncation :: Bool + transformations :: S + local_dimension :: Union{Nothing, Int} + no_cache :: Bool +end + +function PEPSBackend(topology::AbstractStructuredTopology; + beta::Real = 2.0, + bond_dim::Integer = 16, + max_states::Integer = 2^8, + cutoff_prob::Real = 1e-4, + onGPU::Bool = false, + contraction::Symbol = :auto, + num_sweeps::Integer = 1, + graduate_truncation::Bool = true, + transformations = :all, + local_dimension::Union{Nothing, Integer} = nothing, + no_cache::Bool = false) + beta > 0 && isfinite(beta) || throw(ArgumentError("PEPSBackend requires finite beta > 0. Got $beta.")) + bond_dim >= 1 || throw(ArgumentError("PEPSBackend requires bond_dim >= 1. Got $bond_dim.")) + max_states >= 1 || throw(ArgumentError("PEPSBackend requires max_states >= 1. Got $max_states.")) + cutoff_prob >= 0 || throw(ArgumentError("PEPSBackend requires cutoff_prob >= 0. Got $cutoff_prob.")) + num_sweeps >= 1 || throw(ArgumentError("PEPSBackend requires num_sweeps >= 1. Got $num_sweeps.")) + contraction in (:auto, :svd, :svd_truncate, :zipper) || + throw(ArgumentError("Unsupported PEPS contraction $(repr(contraction)). Use :auto, :svd, :svd_truncate, or :zipper.")) + if !isnothing(local_dimension) + local_dimension >= 1 || throw(ArgumentError("PEPSBackend requires local_dimension >= 1 when provided. Got $local_dimension.")) + end + + return PEPSBackend{typeof(topology), typeof(transformations)}( + topology, + Float64(beta), + Int(bond_dim), + Int(max_states), + Float64(cutoff_prob), + onGPU, + contraction, + Int(num_sweeps), + graduate_truncation, + transformations, + isnothing(local_dimension) ? nothing : Int(local_dimension), + no_cache, + ) +end + +backend_error(::PEPSBackend) = ArgumentError("PEPSBackend is not available. Install/load SpinGlassNetworks, SpinGlassEngine, and SpinGlassTensors to activate the PEPS extension, or use backend = :dmrg.") + +# Internal result scaffold for the optional SpinGlassPEPS extension. +# +# This type is not exported while the optional SpinGlassPEPS component stack is +# not covered by CI. The states are decoded to TenSolver Boolean vectors, and +# `energies` are objective values in the original TenSolver convention. +# Backend-specific diagnostics live in `metadata` and the raw extension result +# is stored in `raw`. +struct PEPSSolution{T <: Real} + states :: Vector{Vector{Int}} + energies :: Vector{T} + probabilities :: Vector{T} + metadata :: Dict{String, Any} + raw :: Any +end + +function sample(psi::PEPSSolution) + isempty(psi.states) && throw(ArgumentError("Cannot sample an empty PEPS solution.")) + if isempty(psi.probabilities) + return copy(first(psi.states)) + end + length(psi.probabilities) == length(psi.states) || + throw(ArgumentError("PEPS solution probabilities must match the number of retained states.")) + any(probability -> probability < 0, psi.probabilities) && + throw(ArgumentError("PEPS solution probabilities must be nonnegative.")) + + total = sum(psi.probabilities) + total > 0 || throw(ArgumentError("PEPS solution probabilities must have positive total weight.")) + + threshold = rand() * total + cumulative = zero(total) + for (state, probability) in zip(psi.states, psi.probabilities) + cumulative += probability + if threshold <= cumulative + return copy(state) + end + end + + return copy(last(psi.states)) +end + +sample(psi::PEPSSolution, n :: Integer) = [sample(psi) for _ in 1:n] + +# Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. +function Base.in(bs::AbstractVector, psi::PEPSSolution; cutoff = 1e-8) + return prob(psi, bs) > cutoff +end + +function prob(psi::PEPSSolution{T}, bs) where {T} + target = collect(Int, bs) + p = zero(T) + for (state, probability) in zip(psi.states, psi.probabilities) + if state == target + p += probability + end + end + return p +end + +function minimize( + backend::PEPSBackend, + Q::AbstractMatrix{T}, + l::Union{AbstractVector{T}, Nothing}=nothing, + c::T=zero(T) + ; + cutoff=1e-8, + preprocess::Bool=false, + kwargs..., +) where T + preprocess && throw(ArgumentError("PEPSBackend does not support preprocess=true because the topology fixes the variable order.")) + return solve_ising(backend, IsingModel(qubo_to_ising(Q, l, c)); cutoff, kwargs...) +end + +function minimize(backend::PEPSBackend, p::AbstractPolynomial; kwargs...) + throw(ArgumentError("PEPSBackend does not support polynomial inputs directly. Convert to a structured QUBO or call solve_ising with a supported topology.")) +end diff --git a/src/solution.jl b/src/solution.jl index 4658a6d..a76c2e1 100644 --- a/src/solution.jl +++ b/src/solution.jl @@ -72,21 +72,6 @@ function original_order(bs, permutation) return x end -# Internal result scaffold for the optional SpinGlassPEPS extension. -# -# This type is not exported while the optional SpinGlassPEPS component stack is -# not covered by CI. The states are decoded to TenSolver Boolean vectors, and -# `energies` are objective values in the original TenSolver convention. -# Backend-specific diagnostics live in `metadata` and the raw extension result -# is stored in `raw`. -struct PEPSSolution{T <: Real} - states :: Vector{Vector{Int}} - energies :: Vector{T} - probabilities :: Vector{T} - metadata :: Dict{String, Any} - raw :: Any -end - # Sample from |ψ> in the {0, 1} world instead of 1-based Julia index world. """ sample(psi) @@ -107,33 +92,6 @@ end sample(psi::Solution, n :: Integer) = [sample(psi) for _ in 1:n] -function sample(psi::PEPSSolution) - isempty(psi.states) && throw(ArgumentError("Cannot sample an empty PEPS solution.")) - if isempty(psi.probabilities) - return copy(first(psi.states)) - end - length(psi.probabilities) == length(psi.states) || - throw(ArgumentError("PEPS solution probabilities must match the number of retained states.")) - any(probability -> probability < 0, psi.probabilities) && - throw(ArgumentError("PEPS solution probabilities must be nonnegative.")) - - total = sum(psi.probabilities) - total > 0 || throw(ArgumentError("PEPS solution probabilities must have positive total weight.")) - - threshold = rand() * total - cumulative = zero(total) - for (state, probability) in zip(psi.states, psi.probabilities) - cumulative += probability - if threshold <= cumulative - return copy(state) - end - end - - return copy(last(psi.states)) -end - -sample(psi::PEPSSolution, n :: Integer) = [sample(psi) for _ in 1:n] - """ in(xs, psi::Solution [; cutoff) @@ -145,26 +103,10 @@ function Base.in(bs::AbstractVector, psi::Solution; cutoff = 1e-8) return prob(psi, bs) > cutoff end -# Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. -function Base.in(bs::AbstractVector, psi::PEPSSolution; cutoff = 1e-8) - return prob(psi, bs) > cutoff -end - function prob(psi::Solution{T}, bs) where {T} return is_feasible(psi) ? abs2(coeff(psi, bs)) : zero(T) end -function prob(psi::PEPSSolution{T}, bs) where {T} - target = collect(Int, bs) - p = zero(T) - for (state, probability) in zip(psi.states, psi.probabilities) - if state == target - p += probability - end - end - return p -end - function coeff(psi::Solution, bs) tn = psi.tensor sites = siteinds(tn) diff --git a/src/solver.jl b/src/solver.jl index 9c5b1ec..9b5d093 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -59,109 +59,7 @@ normalize_backend(backend) = throw(backend_error(backend)) include("backends/dmrg.jl") const default_backend = DMRGBackend() -abstract type AbstractStructuredTopology end - -# Structured square-grid topology for optional PEPS solves. -# -# Variables are assumed to be ordered according to SpinGlassNetworks' -# `super_square_lattice((m, n, spins_per_site))` convention. -struct SquareGrid <: AbstractStructuredTopology - m :: Int - n :: Int - spins_per_site :: Int - - function SquareGrid(m::Integer, n::Integer, spins_per_site::Integer=1) - m > 0 || throw(ArgumentError("SquareGrid requires m > 0. Got $m.")) - n > 0 || throw(ArgumentError("SquareGrid requires n > 0. Got $n.")) - spins_per_site > 0 || throw(ArgumentError("SquareGrid requires spins_per_site > 0. Got $spins_per_site.")) - return new(Int(m), Int(n), Int(spins_per_site)) - end -end - -# Structured king-grid topology for optional PEPS solves. It uses the same -# variable ordering as `SquareGrid`, but the PEPS compatibility graph also -# allows diagonal interactions between neighboring grid cells. -struct KingGrid <: AbstractStructuredTopology - m :: Int - n :: Int - spins_per_site :: Int - - function KingGrid(m::Integer, n::Integer, spins_per_site::Integer=1) - m > 0 || throw(ArgumentError("KingGrid requires m > 0. Got $m.")) - n > 0 || throw(ArgumentError("KingGrid requires n > 0. Got $n.")) - spins_per_site > 0 || throw(ArgumentError("KingGrid requires spins_per_site > 0. Got $spins_per_site.")) - return new(Int(m), Int(n), Int(spins_per_site)) - end -end - -_topology_size(topology::AbstractStructuredTopology) = topology.m * topology.n * topology.spins_per_site -_topology_tuple(topology::AbstractStructuredTopology) = (topology.m, topology.n, topology.spins_per_site) -_topology_name(::SquareGrid) = "square" -_topology_name(::KingGrid) = "king" - -# Internal scaffold for the optional SpinGlassPEPS structured backend. -# -# The backend is implemented by the `TenSolverSpinGlassPEPSExt` package -# extension, which loads only when `SpinGlassNetworks`, `SpinGlassEngine`, and -# `SpinGlassTensors` are available. Without those packages this backend errors -# clearly and the default DMRG backend remains unchanged. -# -# This constructor is intentionally not exported while the current registered -# SpinGlass component dependency stack does not resolve in the same environment -# as TenSolver's ITensors/QUBOTools stack and CI cannot exercise the extension. -struct PEPSBackend{T <: AbstractStructuredTopology, S} <: AbstractTenSolverBackend - topology :: T - beta :: Float64 - bond_dim :: Int - max_states :: Int - cutoff_prob :: Float64 - onGPU :: Bool - contraction :: Symbol - num_sweeps :: Int - graduate_truncation :: Bool - transformations :: S - local_dimension :: Union{Nothing, Int} - no_cache :: Bool -end - -function PEPSBackend(topology::AbstractStructuredTopology; - beta::Real = 2.0, - bond_dim::Integer = 16, - max_states::Integer = 2^8, - cutoff_prob::Real = 1e-4, - onGPU::Bool = false, - contraction::Symbol = :auto, - num_sweeps::Integer = 1, - graduate_truncation::Bool = true, - transformations = :all, - local_dimension::Union{Nothing, Integer} = nothing, - no_cache::Bool = false) - beta > 0 && isfinite(beta) || throw(ArgumentError("PEPSBackend requires finite beta > 0. Got $beta.")) - bond_dim >= 1 || throw(ArgumentError("PEPSBackend requires bond_dim >= 1. Got $bond_dim.")) - max_states >= 1 || throw(ArgumentError("PEPSBackend requires max_states >= 1. Got $max_states.")) - cutoff_prob >= 0 || throw(ArgumentError("PEPSBackend requires cutoff_prob >= 0. Got $cutoff_prob.")) - num_sweeps >= 1 || throw(ArgumentError("PEPSBackend requires num_sweeps >= 1. Got $num_sweeps.")) - contraction in (:auto, :svd, :svd_truncate, :zipper) || - throw(ArgumentError("Unsupported PEPS contraction $(repr(contraction)). Use :auto, :svd, :svd_truncate, or :zipper.")) - if !isnothing(local_dimension) - local_dimension >= 1 || throw(ArgumentError("PEPSBackend requires local_dimension >= 1 when provided. Got $local_dimension.")) - end - - return PEPSBackend{typeof(topology), typeof(transformations)}( - topology, - Float64(beta), - Int(bond_dim), - Int(max_states), - Float64(cutoff_prob), - onGPU, - contraction, - Int(num_sweeps), - graduate_truncation, - transformations, - isnothing(local_dimension) ? nothing : Int(local_dimension), - no_cache, - ) -end +include("backends/peps.jl") """ minimize(Q::Matrix[, l::Vector[, c::Number ; device, cutoff, kwargs...) @@ -254,24 +152,6 @@ function minimize(backend::AbstractTenSolverBackend, args...; kwargs...) throw(backend_error(backend)) end -function minimize( - backend::PEPSBackend, - Q::AbstractMatrix{T}, - l::Union{AbstractVector{T}, Nothing}=nothing, - c::T=zero(T) - ; - cutoff=1e-8, - preprocess::Bool=false, - kwargs..., -) where T - preprocess && throw(ArgumentError("PEPSBackend does not support preprocess=true because the topology fixes the variable order. Use backend = :dmrg for preprocessed QUBO solves.")) - return solve_ising(backend, IsingModel(qubo_to_ising(Q, l, c)); cutoff, kwargs...) -end - -function minimize(backend::PEPSBackend, p::AbstractPolynomial; kwargs...) - throw(ArgumentError("PEPSBackend does not support polynomial inputs directly. Convert to a structured QUBO or call solve_ising with a supported topology.")) -end - """ solve_ising(model; backend = DMRGBackend(), kwargs...) solve_ising(J, h[, offset]; backend = DMRGBackend(), kwargs...) From 3879729cbb0ae0bbb9f6b68f28f2d73900ece14b Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:55:48 -0400 Subject: [PATCH 19/23] Introduce AbstractSolution supertype for backend solutions Solution (DMRG) and PEPSSolution subtype AbstractSolution, which documents the sample/prob interface and provides the generic sample(psi, n) and in(xs, psi; cutoff) methods, removing the per-backend duplicates. --- src/backends/peps.jl | 9 +-------- src/solution.jl | 44 ++++++++++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/backends/peps.jl b/src/backends/peps.jl index fef0de6..147fd68 100644 --- a/src/backends/peps.jl +++ b/src/backends/peps.jl @@ -127,7 +127,7 @@ backend_error(::PEPSBackend) = ArgumentError("PEPSBackend is not available. Inst # `energies` are objective values in the original TenSolver convention. # Backend-specific diagnostics live in `metadata` and the raw extension result # is stored in `raw`. -struct PEPSSolution{T <: Real} +struct PEPSSolution{T <: Real} <: AbstractSolution states :: Vector{Vector{Int}} energies :: Vector{T} probabilities :: Vector{T} @@ -160,13 +160,6 @@ function sample(psi::PEPSSolution) return copy(last(psi.states)) end -sample(psi::PEPSSolution, n :: Integer) = [sample(psi) for _ in 1:n] - -# Whether `xs` is one of the decoded Boolean states retained by the PEPS backend. -function Base.in(bs::AbstractVector, psi::PEPSSolution; cutoff = 1e-8) - return prob(psi, bs) > cutoff -end - function prob(psi::PEPSSolution{T}, bs) where {T} target = collect(Int, bs) p = zero(T) diff --git a/src/solution.jl b/src/solution.jl index a76c2e1..57c7fdf 100644 --- a/src/solution.jl +++ b/src/solution.jl @@ -1,6 +1,35 @@ import ITensors, ITensorMPS import ITensorMPS: MPS, siteinds +""" + AbstractSolution + +Supertype for backend solution distributions returned by [`minimize`](@ref) +and [`maximize`](@ref). + +Each backend provides its own concrete subtype (the DMRG backend returns +[`Solution`](@ref)). Subtypes implement: + +- `sample(psi)`: draw one Boolean vector from the distribution. +- `prob(psi, xs)`: probability of drawing the vector `xs`. + +and inherit `sample(psi, n)` and `in(xs, psi; cutoff)` from those. +""" +abstract type AbstractSolution end + +sample(psi::AbstractSolution, n::Integer) = [sample(psi) for _ in 1:n] + +""" + in(xs, psi::AbstractSolution [; cutoff]) + +Whether the vector `xs` has a positive probability of being sampleable from `psi`. +When setting `cutoff`, it will be used as the minimum probability considered positive. +Always `false` for infeasible solutions. +""" +function Base.in(bs::AbstractVector, psi::AbstractSolution; cutoff = 1e-8) + return prob(psi, bs) > cutoff +end + """ Solution @@ -23,7 +52,7 @@ all correspond to iteration `i`. Provably infeasible models produce a `Solution` with no MPS and empty stats vectors; check with [`is_feasible`](@ref) before sampling. """ -struct Solution{T <: Real} +struct Solution{T <: Real} <: AbstractSolution tensor :: Union{MPS, Nothing} energies :: Vector{T} bond_dims :: Vector{Int} @@ -90,19 +119,6 @@ function sample(psi::Solution) end end -sample(psi::Solution, n :: Integer) = [sample(psi) for _ in 1:n] - -""" - in(xs, psi::Solution [; cutoff) - -Whether the vector `xs` has a positive probability of being sampleable from `psi`. -When setting `cutoff`, it will be used as the minimum probability considered positive. -Always `false` for infeasible solutions. -""" -function Base.in(bs::AbstractVector, psi::Solution; cutoff = 1e-8) - return prob(psi, bs) > cutoff -end - function prob(psi::Solution{T}, bs) where {T} return is_feasible(psi) ? abs2(coeff(psi, bs)) : zero(T) end From abd0720b0baa51f207d5795a6e6a90bc09d4e071 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:56:25 -0400 Subject: [PATCH 20/23] Enforce unique states in PEPSSolution Duplicate decoded states are merged (probabilities summed) by the extension before construction; the inner constructor now enforces that invariant, and prob simplifies to a single lookup. --- src/backends/peps.jl | 17 ++++++++++------- test/peps_backend.jl | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/backends/peps.jl b/src/backends/peps.jl index 147fd68..4c5684d 100644 --- a/src/backends/peps.jl +++ b/src/backends/peps.jl @@ -133,6 +133,14 @@ struct PEPSSolution{T <: Real} <: AbstractSolution probabilities :: Vector{T} metadata :: Dict{String, Any} raw :: Any + + function PEPSSolution{T}(states, energies, probabilities, metadata, raw) where {T <: Real} + # The extension merges duplicate decoded states (summing their + # probabilities) before construction; enforce that invariant here so each + # retained state carries exactly one probability. + allunique(states) || throw(ArgumentError("PEPS solution states must be unique. Merge duplicate states before constructing a PEPSSolution.")) + return new{T}(states, energies, probabilities, metadata, raw) + end end function sample(psi::PEPSSolution) @@ -162,13 +170,8 @@ end function prob(psi::PEPSSolution{T}, bs) where {T} target = collect(Int, bs) - p = zero(T) - for (state, probability) in zip(psi.states, psi.probabilities) - if state == target - p += probability - end - end - return p + index = findfirst(==(target), psi.states) + return isnothing(index) ? zero(T) : get(psi.probabilities, index, zero(T)) end function minimize( diff --git a/test/peps_backend.jl b/test/peps_backend.jl index 4fe9930..0ace701 100644 --- a/test/peps_backend.jl +++ b/test/peps_backend.jl @@ -63,15 +63,15 @@ @test !([0, 0] in peps_solution) @test TenSolver.prob(peps_solution, [0, 1]) ≈ 1.0 - duplicate_state_solution = TenSolver.PEPSSolution{Float64}( + # Duplicate decoded states must be merged (probabilities summed) before + # construction; the constructor enforces the invariant. + @test_throws ArgumentError TenSolver.PEPSSolution{Float64}( [[1, 0], [0, 1], [1, 0]], [-2.0, -1.0, -2.0], [0.2, 0.3, 0.4], Dict{String, Any}("backend" => "SpinGlassPEPS"), nothing, ) - @test TenSolver.prob(duplicate_state_solution, [1, 0]) ≈ 0.6 - @test TenSolver.prob(duplicate_state_solution, [0, 1]) ≈ 0.3 @test_throws ArgumentError sample(TenSolver.PEPSSolution{Float64}( [[1, 0], [0, 1]], From ea75764d2dc64c16147cfa3d0577a805ddc42b57 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 05:58:53 -0400 Subject: [PATCH 21/23] Unify PEPS solve keywords with DMRG conventions PEPSBackend now only selects the algorithm and topology, like DMRGBackend; solver parameters move to keywords of the solve call. Shared concepts reuse the DMRG names: maxdim (was bond_dim), iterations (was num_sweeps), and device (was onGPU; any non-cpu device runs the contractor on GPU). PEPS-specific keywords are normalized and validated by the new TenSolver.peps_options helper, which the SpinGlassPEPS extension consumes, so validation is testable without the optional stack. --- ext/TenSolverSpinGlassPEPSExt.jl | 63 ++++++++++----------- src/backends/peps.jl | 97 +++++++++++++++++++------------- test/peps_backend.jl | 44 ++++++++------- 3 files changed, 111 insertions(+), 93 deletions(-) diff --git a/ext/TenSolverSpinGlassPEPSExt.jl b/ext/TenSolverSpinGlassPEPSExt.jl index b4f077e..154817b 100644 --- a/ext/TenSolverSpinGlassPEPSExt.jl +++ b/ext/TenSolverSpinGlassPEPSExt.jl @@ -68,8 +68,8 @@ function check_layout_edges(topology, model::IsingModel, lattice) end end -function build_potts_hamiltonian(backend::PEPSBackend, ig, lattice) - if isnothing(backend.local_dimension) +function build_potts_hamiltonian(local_dimension, ig, lattice) + if isnothing(local_dimension) return SpinGlassNetworks.potts_hamiltonian( ig; spectrum = SpinGlassNetworks.full_spectrum, @@ -79,7 +79,7 @@ function build_potts_hamiltonian(backend::PEPSBackend, ig, lattice) return SpinGlassNetworks.potts_hamiltonian( ig, - backend.local_dimension; + local_dimension; spectrum = SpinGlassNetworks.full_spectrum, cluster_assignment_rule = lattice, ) @@ -96,10 +96,10 @@ function resolve_transformations(transformations) return (transformations,) end -function contraction_strategy(backend::PEPSBackend) - backend.contraction in (:auto, :svd, :svd_truncate) && return SpinGlassEngine.SVDTruncate - backend.contraction === :zipper && return SpinGlassEngine.Zipper - throw(ArgumentError("Unsupported PEPS contraction $(repr(backend.contraction)).")) +function contraction_strategy(contraction::Symbol) + contraction in (:auto, :svd, :svd_truncate) && return SpinGlassEngine.SVDTruncate + contraction === :zipper && return SpinGlassEngine.Zipper + throw(ArgumentError("Unsupported PEPS contraction $(repr(contraction)).")) end function peps_network(topology::SquareGrid, potts_h, transform, ::Type{T}) where {T} @@ -156,22 +156,22 @@ function deduplicated_records(records) return deduped end -function peps_metadata(backend::PEPSBackend, records, raw_results, failures) +function peps_metadata(backend::PEPSBackend, opts, records, raw_results, failures) best = first(records) raw = raw_results[best.transformation] return Dict{String, Any}( "backend" => "SpinGlassPEPS", "topology" => TenSolver.topology_name(backend.topology), "topology_size" => TenSolver.topology_tuple(backend.topology), - "beta" => backend.beta, - "bond_dim" => backend.bond_dim, - "max_states" => backend.max_states, - "cutoff_prob" => backend.cutoff_prob, - "onGPU" => backend.onGPU, - "contraction" => String(backend.contraction), - "num_sweeps" => backend.num_sweeps, - "graduate_truncation" => backend.graduate_truncation, - "local_dimension" => backend.local_dimension, + "beta" => opts.beta, + "maxdim" => opts.maxdim, + "max_states" => opts.max_states, + "cutoff_prob" => opts.cutoff_prob, + "onGPU" => opts.onGPU, + "contraction" => String(opts.contraction), + "iterations" => opts.iterations, + "graduate_truncation" => opts.graduate_truncation, + "local_dimension" => opts.local_dimension, "transformations_tried" => collect(string.(keys(raw_results))), "transformations_failed" => [string(failure.transformation) for failure in failures], "selected_transformation" => string(best.transformation), @@ -182,10 +182,7 @@ function peps_metadata(backend::PEPSBackend, records, raw_results, failures) end function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = nothing, verbosity = 1, kwargs...) - if !isempty(kwargs) - names = join(string.(keys(kwargs)), ", ") - throw(ArgumentError("Unsupported PEPS backend keyword(s): $names. Configure PEPSBackend instead.")) - end + opts = TenSolver.peps_options(; kwargs...) check_topology_size(backend.topology, model) @@ -194,37 +191,37 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = ig = SpinGlassNetworks.ising_graph(S, instance) lattice = SpinGlassNetworks.super_square_lattice(TenSolver.topology_tuple(backend.topology)) check_layout_edges(backend.topology, model, lattice) - potts_h = build_potts_hamiltonian(backend, ig, lattice) + potts_h = build_potts_hamiltonian(opts.local_dimension, ig, lattice) params = SpinGlassEngine.MpsParameters{S}(; - bond_dim = backend.bond_dim, - num_sweeps = backend.num_sweeps, + bond_dim = opts.maxdim, + num_sweeps = opts.iterations, ) search_params = SpinGlassEngine.SearchParameters(; - max_states = backend.max_states, - cutoff_prob = backend.cutoff_prob, + max_states = opts.max_states, + cutoff_prob = opts.cutoff_prob, ) - strategy = contraction_strategy(backend) + strategy = contraction_strategy(opts.contraction) records = NamedTuple[] raw_results = Dict{Any, Any}() failures = NamedTuple[] - for transform in resolve_transformations(backend.transformations) + for transform in resolve_transformations(opts.transformations) try net = peps_network(backend.topology, potts_h, transform, S) ctr = SpinGlassEngine.MpsContractor( strategy, net, params; - onGPU = backend.onGPU, - beta = S(backend.beta), - graduate_truncation = backend.graduate_truncation, + onGPU = opts.onGPU, + beta = S(opts.beta), + graduate_truncation = opts.graduate_truncation, ) merge_strategy = SpinGlassEngine.merge_branches(ctr; merge_prob = :none) sol, info = SpinGlassEngine.low_energy_spectrum( ctr, search_params, merge_strategy; - no_cache = backend.no_cache, + no_cache = opts.no_cache, ) raw_results[transform] = (; solution = sol, info) @@ -252,7 +249,7 @@ function TenSolver.solve_ising(backend::PEPSBackend, model::IsingModel; cutoff = states = [record.state for record in records] energies = S[record.energy for record in records] probabilities = S[record.probability for record in records] - metadata = peps_metadata(backend, records, raw_results, failures) + metadata = peps_metadata(backend, opts, records, raw_results, failures) raw = (; results = raw_results, failures) verbosity > 0 && @info "SpinGlassPEPS backend finished" energy = first(energies) states = length(states) diff --git a/src/backends/peps.jl b/src/backends/peps.jl index 4c5684d..3e625ff 100644 --- a/src/backends/peps.jl +++ b/src/backends/peps.jl @@ -48,12 +48,17 @@ topology_name(::SquareGrid) = "square" topology_name(::KingGrid) = "king" """ - PEPSBackend(topology; kwargs...) + PEPSBackend(topology) Select the optional SpinGlassPEPS structured backend for `minimize` and `TenSolver.solve_ising`, targeting Ising models whose couplings fit the given structured `topology` (`SquareGrid` or `KingGrid`). +Like `DMRGBackend`, the backend object only selects the algorithm; solver +parameters are keyword arguments of the solve call. Keywords shared with the +DMRG backend keep the same names (`maxdim`, `iterations`, `device`); the +PEPS-specific keywords are documented on [`TenSolver.peps_options`](@ref). + The solve is provided by the `TenSolverSpinGlassPEPSExt` package extension and requires `SpinGlassNetworks`, `SpinGlassEngine`, and `SpinGlassTensors` to be installed and loaded; without them, solves with this backend error clearly and @@ -64,56 +69,68 @@ while the registered SpinGlass component dependency stack does not resolve in the same environment as TenSolver's ITensors/QUBOTools stack, so CI cannot exercise the extension. """ -struct PEPSBackend{T <: AbstractStructuredTopology, S} <: AbstractTenSolverBackend +struct PEPSBackend{T <: AbstractStructuredTopology} <: AbstractTenSolverBackend topology :: T - beta :: Float64 - bond_dim :: Int - max_states :: Int - cutoff_prob :: Float64 - onGPU :: Bool - contraction :: Symbol - num_sweeps :: Int - graduate_truncation :: Bool - transformations :: S - local_dimension :: Union{Nothing, Int} - no_cache :: Bool end -function PEPSBackend(topology::AbstractStructuredTopology; - beta::Real = 2.0, - bond_dim::Integer = 16, - max_states::Integer = 2^8, - cutoff_prob::Real = 1e-4, - onGPU::Bool = false, - contraction::Symbol = :auto, - num_sweeps::Integer = 1, - graduate_truncation::Bool = true, - transformations = :all, - local_dimension::Union{Nothing, Integer} = nothing, - no_cache::Bool = false) - beta > 0 && isfinite(beta) || throw(ArgumentError("PEPSBackend requires finite beta > 0. Got $beta.")) - bond_dim >= 1 || throw(ArgumentError("PEPSBackend requires bond_dim >= 1. Got $bond_dim.")) - max_states >= 1 || throw(ArgumentError("PEPSBackend requires max_states >= 1. Got $max_states.")) - cutoff_prob >= 0 || throw(ArgumentError("PEPSBackend requires cutoff_prob >= 0. Got $cutoff_prob.")) - num_sweeps >= 1 || throw(ArgumentError("PEPSBackend requires num_sweeps >= 1. Got $num_sweeps.")) +""" + peps_options(; kwargs...) + +Normalize and validate the keyword arguments of a PEPS solve into an options +named tuple for the SpinGlassPEPS extension. + +Keywords shared with the DMRG backend: + +- `maxdim :: Int = 16` - Maximum boundary-MPS bond dimension used during PEPS contraction. +- `iterations :: Int = 1` - Number of boundary-MPS variational sweeps. +- `device :: Function = cpu` - Accelerator device; anything other than `cpu` runs the SpinGlass contractor on GPU. + +PEPS-specific keywords: + +- `beta :: Float64 = 2.0` - Inverse temperature used for the contraction. +- `max_states :: Int = 256` - Maximum number of low-energy states retained. +- `cutoff_prob :: Float64 = 1e-4` - Probability cutoff for discarding branches. +- `contraction :: Symbol = :auto` - Contraction strategy (`:auto`, `:svd`, `:svd_truncate`, or `:zipper`). +- `graduate_truncation :: Bool = true` - Gradual truncation of the boundary MPS. +- `transformations = :all` - Lattice transformations to try (`:all`, `:identity`, or a collection). +- `local_dimension :: Union{Nothing, Int} = nothing` - Optional fixed Potts cluster dimension. +- `no_cache :: Bool = false` - Disable the SpinGlassEngine memoization cache. +""" +function peps_options(; + maxdim::Integer = 16, + iterations::Integer = 1, + device::Function = cpu, + beta::Real = 2.0, + max_states::Integer = 2^8, + cutoff_prob::Real = 1e-4, + contraction::Symbol = :auto, + graduate_truncation::Bool = true, + transformations = :all, + local_dimension::Union{Nothing, Integer} = nothing, + no_cache::Bool = false, +) + beta > 0 && isfinite(beta) || throw(ArgumentError("PEPS solves require finite beta > 0. Got $beta.")) + maxdim >= 1 || throw(ArgumentError("PEPS solves require maxdim >= 1. Got $maxdim.")) + max_states >= 1 || throw(ArgumentError("PEPS solves require max_states >= 1. Got $max_states.")) + cutoff_prob >= 0 || throw(ArgumentError("PEPS solves require cutoff_prob >= 0. Got $cutoff_prob.")) + iterations >= 1 || throw(ArgumentError("PEPS solves require iterations >= 1. Got $iterations.")) contraction in (:auto, :svd, :svd_truncate, :zipper) || throw(ArgumentError("Unsupported PEPS contraction $(repr(contraction)). Use :auto, :svd, :svd_truncate, or :zipper.")) if !isnothing(local_dimension) - local_dimension >= 1 || throw(ArgumentError("PEPSBackend requires local_dimension >= 1 when provided. Got $local_dimension.")) + local_dimension >= 1 || throw(ArgumentError("PEPS solves require local_dimension >= 1 when provided. Got $local_dimension.")) end - return PEPSBackend{typeof(topology), typeof(transformations)}( - topology, - Float64(beta), - Int(bond_dim), - Int(max_states), - Float64(cutoff_prob), - onGPU, + return (; + maxdim = Int(maxdim), + iterations = Int(iterations), + onGPU = !(device === cpu), + beta = Float64(beta), + max_states = Int(max_states), + cutoff_prob = Float64(cutoff_prob), contraction, - Int(num_sweeps), graduate_truncation, transformations, - isnothing(local_dimension) ? nothing : Int(local_dimension), + local_dimension = isnothing(local_dimension) ? nothing : Int(local_dimension), no_cache, ) end diff --git a/test/peps_backend.jl b/test/peps_backend.jl index 0ace701..4269fbc 100644 --- a/test/peps_backend.jl +++ b/test/peps_backend.jl @@ -12,27 +12,32 @@ @test_throws ArgumentError TenSolver.SquareGrid(0, 1) @test_throws ArgumentError TenSolver.KingGrid(1, 0) - backend = TenSolver.PEPSBackend( - TenSolver.SquareGrid(1, 1); + backend = TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1)) + + @test backend.topology == TenSolver.SquareGrid(1, 1) + + # Solve keywords are validated by peps_options, mirroring how the DMRG + # backend takes its parameters as minimize keywords. + opts = TenSolver.peps_options(; beta = 1.5, - bond_dim = 4, + maxdim = 4, max_states = 2, cutoff_prob = 0.0, - onGPU = false, contraction = :svd, transformations = :identity, ) - - @test backend.topology == TenSolver.SquareGrid(1, 1) - @test backend.beta == 1.5 - @test backend.bond_dim == 4 - @test backend.max_states == 2 - @test backend.cutoff_prob == 0.0 - @test backend.contraction == :svd - @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); beta = 0) - @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); bond_dim = 0) - @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); max_states = 0) - @test_throws ArgumentError TenSolver.PEPSBackend(TenSolver.SquareGrid(1, 1); contraction = :unknown) + @test opts.beta == 1.5 + @test opts.maxdim == 4 + @test opts.iterations == 1 + @test opts.max_states == 2 + @test opts.cutoff_prob == 0.0 + @test opts.onGPU == false + @test opts.contraction == :svd + @test_throws ArgumentError TenSolver.peps_options(; beta = 0) + @test_throws ArgumentError TenSolver.peps_options(; maxdim = 0) + @test_throws ArgumentError TenSolver.peps_options(; max_states = 0) + @test_throws ArgumentError TenSolver.peps_options(; contraction = :unknown) + @test_throws ArgumentError TenSolver.peps_options(; iterations = 0) Q = reshape([-1.0], 1, 1) peps_error = try @@ -110,13 +115,12 @@ end import SpinGlassEngine import SpinGlassTensors - backend = TenSolver.PEPSBackend( - TenSolver.SquareGrid(2, 2); + backend = TenSolver.PEPSBackend(TenSolver.SquareGrid(2, 2)) + peps_kwargs = ( beta = 2.0, - bond_dim = 4, + maxdim = 4, max_states = 4, cutoff_prob = 0.0, - onGPU = false, contraction = :svd, transformations = :identity, ) @@ -132,7 +136,7 @@ end objective(x) = dot(x, Q, x) + dot(l, x) + c exact_energy, _ = brute_force(objective, Int, 4) - energy, solution = minimize(Q, l, c; backend, verbosity = 0) + energy, solution = minimize(Q, l, c; backend, verbosity = 0, peps_kwargs...) state = sample(solution) @test energy ≈ exact_energy atol = 1e-6 From 17207ecc197d40f8945ecfc0ae8b464cc2547bc7 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 06:00:14 -0400 Subject: [PATCH 22/23] Keep solve_ising internal; document PEPS backend surface Per review, minimize/maximize (and JuMP) stay the only exported solver entry points; solve_ising remains the documented internal backend boundary. Reorder the backends docs block, document AbstractSolution and the PEPS solve keywords, and move the solve_ising regression tests from the backend-interface suite to the PEPS backend suite. --- docs/src/api.md | 10 +++++++++- src/TenSolver.jl | 2 +- test/backend.jl | 24 ------------------------ test/peps_backend.jl | 26 +++++++++++++++++++++++++- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 7d3a0be..13fbc14 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -14,13 +14,14 @@ TenSolver.maximize ```@docs TenSolver.AbstractTenSolverBackend TenSolver.DMRGBackend -TenSolver.normalize_backend TenSolver.PEPSBackend +TenSolver.normalize_backend ``` ## Solution ```@docs +TenSolver.AbstractSolution TenSolver.Solution ``` @@ -71,6 +72,13 @@ TenSolver.qmatrix_permutation TenSolver.preprocess_qubo ``` +### Backend Boundaries + +```@docs +TenSolver.solve_ising +TenSolver.peps_options +``` + ### MPO Construction ```@docs diff --git a/src/TenSolver.jl b/src/TenSolver.jl index 0cddd84..9813217 100644 --- a/src/TenSolver.jl +++ b/src/TenSolver.jl @@ -23,7 +23,7 @@ include("solution.jl") export sample include("solver.jl") -export minimize, maximize, solve_ising +export minimize, maximize export AbstractTenSolverBackend, DMRGBackend # Convergence logging diff --git a/test/backend.jl b/test/backend.jl index ba3a7f8..1fa7a4d 100644 --- a/test/backend.jl +++ b/test/backend.jl @@ -30,30 +30,6 @@ end @test TenSolver.sample(psi) == [1] end - @testset "solve_ising preserves pair couplings and offsets" begin - J = [0.0 0.5; 1.0 0.0] - h = [-0.25, 0.75] - offset = 2.0 - model = TenSolver.IsingModel(J, h, offset) - spin_states = [[s1, s2] for s1 in (-1, 1) for s2 in (-1, 1)] - energies = [TenSolver.ising_energy(model, spin) for spin in spin_states] - expected_energy, expected_index = findmin(energies) - expected_spin = spin_states[expected_index] - - for solve_call in ( - () -> solve_ising(model; backend=:dmrg, verbosity=0), - () -> solve_ising(J, h, offset; backend=:dmrg, verbosity=0), - ) - energy, solution = solve_call() - sample_bits = TenSolver.sample(solution) - sample_spin = TenSolver.bool_to_spin(sample_bits) - - @test energy ≈ expected_energy - @test sample_spin == expected_spin - @test TenSolver.ising_energy(model, sample_spin) ≈ expected_energy - end - end - @testset "Symbol backends can be provided by extensions" begin Q = reshape([1.0], 1, 1) E, payload = minimize(Q; backend=:test_symbol_backend, verbosity=0, cutoff=1e-6) diff --git a/test/peps_backend.jl b/test/peps_backend.jl index 4269fbc..2e2a13b 100644 --- a/test/peps_backend.jl +++ b/test/peps_backend.jl @@ -50,10 +50,34 @@ @test occursin("SpinGlassNetworks", sprint(showerror, peps_error)) model = TenSolver.IsingModel(zeros(1, 1), [-1.0]) - energy, solution = solve_ising(model; backend = :dmrg, verbosity = 0) + energy, solution = TenSolver.solve_ising(model; backend = :dmrg, verbosity = 0) @test energy ≈ -1.0 @test sample(solution) == [1] + @testset "solve_ising preserves pair couplings and offsets" begin + J = [0.0 0.5; 1.0 0.0] + h = [-0.25, 0.75] + offset = 2.0 + ising = TenSolver.IsingModel(J, h, offset) + spin_states = [[s1, s2] for s1 in (-1, 1) for s2 in (-1, 1)] + energies = [TenSolver.ising_energy(ising, spin) for spin in spin_states] + expected_energy, expected_index = findmin(energies) + expected_spin = spin_states[expected_index] + + for solve_call in ( + () -> TenSolver.solve_ising(ising; backend=:dmrg, verbosity=0), + () -> TenSolver.solve_ising(J, h, offset; backend=:dmrg, verbosity=0), + ) + ising_energy_value, ising_solution = solve_call() + sample_bits = TenSolver.sample(ising_solution) + sample_spin = TenSolver.bool_to_spin(sample_bits) + + @test ising_energy_value ≈ expected_energy + @test sample_spin == expected_spin + @test TenSolver.ising_energy(ising, sample_spin) ≈ expected_energy + end + end + peps_solution = TenSolver.PEPSSolution{Float64}( [[1, 0], [0, 1]], [-2.0, -1.0], From 8459f8de427cbface1a492489a8ec4793452854f Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 15 Jul 2026 06:21:50 -0400 Subject: [PATCH 23/23] Restore IsingModel form constructor lost in rebase; fix PEPS docs block scaled_form_parts and IsingModel(::QUBOTools.AbstractForm) were also dropped by the rebase, breaking solve_ising(::DMRGBackend, model) and the PEPS minimize path. Give SquareGrid/KingGrid the docstrings their existing api.md entries expect and document peps_options alongside. --- docs/src/api.md | 8 +------- src/backends/peps.jl | 22 +++++++++++++++------- src/ising.jl | 12 ++++++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/src/api.md b/docs/src/api.md index 13fbc14..e31aa6e 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -72,12 +72,6 @@ TenSolver.qmatrix_permutation TenSolver.preprocess_qubo ``` -### Backend Boundaries - -```@docs -TenSolver.solve_ising -TenSolver.peps_options -``` ### MPO Construction @@ -100,11 +94,11 @@ TenSolver.project_state ### PEPS Backend - ```@docs TenSolver.SquareGrid TenSolver.KingGrid TenSolver.solve_ising +TenSolver.peps_options ``` ## Index diff --git a/src/backends/peps.jl b/src/backends/peps.jl index 3e625ff..0a0ae1c 100644 --- a/src/backends/peps.jl +++ b/src/backends/peps.jl @@ -9,10 +9,14 @@ abstract type AbstractStructuredTopology end -# Structured square-grid topology for optional PEPS solves. -# -# Variables are assumed to be ordered according to SpinGlassNetworks' -# `super_square_lattice((m, n, spins_per_site))` convention. +""" + SquareGrid(m, n, spins_per_site = 1) + +Structured square-grid topology for optional PEPS solves. + +Variables are assumed to be ordered according to SpinGlassNetworks' +`super_square_lattice((m, n, spins_per_site))` convention. +""" struct SquareGrid <: AbstractStructuredTopology m :: Int n :: Int @@ -26,9 +30,13 @@ struct SquareGrid <: AbstractStructuredTopology end end -# Structured king-grid topology for optional PEPS solves. It uses the same -# variable ordering as `SquareGrid`, but the PEPS compatibility graph also -# allows diagonal interactions between neighboring grid cells. +""" + KingGrid(m, n, spins_per_site = 1) + +Structured king-grid topology for optional PEPS solves. It uses the same +variable ordering as [`SquareGrid`](@ref), but the PEPS compatibility graph +also allows diagonal interactions between neighboring grid cells. +""" struct KingGrid <: AbstractStructuredTopology m :: Int n :: Int diff --git a/src/ising.jl b/src/ising.jl index 542dbd6..045793e 100644 --- a/src/ising.jl +++ b/src/ising.jl @@ -128,6 +128,18 @@ function check_form_domain(form::QUBOTools.AbstractForm, domain, label::Abstract QUBOTools.domain(form) === domain || throw(ArgumentError("$label conversion expected a QUBOTools form in domain $domain. Encountered $(QUBOTools.domain(form)).")) end +function scaled_form_parts(form::QUBOTools.AbstractForm) + _, l, Q, scale, offset, _, _ = form + T = promote_type(eltype(Q), eltype(l), typeof(scale), typeof(offset)) + return T(scale) .* sparse(T.(Q)), T(scale) .* collect(T, l), T(scale) * T(offset) +end + +function IsingModel(form::QUBOTools.AbstractForm) + check_form_domain(form, QUBOTools.SpinDomain, "Ising model") + J, h, offset = scaled_form_parts(form) + return IsingModel(J, h, offset) +end + """ bool_to_spin(x)