From a808922cc1af137811e6f7e479684365f42b13cf Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 22:12:08 +0800 Subject: [PATCH 1/7] Implement direct SAT search for unweighted gadgets --- Project.toml | 2 + docs/make.jl | 1 + docs/src/unweighted_search.md | 52 ++++ src/GadgetSearch.jl | 4 + src/core/unweighted_sat.jl | 340 +++++++++++++++++++++++++++ src/core/unweighted_search.jl | 417 ++++++++++++++++++++++++++++----- test/core/unweighted_search.jl | 220 +++++++++-------- 7 files changed, 862 insertions(+), 174 deletions(-) create mode 100644 docs/src/unweighted_search.md create mode 100644 src/core/unweighted_sat.jl diff --git a/Project.toml b/Project.toml index ae5b2e3..d3e83f0 100644 --- a/Project.toml +++ b/Project.toml @@ -16,6 +16,7 @@ IterTools = "c8e1da08-722c-5040-9ed9-7db0dc04731e" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Karnak = "cd156443-31ad-4f6f-850f-a93ee5f75905" +Kissat = "057e91dc-0d29-4c6c-955d-3db04e6fbc7f" Luxor = "ae8d54c2-7ccd-5906-9d76-62fc9837b5bc" ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -34,6 +35,7 @@ IterTools = "1.10.0" JSON3 = "1.14.1" JuMP = "1" Karnak = "1.1.0" +Kissat = "0.1.0" Luxor = "4.1.0" ProgressMeter = "1.10.4" Serialization = "1.11.0" diff --git a/docs/make.jl b/docs/make.jl index e14dd3a..363645a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -42,6 +42,7 @@ makedocs(; "Rydberg Gadgets on Triangular Lattice" => "generated/trangular_Rydberg_example.md", "QUBO Gadgets on Triangular Lattice" => "generated/triangular_QUBO_example.md", ], + "Unweighted Search" => "unweighted_search.md", "Reference" => "ref.md", ], ) diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md new file mode 100644 index 0000000..09b5725 --- /dev/null +++ b/docs/src/unweighted_search.md @@ -0,0 +1,52 @@ +# Four-pin unweighted lattice search + +`search_unweighted_gadgets` searches a finite lattice window for a four-pin +gadget whose reduced alpha tensor differs from the target by one constant. The +four pins and their outward directions must form the crossing geometry checked +by `check_crossing_frame`. + +The target graph is used only to obtain the target reduced alpha tensor and for +the unchanged final verifier. The search chooses the concrete pin locations, +pin directions, and lattice sites. `window_side` controls the finite search +window. The triangular lattice is the default; pass `Square()` to select KSG. + +The direct search enumerates the finite set of ordered pin locations and +outward lattice directions that satisfy the crossing geometry. For each layout, +atom count, and constant offset, a SAT instance chooses all occupied sites at +once. Its constraints enforce the target reduced alpha tensor and connectivity. +This does not require a known logical graph, a 28-site seed, or a historical +rewrite path. + +`min_vertices:max_vertices` is examined in increasing order, so reaching a +larger atom count means all scheduled smaller cases have already been rejected. +`max_evaluations` bounds SAT instances, not graph mutations. Every returned +candidate is independently checked by both `is_gadget_replacement` and +`check_crossing_frame`. + +`max_frame_evaluations` separately bounds pin-and-ray geometry checks. Increase +it together with `window_side` for exhaustive searches in larger windows. + +```julia +using GadgetSearch, Graphs + +target = SimpleGraph(4) +add_edge!(target, 1, 3) +add_edge!(target, 2, 4) + +result = search_unweighted_gadgets( + target, [1, 2, 3, 4], Triangular(); + min_vertices=4, + max_vertices=23, + max_evaluations=100_000, + max_frame_evaluations=10_000_000, + max_results=1, + window_side=8, +) +``` + +The keyword defaults are `min_vertices=5`, `max_vertices=13`, +`max_evaluations=2_000`, `max_frame_evaluations=1_000_000`, `max_results=1`, +and `window_side=4` for a four-pin target. The result contains the target, +ordered boundary, lattice name, accepted gadgets, number of SAT instances, and +one of four termination reasons: `:solution`, `:budget`, `:frame_budget`, or +`:search_space_exhausted`. diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 430f671..283fb63 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -9,6 +9,7 @@ using IterTools using Combinatorics using Random using Serialization +import Kissat # Tensor network dependencies for alpha tensor computation using GenericTensorNetworks: GenericTensorNetwork, IndependentSet, SizeMax, solve @@ -26,6 +27,7 @@ include("graphio/savegraph.jl") include("graphio/udg.jl") include("utils/ruleio.jl") include("core/unweighted_search.jl") +include("core/unweighted_sat.jl") include("core/search.jl") include("utils/gadget.jl") include("utils/visualize.jl") @@ -72,6 +74,8 @@ export is_gadget_replacement # Unweighted search export UnweightedGadget +export UnweightedSearchResult export search_unweighted_gadgets +export check_crossing_frame end # module diff --git a/src/core/unweighted_sat.jl b/src/core/unweighted_sat.jl new file mode 100644 index 0000000..8b0d928 --- /dev/null +++ b/src/core/unweighted_sat.jl @@ -0,0 +1,340 @@ +mutable struct _SatCnf + variables::Int + clauses::Vector{Vector{Int}} +end + +struct _SatFrameContext + frame + coordinates::Vector{_LatticeCoordinate} + boundary::Vector{Int} + edge_list::Vector{Tuple{Int, Int}} + order::Vector{Int} + layers::Vector{Vector{Tuple{Int, Int, Bool}}} + adjacent::Vector{Vector{Int}} +end + +_SatCnf() = _SatCnf(0, Vector{Int}[]) + +function _sat_variable!(cnf::_SatCnf) + cnf.variables += 1 + return cnf.variables +end + +_sat_clause!(cnf::_SatCnf, literals::Int...) = push!(cnf.clauses, collect(literals)) + +function _sat_at_most!(cnf::_SatCnf, literals::Vector{Int}, bound::Int) + bound >= length(literals) && return + bound >= 0 || return _sat_clause!(cnf) + if bound == 0 + foreach(literal -> _sat_clause!(cnf, -literal), literals) + return + end + counters = [ + [_sat_variable!(cnf) for _ in 1:bound] + for _ in 1:length(literals)-1 + ] + for row in 1:length(literals)-1 + _sat_clause!(cnf, -literals[row], counters[row][1]) + end + for row in 2:length(literals)-1 + _sat_clause!(cnf, -counters[row-1][1], counters[row][1]) + end + for column in 2:bound + for row in column:length(literals)-1 + _sat_clause!( + cnf, -literals[row], -counters[row-1][column-1], + counters[row][column], + ) + end + for row in column+1:length(literals)-1 + _sat_clause!(cnf, -counters[row-1][column], counters[row][column]) + end + end + for row in bound+1:length(literals) + _sat_clause!(cnf, -literals[row], -counters[row-1][bound]) + end +end + +function _sat_exactly!(cnf::_SatCnf, literals::Vector{Int}, count::Int) + _sat_at_most!(cnf, literals, count) + _sat_at_most!(cnf, -literals, length(literals) - count) +end + +function _independent_set_bdd(coordinates, edge_list) + adjacent = [Set{Int}() for _ in coordinates] + for (first, second) in edge_list + push!(adjacent[first], second) + push!(adjacent[second], first) + end + order = sortperm(coordinates) + position = zeros(Int, length(coordinates)) + for (step, vertex) in enumerate(order) + position[vertex] = step + end + last_neighbor = [maximum([position[vertex]; position[collect(adjacent[vertex])]]) + for vertex in eachindex(coordinates)] + layer = [Int[]] + layers = Vector{Vector{Tuple{Int, Int, Bool}}}() + for (step, vertex) in enumerate(order) + next_layer = Vector{Vector{Int}}() + next_index = Dict{Tuple{Vararg{Int}}, Int}() + arcs = Tuple{Int, Int, Bool}[] + for (source, occupied) in enumerate(layer), take in (false, true) + take && any(neighbor -> neighbor in occupied, adjacent[vertex]) && continue + next_occupied = take ? [occupied; vertex] : occupied + frontier = Tuple(sort!(filter(v -> last_neighbor[v] > step, next_occupied))) + destination = get!(next_index, frontier) do + push!(next_layer, collect(frontier)) + length(next_layer) + end + push!(arcs, (source, destination, take)) + end + push!(layers, arcs) + layer = next_layer + end + return order, layers +end + +function _target_completion(target) + return [maximum(target[subset+1] for subset in 0:state + if subset & ~state == 0 && isfinite(target[subset+1])) + for state in 0:length(target)-1] +end + +function _essential_lower_states(target) + states = Int[] + for state in 0:length(target)-1 + value = target[state+1] + isfinite(value) || continue + implied = [target[superset+1] - count_ones(superset ⊻ state) + for superset in state+1:length(target)-1 + if state & ~superset == 0 && isfinite(target[superset+1])] + (isempty(implied) || value > maximum(implied)) && push!(states, state) + end + return states +end + +function _essential_upper_states(completion) + states = Int[] + for state in 0:length(completion)-1 + implied = [completion[subset+1] + count_ones(state ⊻ subset) + for subset in 0:state-1 if subset & ~state == 0] + (isempty(implied) || completion[state+1] < minimum(implied)) && + push!(states, state) + end + return states +end + +function _add_independent_set_upper_bound!( + cnf, selected, pins, order, layers, boundary_state, bound, +) + root = _sat_variable!(cnf) + _sat_clause!(cnf, root) + reachable = Dict((1, 0) => root) + pin_slots = Dict(pin => slot - 1 for (slot, pin) in enumerate(pins)) + for (vertex, arcs) in zip(order, layers) + next_reachable = Dict{Tuple{Int, Int}, Int}() + required = haskey(pin_slots, vertex) ? + !iszero(boundary_state & (1 << pin_slots[vertex])) : nothing + for (source, destination, take) in arcs + required !== nothing && take != required && continue + for ((source_state, count), source_variable) in reachable + source_state == source || continue + next_count = min(bound + 1, count + Int(take)) + destination_variable = get!(next_reachable, (destination, next_count)) do + _sat_variable!(cnf) + end + take ? _sat_clause!( + cnf, -source_variable, -selected[vertex], destination_variable, + ) : _sat_clause!(cnf, -source_variable, destination_variable) + end + end + reachable = next_reachable + end + for ((_, count), variable) in reachable + count == bound + 1 && _sat_clause!(cnf, -variable) + end +end + +function _add_selected_connectivity!(cnf, selected, adjacent, root, selected_count) + reachable = [_sat_variable!(cnf) for _ in selected] + for vertex in eachindex(reachable) + _sat_clause!(cnf, vertex == root ? reachable[vertex] : -reachable[vertex]) + end + for _ in 1:selected_count-1 + next_reachable = [_sat_variable!(cnf) for _ in selected] + for vertex in eachindex(selected) + arrivals = Int[] + for neighbor in adjacent[vertex] + arrival = _sat_variable!(cnf) + push!(arrivals, arrival) + _sat_clause!(cnf, -arrival, selected[vertex]) + _sat_clause!(cnf, -arrival, reachable[neighbor]) + _sat_clause!(cnf, -selected[vertex], -reachable[neighbor], arrival) + _sat_clause!(cnf, -arrival, next_reachable[vertex]) + end + _sat_clause!(cnf, -reachable[vertex], next_reachable[vertex]) + _sat_clause!(cnf, -next_reachable[vertex], reachable[vertex], arrivals...) + end + reachable = next_reachable + end + for vertex in eachindex(selected) + _sat_clause!(cnf, -selected[vertex], reachable[vertex]) + end +end + +function _prepare_sat_frame(lattice, frame) + coordinates = sort(copy(frame.allowed)) + patch = _LatticePatch(coordinates, copy(frame.pins), copy(frame.rays)) + host, boundary, _ = _materialize_lattice_patch(lattice, patch) + edge_list = [(src(edge), dst(edge)) for edge in edges(host)] + order, layers = _independent_set_bdd(coordinates, edge_list) + adjacent = [collect(neighbors(host, vertex)) for vertex in vertices(host)] + return _SatFrameContext( + frame, coordinates, boundary, edge_list, order, layers, adjacent, + ) +end + +function _solve_fixed_crossing_sat(target, lattice, frame, atom_count, offset) + return _solve_fixed_crossing_sat( + target, lattice, _prepare_sat_frame(lattice, frame), atom_count, offset, + ) +end + +function _solve_fixed_crossing_sat( + target, lattice, context::_SatFrameContext, atom_count, offset, +) + frame = context.frame + coordinates = context.coordinates + pins = context.boundary + edge_list = context.edge_list + order = context.order + layers = context.layers + cnf = _SatCnf() + selected = [_sat_variable!(cnf) for _ in coordinates] + foreach(pin -> _sat_clause!(cnf, selected[pin]), pins) + _sat_exactly!(cnf, selected, atom_count) + + for state in _essential_lower_states(target) + witness = [_sat_variable!(cnf) for _ in coordinates] + for vertex in eachindex(coordinates) + _sat_clause!(cnf, -witness[vertex], selected[vertex]) + end + for (slot, pin) in enumerate(pins) + _sat_clause!( + cnf, + iszero(state & (1 << (slot - 1))) ? -witness[pin] : witness[pin], + ) + end + for (first, second) in edge_list + _sat_clause!(cnf, -witness[first], -witness[second]) + end + _sat_exactly!(cnf, witness, Int(target[state+1] + offset)) + end + + completion = _target_completion(target) + for state in _essential_upper_states(completion) + _add_independent_set_upper_bound!( + cnf, selected, pins, order, layers, state, + Int(completion[state+1] + offset), + ) + end + _add_selected_connectivity!( + cnf, selected, context.adjacent, pins[1], atom_count, + ) + + solver = Kissat.init() + Kissat.set_option(solver, "quiet", 1) + append!(solver, cnf.clauses) + Kissat.solve(solver) == :satisfiable || return nothing + values = Base.values(solver, 1:length(selected)) + chosen = findall(>(0), values) + sites = coordinates[chosen] + analysis = _analyze_crossing_candidate( + target, lattice, sites, frame.pins, frame.rays, + ) + analysis.solved || error("SAT candidate failed its encoded constraints") + return analysis +end + +function _search_crossing_sat( + target_graph, target_boundary, target_reduced, lattice; + min_vertices, max_vertices, max_evaluations, max_frame_evaluations, + max_results, window_side, +) + all(value -> isinf(value) || isinteger(value), target_reduced) || + error("the reduced alpha tensor must contain integers or -Inf") + completion = _target_completion(target_reduced) + gadgets = UnweightedGadget[] + evaluated = 0 + frame_evaluated = Ref(0) + for atom_count in min_vertices:max_vertices + atom_count > window_side^2 && break + stopped = _foreach_crossing_frame( + lattice, window_side, frame_evaluated, max_frame_evaluations, + ) do frame + atom_count <= length(frame.allowed) || return nothing + offsets = (-Int(minimum(completion))):(atom_count - Int(maximum(completion))) + isempty(offsets) && return nothing + evaluated == max_evaluations && return :budget + context = _prepare_sat_frame(lattice, frame) + for offset in offsets + evaluated == max_evaluations && return :budget + evaluated += 1 + analysis = _solve_fixed_crossing_sat( + target_reduced, lattice, context, atom_count, offset, + ) + analysis === nothing && continue + valid, verified_offset = is_gadget_replacement( + target_graph, analysis.graph, target_boundary, analysis.boundary, + ) + valid || error("SAT candidate failed the fixed verifier") + analysis.offset == verified_offset || + error("SAT candidate offset mismatch") + push!(gadgets, _unweighted_gadget(target_graph, lattice, analysis)) + length(gadgets) == max_results && + return :solution + end + return nothing + end + stopped === nothing || return gadgets, evaluated, stopped + end + return gadgets, evaluated, :search_space_exhausted +end + +function _foreach_crossing_frame(visit, lattice, side, evaluated, limit) + window = _LatticeCoordinate[ + (column, row) for column in 0:side-1 for row in 0:side-1 + ] + ray_options = eachindex(_lattice_directions(lattice)) + seen = Set{Tuple}() + for pin_set in combinations(window, 4) + for pins in permutations(pin_set) + for rays in Iterators.product(ntuple(_ -> ray_options, 4)...) + evaluated[] == limit && return :frame_budget + evaluated[] += 1 + ordered_pins = collect(pins) + ray_indices = collect(rays) + patch = _LatticePatch(ordered_pins, ordered_pins, ray_indices) + all(_check_crossing_frame(lattice, patch)) || continue + allowed = [site for site in window if site in ordered_pins || all( + _check_crossing_frame( + lattice, + _LatticePatch( + [ordered_pins; site], ordered_pins, ray_indices, + ), + ), + )] + frame = ( + pins=ordered_pins, rays=ray_indices, allowed=allowed, + ) + key = _canonical_crossing_frame_key(lattice, frame) + key in seen && continue + push!(seen, key) + result = visit(frame) + result === nothing || return result + end + end + end + return nothing +end diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index dd28596..997c4f0 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -1,90 +1,383 @@ # ============================================================================ -# Unweighted Gadget Types +# Unweighted lattice search # ============================================================================ -""" - UnweightedGadget +const _LatticeCoordinate = Tuple{Int, Int} -Result of an unweighted gadget search. -Stores the pattern graph R, replacement graph R', boundary vertices, -constant offset between reduced alpha tensors, and optional vertex positions. -""" +"""A verifier-accepted replacement together with its concrete lattice embedding.""" struct UnweightedGadget pattern_graph::SimpleGraph{Int} replacement_graph::SimpleGraph{Int} boundary_vertices::Vector{Int} constant_offset::Float64 - pos::Union{Nothing, Vector{Tuple{Float64, Float64}}} + lattice::Symbol + lattice_coordinates::Vector{_LatticeCoordinate} + pos::Vector{Tuple{Float64, Float64}} + pin_rays::Vector{_LatticeCoordinate} end -# ============================================================================ -# Unweighted Filter Construction -# ============================================================================ +"""Outcome of a bounded direct SAT search on one concrete lattice.""" +struct UnweightedSearchResult + target_graph::SimpleGraph{Int} + target_boundary::Vector{Int} + lattice::Symbol + gadgets::Vector{UnweightedGadget} + evaluated::Int + termination_reason::Symbol +end +struct _LatticePatch + coordinates::Vector{_LatticeCoordinate} + pins::Vector{_LatticeCoordinate} + rays::Vector{Int} +end + +""" +Search a four-pin unweighted gadget in a finite window of `lattice`. +Every returned gadget satisfies the reduced-alpha target up to a constant and +the four-direction crossing geometry. """ - _make_unweighted_filter(pattern_graph, pattern_boundary; prefilter) +function search_unweighted_gadgets( + target_graph::SimpleGraph{Int}, + target_boundary::Vector{Int}, + lattice::LatticeType=Triangular(); + min_vertices::Int=length(target_boundary) + 1, + max_vertices::Int=min_vertices + 8, + max_evaluations::Int=2_000, + max_frame_evaluations::Int=1_000_000, + max_results::Int=1, + window_side::Int=4, +) + boundary_count = length(target_boundary) + boundary_count == 4 || + throw(ArgumentError("unweighted lattice search requires four boundary vertices")) + min_vertices >= boundary_count || + throw(ArgumentError("min_vertices must be at least the number of boundary vertices")) + max_vertices >= min_vertices || + throw(ArgumentError("max_vertices must be at least min_vertices")) + max_evaluations > 0 || + throw(ArgumentError("max_evaluations must be positive")) + max_frame_evaluations > 0 || + throw(ArgumentError("max_frame_evaluations must be positive")) + max_results > 0 || throw(ArgumentError("max_results must be positive")) + window_side >= 2 || throw(ArgumentError("window_side must be at least 2")) + + target_reduced = vec(calculate_reduced_alpha_tensor(target_graph, target_boundary)) + all(isinf, target_reduced) && + error("target graph has an entirely -Inf reduced alpha tensor") + gadgets, evaluated, reason = _search_crossing_sat( + target_graph, target_boundary, target_reduced, lattice; + min_vertices, max_vertices, max_evaluations, max_frame_evaluations, + max_results, window_side, + ) + return UnweightedSearchResult( + target_graph, copy(target_boundary), _lattice_symbol(lattice), + gadgets, evaluated, reason, + ) +end + +function _analyze_crossing_candidate(target_reduced, lattice, sites, pins, rays) + patch = _LatticePatch(sort(copy(sites)), copy(pins), copy(rays)) + graph, boundary, positions = _materialize_lattice_patch(lattice, patch) + reduced = vec(calculate_reduced_alpha_tensor(graph, boundary)) + tensor_valid, offset = is_diff_by_constant(reduced, target_reduced) + geometry_valid = all(_check_crossing_frame(lattice, patch)) + solved = tensor_valid && geometry_valid && is_connected(graph) + return (; + graph, boundary, positions, patch, solved, offset=Float64(offset), + ) +end + +function _unweighted_gadget(target_graph, lattice, analysis) + return UnweightedGadget( + target_graph, analysis.graph, analysis.boundary, analysis.offset, + _lattice_symbol(lattice), copy(analysis.patch.coordinates), + analysis.positions, _patch_ray_directions(lattice, analysis.patch), + ) +end + +_rotate_lattice_coordinate(::Triangular, point) = (-point[2], point[1] + point[2]) +_reflect_lattice_coordinate(::Triangular, point) = (point[2], point[1]) +_rotate_lattice_coordinate(::Square, point) = (-point[2], point[1]) +_reflect_lattice_coordinate(::Square, point) = (point[1], -point[2]) + +function _transform_lattice_coordinate(lattice, point, rotations, reflected) + transformed = reflected ? _reflect_lattice_coordinate(lattice, point) : point + for _ in 1:rotations + transformed = _rotate_lattice_coordinate(lattice, transformed) + end + return transformed +end + +function _canonical_crossing_frame_key(lattice, frame) + allowed = _canonical_coordinate.(Ref(lattice), frame.allowed) + pins = _canonical_coordinate.(Ref(lattice), frame.pins) + rays = _lattice_directions(lattice)[frame.rays] + return minimum((begin + transformed_allowed = _transform_lattice_coordinate.( + Ref(lattice), allowed, rotations, reflected, + ) + transformed_pins = _transform_lattice_coordinate.( + Ref(lattice), pins, rotations, reflected, + ) + transformed_rays = _transform_lattice_coordinate.( + Ref(lattice), rays, rotations, reflected, + ) + minimum_first = minimum(first, transformed_allowed) + minimum_last = minimum(last, transformed_allowed) + normalize(point) = (point[1] - minimum_first, point[2] - minimum_last) + ( + Tuple(sort(normalize.(transformed_allowed))), + Tuple(normalize.(transformed_pins)), + Tuple(transformed_rays), + ) + end for reflected in (false, true) for rotations in + 0:(lattice isa Triangular ? 5 : 3))) +end + +function _materialize_lattice_patch(lattice::LatticeType, patch::_LatticePatch) + positions = get_physical_positions(lattice, patch.coordinates) + graph = unit_disk_graph(positions, get_radius(lattice)) + coordinate_index = Dict(coordinate => index for (index, coordinate) in enumerate(patch.coordinates)) + boundary = [coordinate_index[pin] for pin in patch.pins] + return graph, boundary, positions +end + +function _normalize_lattice_patch( + ::Square, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, + rays::Vector{Int}, +) + min_x = minimum(first, coordinates) + min_y = minimum(last, coordinates) + translate(point) = (point[1] - min_x, point[2] - min_y) + return _LatticePatch(sort!(translate.(coordinates)), translate.(pins), copy(rays)) +end + +function _normalize_lattice_patch( + ::Triangular, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, + rays::Vector{Int}, +) + axial = [_offset_to_axial(point) for point in coordinates] + pin_axial = [_offset_to_axial(point) for point in pins] + min_q = minimum(first, axial) + min_r = minimum(last, axial) + translate(point) = (point[1] - min_q, point[2] - min_r) + translated = _axial_to_offset.(translate.(axial)) + translated_pins = _axial_to_offset.(translate.(pin_axial)) + return _LatticePatch(sort!(translated), translated_pins, copy(rays)) +end + +_offset_to_axial(point::_LatticeCoordinate) = (point[1] - fld(point[2], 2), point[2]) +_axial_to_offset(point::_LatticeCoordinate) = (point[1] + fld(point[2], 2), point[2]) + +_lattice_directions(::Square) = _LatticeCoordinate[ + (-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1), +] +_lattice_directions(::Triangular) = _LatticeCoordinate[ + (1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1), +] + +function _lattice_step(::Square, point::_LatticeCoordinate, direction::_LatticeCoordinate, distance::Int) + return (point[1] + distance * direction[1], point[2] + distance * direction[2]) +end + +function _lattice_step(::Triangular, point::_LatticeCoordinate, direction::_LatticeCoordinate, distance::Int) + q, r = _offset_to_axial(point) + return _axial_to_offset((q + distance * direction[1], r + distance * direction[2])) +end + +_lattice_symbol(::Square) = :KSG +_lattice_symbol(::Triangular) = :triangular + +_patch_ray_directions(lattice::LatticeType, patch::_LatticePatch) = + _lattice_directions(lattice)[patch.rays] -Build a filter closure that checks candidate graphs against the target pattern. """ -function _make_unweighted_filter( - pattern_graph::SimpleGraph{Int}, - pattern_boundary::Vector{Int}; - prefilter::Bool=true, + check_crossing_frame(lattice, coordinates, pins, pin_rays) + +Check the four geometric crossing-frame conditions. `pin_rays[i]` is the +outward lattice direction attached to `pins[i]`. The returned named tuple reports +G1 (strict hull interfaces), G2 (alternating channels), G3 (outward rays), and +G4 (clear pairwise non-adjacent exterior corridors). +""" +function check_crossing_frame( + lattice::LatticeType, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, + pin_rays::Vector{_LatticeCoordinate}, ) - k = length(pattern_boundary) - target_reduced = vec(calculate_reduced_alpha_tensor(pattern_graph, pattern_boundary)) - all(isinf, target_reduced) && error("target graph has an entirely -Inf reduced alpha tensor") - target_mask = inf_mask(target_reduced) - apply_prefilter = prefilter && pins_prefilter(pattern_graph, pattern_boundary) - return function(candidate::SimpleGraph{Int}, pos, pin_set) - vertex_pool = something(pin_set, 1:Graphs.nv(candidate)) - if apply_prefilter && !pins_prefilter(candidate, vertex_pool) - return nothing + length(pins) == 4 || throw(ArgumentError("a crossing frame requires four ordered pins")) + length(pin_rays) == 4 || throw(ArgumentError("a crossing frame requires four pin rays")) + length(unique(coordinates)) == length(coordinates) || + throw(ArgumentError("crossing-frame coordinates must be unique")) + length(unique(pins)) == 4 || + throw(ArgumentError("crossing-frame pins must be distinct")) + all(in(Set(coordinates)), pins) || + throw(ArgumentError("every crossing-frame pin must be present in coordinates")) + directions = _lattice_directions(lattice) + ray_indices = [_lattice_direction_index(directions, ray) for ray in pin_rays] + patch = _normalize_lattice_patch(lattice, coordinates, pins, ray_indices) + checks = _check_crossing_frame(lattice, patch) + return (G1=checks[1], G2=checks[2], G3=checks[3], G4=checks[4]) +end + +function _lattice_direction_index(directions, ray) + index = findfirst(==(ray), directions) + index === nothing && throw(ArgumentError("$ray is not a lattice direction")) + return index +end + +function _check_crossing_frame(lattice::LatticeType, patch::_LatticePatch) + length(patch.pins) == 4 || return (true, true, true, true) + directions = _patch_ray_directions(lattice, patch) + interfaces = [ + _lattice_step(lattice, pin, direction, 1) + for (pin, direction) in zip(patch.pins, directions) + ] + occupied_geometry = _geometry_coordinate.(Ref(lattice), patch.coordinates) + interface_geometry = _geometry_coordinate.(Ref(lattice), interfaces) + hull = _strict_convex_hull([occupied_geometry; interface_geometry]) + + g1 = length(unique(interface_geometry)) == 4 && all(in(hull), interface_geometry) + g2 = g1 && _interfaces_alternate(hull, interface_geometry) + g3 = _rays_point_outward(lattice, interfaces, directions) + g4 = _corridors_are_clear(lattice, patch.coordinates, patch.pins, interfaces, directions) + return (g1, g2, g3, g4) +end + +_canonical_coordinate(::Square, point::_LatticeCoordinate) = point +_canonical_coordinate(::Triangular, point::_LatticeCoordinate) = _offset_to_axial(point) +_from_canonical(::Square, point::_LatticeCoordinate) = point +_from_canonical(::Triangular, point::_LatticeCoordinate) = _axial_to_offset(point) +_lattice_distance(::Square, first, second) = max(abs(first[1] - second[1]), abs(first[2] - second[2])) +_lattice_distance(::Triangular, first, second) = max(abs(first[1] - second[1]), abs(first[2] - second[2]), abs(sum(first) - sum(second))) +_geometry_coordinate(::Square, point::_LatticeCoordinate) = point +function _geometry_coordinate(::Triangular, point::_LatticeCoordinate) + q, r = _offset_to_axial(point) + return (2q + r, r) +end + +_orientation(a, b, c) = + (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]) + +function _strict_convex_hull(points::Vector{_LatticeCoordinate}) + sorted_points = sort!(unique(points)) + length(sorted_points) <= 2 && return sorted_points + lower = _LatticeCoordinate[] + for point in sorted_points + while length(lower) >= 2 && _orientation(lower[end-1], lower[end], point) <= 0 + pop!(lower) end - (Graphs.nv(candidate) < k || length(vertex_pool) < k) && return nothing - for boundary in Combinatorics.combinations(vertex_pool, k) - candidate_reduced = vec(calculate_reduced_alpha_tensor(candidate, boundary)) - all(isinf, candidate_reduced) && continue - candidate_mask = inf_mask(candidate_reduced) - candidate_mask == target_mask || continue - valid, constant_offset = is_diff_by_constant(candidate_reduced, target_reduced) - if valid - return UnweightedGadget( - pattern_graph, candidate, boundary, - constant_offset, pos) - end + push!(lower, point) + end + upper = _LatticeCoordinate[] + for point in Iterators.reverse(sorted_points) + while length(upper) >= 2 && _orientation(upper[end-1], upper[end], point) <= 0 + pop!(upper) end - return nothing + push!(upper, point) end + return [lower[1:end-1]; upper[1:end-1]] end -# ============================================================================ -# Unweighted Search -# ============================================================================ +function _interfaces_alternate( + hull::Vector{_LatticeCoordinate}, + interfaces::Vector{_LatticeCoordinate}, +) + labels = Dict(point => label for (label, point) in enumerate(interfaces)) + order = [labels[point] for point in hull if haskey(labels, point)] + length(order) == 4 || return false + return all(isodd(order[index]) != isodd(order[mod1(index + 1, 4)]) for index in 1:4) +end -""" - search_unweighted_gadgets(target_graph, target_boundary, loader; kwargs...) +function _rays_point_outward( + lattice::LatticeType, + interfaces::Vector{_LatticeCoordinate}, + directions::Vector{_LatticeCoordinate}, +) + canonical_interfaces = _canonical_coordinate.(Ref(lattice), interfaces) + sum_q = sum(first, canonical_interfaces) + sum_r = sum(last, canonical_interfaces) + for (interface, direction) in zip(canonical_interfaces, directions) + out_q = 4interface[1] - sum_q + out_r = 4interface[2] - sum_r + if lattice isa Square + out_q * direction[1] + out_r * direction[2] > 0 || return false + else + out_x = 2out_q + out_r + direction_x = 2direction[1] + direction[2] + out_x * direction_x + 3out_r * direction[2] > 0 || return false + end + end + return true +end -Search for unweighted gadget replacements of `target_graph` by iterating over a `GraphLoader`. -""" -function search_unweighted_gadgets( - target_graph::SimpleGraph{Int}, - target_boundary::Vector{Int}, - loader::GraphLoader; - prefilter::Bool=true, - limit::Union{Int,Nothing}=nothing, - max_results::Union{Int,Nothing}=nothing, +function _corridors_are_clear( + lattice::LatticeType, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, + interfaces::Vector{_LatticeCoordinate}, + directions::Vector{_LatticeCoordinate}, ) - total = isnothing(limit) ? length(loader) : min(length(loader), limit) - filter_fn = _make_unweighted_filter(target_graph, target_boundary; prefilter) - results = UnweightedGadget[] - @showprogress for key in Iterators.take(keys(loader), total) - result = filter_fn(loader[key], loader.layout[key], loader.pinset) - result === nothing && continue - push!(results, result) - max_results !== nothing && length(results) >= max_results && break + occupied = _canonical_coordinate.(Ref(lattice), coordinates) + canonical_pins = _canonical_coordinate.(Ref(lattice), pins) + starts = _canonical_coordinate.(Ref(lattice), interfaces) + adjacency_offsets = [_LatticeCoordinate[(0, 0)]; _lattice_directions(lattice)] + + for index in eachindex(starts), site in occupied + site == canonical_pins[index] && continue + for offset in adjacency_offsets + _point_on_ray((site[1] + offset[1], site[2] + offset[2]), starts[index], directions[index]) && + return false + end + end + for first in 1:3, second in first+1:4, offset in adjacency_offsets + _rays_touch( + starts[first], directions[first], starts[second], directions[second], offset, + ) && return false + end + return true +end + +function _point_on_ray(point, start, direction) + displacement = (point[1] - start[1], point[2] - start[2]) + multiple = _direction_multiple(displacement, direction) + return multiple !== nothing && multiple >= 0 +end + +function _direction_multiple(displacement, direction) + if direction[1] != 0 + rem(displacement[1], direction[1]) == 0 || return nothing + multiple = div(displacement[1], direction[1]) + else + direction[2] != 0 || error("zero ray direction") + rem(displacement[2], direction[2]) == 0 || return nothing + multiple = div(displacement[2], direction[2]) + end + displacement == (multiple * direction[1], multiple * direction[2]) || return nothing + return multiple +end + +function _rays_touch(start1, direction1, start2, direction2, offset) + right = (start2[1] + offset[1] - start1[1], start2[2] + offset[2] - start1[2]) + determinant = direction1[2] * direction2[1] - direction1[1] * direction2[2] + if determinant != 0 + first_numerator = right[2] * direction2[1] - right[1] * direction2[2] + second_numerator = direction1[1] * right[2] - direction1[2] * right[1] + rem(first_numerator, determinant) == 0 || return false + rem(second_numerator, determinant) == 0 || return false + return div(first_numerator, determinant) >= 0 && div(second_numerator, determinant) >= 0 + end + + if direction1 == direction2 + return _direction_multiple(right, direction1) !== nothing end - return results + multiple = _direction_multiple(right, direction1) + return multiple !== nothing && multiple >= 0 end # ============================================================================ diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 3a7fa70..b4c6ef7 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -2,127 +2,123 @@ using GadgetSearch using Graphs using Test -function _cross_graph() - g = SimpleGraph(4) - add_edge!(g, 1, 3) - add_edge!(g, 2, 4) - return g +function cross_graph() + graph = SimpleGraph(4) + add_edge!(graph, 1, 3) + add_edge!(graph, 2, 4) + return graph end - -function _batoidea_graph() - g = SimpleGraph(11) - add_edge!(g, 1, 5); add_edge!(g, 1, 9) - add_edge!(g, 2, 5); add_edge!(g, 2, 6); add_edge!(g, 2, 7) - add_edge!(g, 3, 8) - add_edge!(g, 4, 9); add_edge!(g, 4, 10); add_edge!(g, 4, 11) - add_edge!(g, 5, 6); add_edge!(g, 5, 9); add_edge!(g, 5, 10) - add_edge!(g, 6, 7); add_edge!(g, 6, 9); add_edge!(g, 6, 10); add_edge!(g, 6, 11) - add_edge!(g, 7, 8); add_edge!(g, 7, 10); add_edge!(g, 7, 11) - add_edge!(g, 8, 11) - add_edge!(g, 9, 10) - add_edge!(g, 10, 11) - return g -end - -function _edge_graph() - g = SimpleGraph(2) - add_edge!(g, 1, 2) - return g -end - -function _connected_graph() - g = SimpleGraph(4) - add_edge!(g, 1, 3) - add_edge!(g, 1, 4) - add_edge!(g, 2, 4) - add_edge!(g, 3, 4) - return g -end - -function _isolated_graph() - g = SimpleGraph(3) - add_edge!(g, 1, 2) - return g -end - -function _to_g6(g) - return graph_to_g6(g) -end - -@testset "Unweighted Search" begin - @testset "search_unweighted_gadgets: basic" begin - cross = _cross_graph() - batoidea = _batoidea_graph() - loader = GraphLoader( - GraphDataset([_to_g6(cross), _to_g6(batoidea)]), - pinset=[1, 2, 3, 4], - ) - results = search_unweighted_gadgets(cross, [1, 2, 3, 4], loader) - @test results isa Vector{UnweightedGadget} - @test any(r -> r.constant_offset == 0.0, results) - @test any(r -> r.constant_offset == 2.0, results) - @test all(r -> r.pattern_graph == cross, results) - @test !hasproperty(UnweightedGadget, :target_index) +@testset "Unweighted search" begin + @testset "fixed verifier" begin + reduced = calculate_reduced_alpha_tensor(cross_graph(), [1, 2, 3, 4]) + @test GadgetSearch.inf_mask(reduced) == BigInt(60576) + @test is_diff_by_constant(reduced .+ 3, reduced) == (true, 3.0) end - @testset "search_unweighted_gadgets: limit and max_results" begin - cross = _cross_graph() - batoidea = _batoidea_graph() - loader = GraphLoader( - GraphDataset([_to_g6(cross), _to_g6(batoidea)]), - pinset=[1, 2, 3, 4], + @testset "crossing frame" begin + square = [(0, 0), (-1, 0), (0, 1), (1, 0), (0, -1)] + pins = square[2:5] + rays = [(-1, 0), (0, 1), (1, 0), (0, -1)] + @test all(check_crossing_frame(Square(), square, pins, rays)) + @test !check_crossing_frame(Square(), [square; (-2, 1)], pins, rays).G4 + @test_throws ArgumentError check_crossing_frame( + Square(), [(0, 0)], pins, rays, ) - limited = search_unweighted_gadgets(cross, [1, 2, 3, 4], loader; limit=1) - @test length(limited) == 1 - @test limited[1].constant_offset == 0.0 - capped = search_unweighted_gadgets(cross, [1, 2, 3, 4], loader; max_results=1) - @test length(capped) == 1 - end - - @testset "search_unweighted_gadgets: prefilter rejects disconnected pin coverage" begin - loader = GraphLoader(GraphDataset([_to_g6(_cross_graph())]), pinset=[1, 3]) - edge = _edge_graph() - results_on = search_unweighted_gadgets(edge, [1, 2], loader; prefilter=true) - results_off = search_unweighted_gadgets(edge, [1, 2], loader; prefilter=false) - @test isempty(results_on) - @test length(results_off) == 1 + @test_throws ArgumentError check_crossing_frame( + Square(), [square; square[1]], pins, rays, + ) + @test_throws ArgumentError check_crossing_frame( + Square(), square, [pins[1], pins[1], pins[3], pins[4]], rays, + ) + triangular = [(0, 0), (-1, 0), (0, 1), (1, 0), (-1, -1)] + triangular_pins = triangular[2:5] + @test all(check_crossing_frame( + Triangular(), triangular, triangular_pins, rays, + )) end - @testset "UnweightedGadget has no target_index" begin - @test !(:target_index in fieldnames(UnweightedGadget)) - end + @testset "fixed SAT positive control" begin + coordinates = [ + (0,3),(6,6),(1,3),(2,1),(2,2),(2,3),(3,1),(3,3), + (3,4),(3,5),(4,1),(4,2),(4,4),(4,5),(5,1),(5,2), + (5,3),(5,4),(5,5),(6,2),(6,3),(6,5),(7,4), + ] + frame = ( + pins=[(5,1),(0,3),(3,5),(6,6)], + rays=[6,3,3,2], + allowed=coordinates, + ) + target_reduced = vec(calculate_reduced_alpha_tensor( + cross_graph(), [1,2,3,4], + )) + analysis = GadgetSearch._solve_fixed_crossing_sat( + target_reduced, Triangular(), frame, 23, 7, + ) + @test analysis !== nothing + @test analysis.offset == 7 + @test nv(analysis.graph) == 23 + @test is_gadget_replacement( + cross_graph(), analysis.graph, [1,2,3,4], analysis.boundary, + ) == (true, 7.0) + @test all(check_crossing_frame( + Triangular(), analysis.patch.coordinates, analysis.patch.pins, + GadgetSearch._patch_ray_directions(Triangular(), analysis.patch), + )) + shifted_target = map(value -> isfinite(value) ? value + 8 : value, target_reduced) + negative_offset = GadgetSearch._solve_fixed_crossing_sat( + shifted_target, Triangular(), frame, 23, -1, + ) + @test negative_offset !== nothing + @test negative_offset.offset == -1 - @testset "inf_mask (internal)" begin - @test GadgetSearch.inf_mask([0.0, -Inf, 3.0, -Inf]) == BigInt(10) - @test GadgetSearch.inf_mask(fill(-Inf, 4)) == BigInt(15) - reduced = calculate_reduced_alpha_tensor(_cross_graph(), [1, 2, 3, 4]) - @test GadgetSearch.inf_mask(reduced) == BigInt(60576) + ksg_coordinates = [(0, 0), (-1, 0), (0, 1), (1, 0), (0, -1)] + ksg_frame = ( + pins=ksg_coordinates[2:5], + rays=[4, 7, 5, 2], + allowed=ksg_coordinates, + ) + ksg_patch = GadgetSearch._LatticePatch( + ksg_coordinates, ksg_frame.pins, ksg_frame.rays, + ) + ksg_graph, ksg_boundary, _ = GadgetSearch._materialize_lattice_patch( + Square(), ksg_patch, + ) + ksg_target = vec(calculate_reduced_alpha_tensor(ksg_graph, ksg_boundary)) + ksg_analysis = GadgetSearch._solve_fixed_crossing_sat( + ksg_target, Square(), ksg_frame, 5, 0, + ) + @test ksg_analysis !== nothing + @test nv(ksg_analysis.graph) == 5 + @test all(check_crossing_frame( + Square(), ksg_analysis.patch.coordinates, ksg_analysis.patch.pins, + GadgetSearch._patch_ray_directions(Square(), ksg_analysis.patch), + )) + @test isnothing(GadgetSearch._solve_fixed_crossing_sat( + ksg_target, Square(), ksg_frame, 6, 0, + )) end - @testset "pins_prefilter (internal)" begin - connected = _connected_graph() - disconnected = _cross_graph() - isolated = _isolated_graph() - @test GadgetSearch.pins_prefilter(connected, [1]) - @test GadgetSearch.pins_prefilter(disconnected, [1, 2]) - @test !GadgetSearch.pins_prefilter(disconnected, [1]) - @test !GadgetSearch.pins_prefilter(isolated, [1]) - @test GadgetSearch.pins_prefilter(isolated, [1, 3]) - @test_throws ErrorException GadgetSearch.pins_prefilter(connected, [1, 1]) - @test_throws ErrorException GadgetSearch.pins_prefilter(connected, [0]) - end + @testset "public bounded search" begin + @test_throws ArgumentError search_unweighted_gadgets( + path_graph(3), [1,2,3], Triangular(), + ) + @test_throws ArgumentError search_unweighted_gadgets( + path_graph(4), [1,2,3,4]; window_side=1, + ) + ksg = search_unweighted_gadgets( + cross_graph(), [1,2,3,4], Square(); + min_vertices=4, max_vertices=4, max_evaluations=1, + ) + @test ksg.lattice == :KSG + @test ksg.evaluated <= 1 + @test ksg.termination_reason in (:budget, :frame_budget) - @testset "Triangular UDG Integration" begin - path = tempname() * ".g6" - try - generate_full_grid_udg(Triangular(), 1, 1; path=path) - loader = GraphLoader(path; pinset=[1, 2, 3, 4]) - target = loader[1] - results = search_unweighted_gadgets(target, [1, 2, 3, 4], loader; limit=1, max_results=1) - @test length(results) == 1 - @test results[1].constant_offset == 0.0 - finally - isfile(path) && rm(path) - end + exhausted = search_unweighted_gadgets( + cross_graph(), [1,2,3,4], Triangular(); + min_vertices=4, max_vertices=4, max_evaluations=1, + ) + @test exhausted.evaluated == 1 + @test isempty(exhausted.gadgets) + @test exhausted.termination_reason in (:budget, :frame_budget) end end From 7d6002d708dd8b3647a30e5d009c001bdc169981 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 22:30:04 +0800 Subject: [PATCH 2/7] Address review: enumerate SAT models cross-platform --- Project.toml | 4 +- docs/src/unweighted_search.md | 6 ++- src/GadgetSearch.jl | 2 +- src/core/unweighted_sat.jl | 88 ++++++++++++++++++++++++++-------- test/core/unweighted_search.jl | 13 +++++ 5 files changed, 87 insertions(+), 26 deletions(-) diff --git a/Project.toml b/Project.toml index d3e83f0..52f0e5f 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ authors = ["Xiwei Pan"] ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4" Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" +CryptoMiniSat = "8e9d5576-ec03-11e9-187e-e534548435ab" GenericTensorNetworks = "3521c873-ad32-4bb4-b63d-f4f178f42b49" GraphIO = "aa1b3936-2fda-51b9-ab35-c553d3a640a2" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" @@ -16,7 +17,6 @@ IterTools = "c8e1da08-722c-5040-9ed9-7db0dc04731e" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Karnak = "cd156443-31ad-4f6f-850f-a93ee5f75905" -Kissat = "057e91dc-0d29-4c6c-955d-3db04e6fbc7f" Luxor = "ae8d54c2-7ccd-5906-9d76-62fc9837b5bc" ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -26,6 +26,7 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" ColorSchemes = "3.29.0" Colors = "0.13" Combinatorics = "1" +CryptoMiniSat = "0.3.0" GenericTensorNetworks = "4.1.0" GraphIO = "0.7.1" Graphs = "1.9.0" @@ -35,7 +36,6 @@ IterTools = "1.10.0" JSON3 = "1.14.1" JuMP = "1" Karnak = "1.1.0" -Kissat = "0.1.0" Luxor = "4.1.0" ProgressMeter = "1.10.4" Serialization = "1.11.0" diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 09b5725..6780756 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -19,7 +19,9 @@ rewrite path. `min_vertices:max_vertices` is examined in increasing order, so reaching a larger atom count means all scheduled smaller cases have already been rejected. -`max_evaluations` bounds SAT instances, not graph mutations. Every returned +`max_evaluations` bounds SAT solver calls, not graph mutations. For each fixed +layout, atom count, and offset, satisfying site selections are blocked and the +solver is called again until that case is unsatisfiable. Every returned candidate is independently checked by both `is_gadget_replacement` and `check_crossing_frame`. @@ -47,6 +49,6 @@ result = search_unweighted_gadgets( The keyword defaults are `min_vertices=5`, `max_vertices=13`, `max_evaluations=2_000`, `max_frame_evaluations=1_000_000`, `max_results=1`, and `window_side=4` for a four-pin target. The result contains the target, -ordered boundary, lattice name, accepted gadgets, number of SAT instances, and +ordered boundary, lattice name, accepted gadgets, number of SAT solver calls, and one of four termination reasons: `:solution`, `:budget`, `:frame_budget`, or `:search_space_exhausted`. diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 283fb63..4184a4c 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -9,7 +9,7 @@ using IterTools using Combinatorics using Random using Serialization -import Kissat +import CryptoMiniSat # Tensor network dependencies for alpha tensor computation using GenericTensorNetworks: GenericTensorNetwork, IndependentSet, SizeMax, solve diff --git a/src/core/unweighted_sat.jl b/src/core/unweighted_sat.jl index 8b0d928..ffd3ad9 100644 --- a/src/core/unweighted_sat.jl +++ b/src/core/unweighted_sat.jl @@ -204,7 +204,15 @@ end function _solve_fixed_crossing_sat( target, lattice, context::_SatFrameContext, atom_count, offset, ) - frame = context.frame + solver, selected = _fixed_crossing_sat_problem( + target, context, atom_count, offset, + ) + return _solve_next_fixed_crossing_sat!( + solver, selected, target, lattice, context, + ) +end + +function _fixed_crossing_sat_problem(target, context, atom_count, offset) coordinates = context.coordinates pins = context.boundary edge_list = context.edge_list @@ -242,14 +250,21 @@ function _solve_fixed_crossing_sat( _add_selected_connectivity!( cnf, selected, context.adjacent, pins[1], atom_count, ) + solver = CryptoMiniSat.CMS(cnf.variables; num_threads=1) + for clause in cnf.clauses + CryptoMiniSat.add_clause(solver, clause) + end + return solver, selected +end - solver = Kissat.init() - Kissat.set_option(solver, "quiet", 1) - append!(solver, cnf.clauses) - Kissat.solve(solver) == :satisfiable || return nothing - values = Base.values(solver, 1:length(selected)) - chosen = findall(>(0), values) - sites = coordinates[chosen] +function _solve_next_fixed_crossing_sat!( + solver, selected, target, lattice, context, +) + assignment = _next_selected_assignment!(solver, selected) + assignment === nothing && return nothing + chosen = findall(identity, assignment) + sites = context.coordinates[chosen] + frame = context.frame analysis = _analyze_crossing_candidate( target, lattice, sites, frame.pins, frame.rays, ) @@ -257,6 +272,20 @@ function _solve_fixed_crossing_sat( return analysis end +function _next_selected_assignment!(solver, selected) + status = CryptoMiniSat.solve(solver) + status === false && return nothing + status === true || error("SAT solver returned an undefined result") + model = CryptoMiniSat.get_model(solver) + assignment = Bool[model[variable] for variable in selected] + blocking_clause = [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ] + CryptoMiniSat.add_clause(solver, blocking_clause) + return assignment +end + function _search_crossing_sat( target_graph, target_boundary, target_reduced, lattice; min_vertices, max_vertices, max_evaluations, max_frame_evaluations, @@ -266,6 +295,7 @@ function _search_crossing_sat( error("the reduced alpha tensor must contain integers or -Inf") completion = _target_completion(target_reduced) gadgets = UnweightedGadget[] + seen_gadgets = Set{Tuple}() evaluated = 0 frame_evaluated = Ref(0) for atom_count in min_vertices:max_vertices @@ -280,20 +310,36 @@ function _search_crossing_sat( context = _prepare_sat_frame(lattice, frame) for offset in offsets evaluated == max_evaluations && return :budget - evaluated += 1 - analysis = _solve_fixed_crossing_sat( - target_reduced, lattice, context, atom_count, offset, - ) - analysis === nothing && continue - valid, verified_offset = is_gadget_replacement( - target_graph, analysis.graph, target_boundary, analysis.boundary, + solver, selected = _fixed_crossing_sat_problem( + target_reduced, context, atom_count, offset, ) - valid || error("SAT candidate failed the fixed verifier") - analysis.offset == verified_offset || - error("SAT candidate offset mismatch") - push!(gadgets, _unweighted_gadget(target_graph, lattice, analysis)) - length(gadgets) == max_results && - return :solution + while true + evaluated == max_evaluations && return :budget + evaluated += 1 + analysis = _solve_next_fixed_crossing_sat!( + solver, selected, target_reduced, lattice, context, + ) + analysis === nothing && break + valid, verified_offset = is_gadget_replacement( + target_graph, analysis.graph, target_boundary, + analysis.boundary, + ) + valid || error("SAT candidate failed the fixed verifier") + analysis.offset == verified_offset || + error("SAT candidate offset mismatch") + key = _canonical_crossing_frame_key(lattice, ( + allowed=analysis.patch.coordinates, + pins=analysis.patch.pins, + rays=analysis.patch.rays, + )) + key in seen_gadgets && continue + push!(seen_gadgets, key) + push!( + gadgets, + _unweighted_gadget(target_graph, lattice, analysis), + ) + length(gadgets) == max_results && return :solution + end end return nothing end diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index b4c6ef7..9d8a341 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -98,6 +98,19 @@ end )) end + @testset "SAT model enumeration" begin + solver = GadgetSearch.CryptoMiniSat.CMS(3; num_threads=1) + assignments = Set{Tuple{Bool, Bool}}() + while true + assignment = GadgetSearch._next_selected_assignment!(solver, [1, 2]) + assignment === nothing && break + push!(assignments, Tuple(assignment)) + end + @test assignments == Set([ + (false, false), (false, true), (true, false), (true, true), + ]) + end + @testset "public bounded search" begin @test_throws ArgumentError search_unweighted_gadgets( path_graph(3), [1,2,3], Triangular(), From 9847e3cf585b9deb1e87d7956910cb9c1a0e37a4 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 23:07:33 +0800 Subject: [PATCH 3/7] Test public SAT search completion paths --- docs/src/unweighted_search.md | 3 +++ test/core/unweighted_search.jl | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 6780756..e08601c 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -25,6 +25,9 @@ solver is called again until that case is unsatisfiable. Every returned candidate is independently checked by both `is_gadget_replacement` and `check_crossing_frame`. +The implementation uses CryptoMiniSat as an incremental solver for ordinary +CNF clauses. The encoding does not use its optional XOR-clause extension. + `max_frame_evaluations` separately bounds pin-and-ray geometry checks. Increase it together with `window_side` for exhaustive searches in larger windows. diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 9d8a341..39f21f9 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -133,5 +133,37 @@ end @test exhausted.evaluated == 1 @test isempty(exhausted.gadgets) @test exhausted.termination_reason in (:budget, :frame_budget) + + ksg_target = complete_graph(4) + solved = search_unweighted_gadgets( + ksg_target, collect(1:4), Square(); + min_vertices=4, max_vertices=4, max_evaluations=50, + max_frame_evaluations=100_000, max_results=1, window_side=2, + ) + @test solved.termination_reason == :solution + @test length(solved.gadgets) == 1 + @test is_gadget_replacement( + ksg_target, solved.gadgets[1].replacement_graph, collect(1:4), + solved.gadgets[1].boundary_vertices, + )[1] + + fully_enumerated = search_unweighted_gadgets( + ksg_target, collect(1:4), Square(); + min_vertices=4, max_vertices=4, max_evaluations=50, + max_frame_evaluations=100_000, max_results=2, window_side=2, + ) + @test fully_enumerated.termination_reason == :search_space_exhausted + @test length(fully_enumerated.gadgets) == 1 + @test fully_enumerated.evaluated > solved.evaluated + end + + @testset "lattice coordinate helpers" begin + point = (3, 4) + @test GadgetSearch._from_canonical(Square(), point) == point + @test GadgetSearch._from_canonical( + Triangular(), GadgetSearch._canonical_coordinate(Triangular(), point), + ) == point + @test GadgetSearch._lattice_distance(Square(), (0, 0), (2, -1)) == 2 + @test GadgetSearch._lattice_distance(Triangular(), (0, 0), (2, -1)) == 2 end end From 1b6099a725f465d10602534612912e103c864579 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 23:19:29 +0800 Subject: [PATCH 4/7] Use Kissat outside Windows --- Project.toml | 2 + README.md | 4 ++ docs/src/unweighted_search.md | 3 -- src/GadgetSearch.jl | 6 ++- src/core/unweighted_sat.jl | 70 +++++++++++++++++++++++++--------- test/core/unweighted_search.jl | 24 ++++++------ 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/Project.toml b/Project.toml index 52f0e5f..ee5b7c8 100644 --- a/Project.toml +++ b/Project.toml @@ -17,6 +17,7 @@ IterTools = "c8e1da08-722c-5040-9ed9-7db0dc04731e" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Karnak = "cd156443-31ad-4f6f-850f-a93ee5f75905" +Kissat_jll = "971a2a11-349f-5eb6-a4bf-bc548fa92b3a" Luxor = "ae8d54c2-7ccd-5906-9d76-62fc9837b5bc" ProgressMeter = "92933f4c-e287-5a05-a399-4b506db050ca" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -36,6 +37,7 @@ IterTools = "1.10.0" JSON3 = "1.14.1" JuMP = "1" Karnak = "1.1.0" +Kissat_jll = "3.0.0" Luxor = "4.1.0" ProgressMeter = "1.10.4" Serialization = "1.11.0" diff --git a/README.md b/README.md index a693bf5..791758c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ Unweighted mode ignores vertex weights, treats the pins as open vertices, and computes the reduced alpha tensor. The lattice dimensions can be adjusted from 2×2 to 20×20 and are preserved in exported JSON. +The unweighted SAT search uses Kissat on Linux and macOS. Windows uses +CryptoMiniSat because the Julia Kissat binary is unavailable there. Windows is +supported for tests, but not recommended for long searches; use Linux or macOS. + ### Rydberg Model (MIS-based) ```julia diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index e08601c..6780756 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -25,9 +25,6 @@ solver is called again until that case is unsatisfiable. Every returned candidate is independently checked by both `is_gadget_replacement` and `check_crossing_frame`. -The implementation uses CryptoMiniSat as an incremental solver for ordinary -CNF clauses. The encoding does not use its optional XOR-clause extension. - `max_frame_evaluations` separately bounds pin-and-ray geometry checks. Increase it together with `window_side` for exhaustive searches in larger windows. diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 4184a4c..7625e5d 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -9,7 +9,11 @@ using IterTools using Combinatorics using Random using Serialization -import CryptoMiniSat +@static if Sys.iswindows() + import CryptoMiniSat +else + import Kissat_jll +end # Tensor network dependencies for alpha tensor computation using GenericTensorNetworks: GenericTensorNetwork, IndependentSet, SizeMax, solve diff --git a/src/core/unweighted_sat.jl b/src/core/unweighted_sat.jl index ffd3ad9..95c41f2 100644 --- a/src/core/unweighted_sat.jl +++ b/src/core/unweighted_sat.jl @@ -250,11 +250,7 @@ function _fixed_crossing_sat_problem(target, context, atom_count, offset) _add_selected_connectivity!( cnf, selected, context.adjacent, pins[1], atom_count, ) - solver = CryptoMiniSat.CMS(cnf.variables; num_threads=1) - for clause in cnf.clauses - CryptoMiniSat.add_clause(solver, clause) - end - return solver, selected + return _new_sat_solver(cnf), selected end function _solve_next_fixed_crossing_sat!( @@ -272,18 +268,58 @@ function _solve_next_fixed_crossing_sat!( return analysis end -function _next_selected_assignment!(solver, selected) - status = CryptoMiniSat.solve(solver) - status === false && return nothing - status === true || error("SAT solver returned an undefined result") - model = CryptoMiniSat.get_model(solver) - assignment = Bool[model[variable] for variable in selected] - blocking_clause = [ - assignment[index] ? -variable : variable - for (index, variable) in enumerate(selected) - ] - CryptoMiniSat.add_clause(solver, blocking_clause) - return assignment +@static if Sys.iswindows() + function _new_sat_solver(cnf::_SatCnf) + solver = CryptoMiniSat.CMS(cnf.variables; num_threads=1) + foreach(clause -> CryptoMiniSat.add_clause(solver, clause), cnf.clauses) + return solver + end + + function _next_selected_assignment!(solver, selected) + status = CryptoMiniSat.solve(solver) + status === false && return nothing + status === true || error("SAT solver returned an undefined result") + model = CryptoMiniSat.get_model(solver) + assignment = Bool[model[variable] for variable in selected] + blocking_clause = [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ] + CryptoMiniSat.add_clause(solver, blocking_clause) + return assignment + end +else + mutable struct _KissatEnumerator + clauses::Vector{Vector{Int}} + end + + _new_sat_solver(cnf::_SatCnf) = _KissatEnumerator(copy(cnf.clauses)) + _kissat_init() = ccall((:kissat_init, Kissat_jll.libkissat), Ptr{Cvoid}, ()) + _kissat_add(solver, literal) = ccall((:kissat_add, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid}, Cint), solver, literal) + _kissat_solve(solver) = ccall((:kissat_solve, Kissat_jll.libkissat), Cint, (Ptr{Cvoid},), solver) + _kissat_value(solver, variable) = ccall((:kissat_value, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cint), solver, variable) + _kissat_release(solver) = ccall((:kissat_release, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid},), solver) + _kissat_quiet(solver) = ccall((:kissat_set_option, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cstring, Cint), solver, "quiet", 1) + + function _next_selected_assignment!(enumerator::_KissatEnumerator, selected) + solver = _kissat_init() + _kissat_quiet(solver) + for clause in enumerator.clauses + foreach(literal -> _kissat_add(solver, literal), clause) + _kissat_add(solver, 0) + end + status = _kissat_solve(solver) + assignment = status == 10 ? + Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing + _kissat_release(solver) + status == 20 && return nothing + status == 10 || error("SAT solver returned an undefined result") + push!(enumerator.clauses, [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ]) + return assignment + end end function _search_crossing_sat( diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 39f21f9..73fb4d1 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -99,7 +99,9 @@ end end @testset "SAT model enumeration" begin - solver = GadgetSearch.CryptoMiniSat.CMS(3; num_threads=1) + cnf = GadgetSearch._SatCnf() + foreach(_ -> GadgetSearch._sat_variable!(cnf), 1:3) + solver = GadgetSearch._new_sat_solver(cnf) assignments = Set{Tuple{Bool, Bool}}() while true assignment = GadgetSearch._next_selected_assignment!(solver, [1, 2]) @@ -137,32 +139,28 @@ end ksg_target = complete_graph(4) solved = search_unweighted_gadgets( ksg_target, collect(1:4), Square(); - min_vertices=4, max_vertices=4, max_evaluations=50, - max_frame_evaluations=100_000, max_results=1, window_side=2, + min_vertices=4, max_vertices=4, max_evaluations=50, max_results=1, + max_frame_evaluations=100_000, window_side=2, ) @test solved.termination_reason == :solution @test length(solved.gadgets) == 1 @test is_gadget_replacement( - ksg_target, solved.gadgets[1].replacement_graph, collect(1:4), - solved.gadgets[1].boundary_vertices, + ksg_target, solved.gadgets[1].replacement_graph, + collect(1:4), solved.gadgets[1].boundary_vertices, )[1] fully_enumerated = search_unweighted_gadgets( ksg_target, collect(1:4), Square(); - min_vertices=4, max_vertices=4, max_evaluations=50, - max_frame_evaluations=100_000, max_results=2, window_side=2, + min_vertices=4, max_vertices=4, max_evaluations=50, max_results=2, + max_frame_evaluations=100_000, window_side=2, ) @test fully_enumerated.termination_reason == :search_space_exhausted @test length(fully_enumerated.gadgets) == 1 @test fully_enumerated.evaluated > solved.evaluated - end - - @testset "lattice coordinate helpers" begin point = (3, 4) @test GadgetSearch._from_canonical(Square(), point) == point - @test GadgetSearch._from_canonical( - Triangular(), GadgetSearch._canonical_coordinate(Triangular(), point), - ) == point + canonical = GadgetSearch._canonical_coordinate(Triangular(), point) + @test GadgetSearch._from_canonical(Triangular(), canonical) == point @test GadgetSearch._lattice_distance(Square(), (0, 0), (2, -1)) == 2 @test GadgetSearch._lattice_distance(Triangular(), (0, 0), (2, -1)) == 2 end From c8310f2b0acbf25cd9cf5444c2512303661c73f3 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 23:28:02 +0800 Subject: [PATCH 5/7] Make lattice result mapping coverable --- src/core/unweighted_search.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index 997c4f0..f21ff90 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -190,8 +190,9 @@ function _lattice_step(::Triangular, point::_LatticeCoordinate, direction::_Latt return _axial_to_offset((q + distance * direction[1], r + distance * direction[2])) end -_lattice_symbol(::Square) = :KSG -_lattice_symbol(::Triangular) = :triangular +function _lattice_symbol(lattice::LatticeType) + return lattice isa Square ? :KSG : :triangular +end _patch_ray_directions(lattice::LatticeType, patch::_LatticePatch) = _lattice_directions(lattice)[patch.rays] From d7fce55d984e4db5ed314f1c43a361f4f8db0c45 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 17:14:06 +0800 Subject: [PATCH 6/7] Complete unweighted gadget synthesis framework --- .gitattributes | 1 + docs/figures/unweighted-sat/bdd-example.svg | 1 + .../unweighted-sat/connectivity-layers.svg | 1 + .../unweighted-sat/frame-enumeration.svg | 1 + docs/figures/unweighted-sat/pipeline.svg | 1 + docs/figures/unweighted-sat/verification.svg | 1 + docs/src/unweighted_search.md | 59 + docs/unweighted-sat-search-report.pdf | Bin 0 -> 230717 bytes docs/unweighted-sat-search-report.typ | 452 +++++++ src/GadgetSearch.jl | 5 + src/core/unweighted_sat.jl | 1107 +++++++++++++++-- src/core/unweighted_search.jl | 387 +++++- test/core/unweighted_search.jl | 469 +++++++ 13 files changed, 2360 insertions(+), 125 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/figures/unweighted-sat/bdd-example.svg create mode 100644 docs/figures/unweighted-sat/connectivity-layers.svg create mode 100644 docs/figures/unweighted-sat/frame-enumeration.svg create mode 100644 docs/figures/unweighted-sat/pipeline.svg create mode 100644 docs/figures/unweighted-sat/verification.svg create mode 100644 docs/unweighted-sat-search-report.pdf create mode 100644 docs/unweighted-sat-search-report.typ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5c0d01e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/unweighted-sat-search-report.pdf binary diff --git a/docs/figures/unweighted-sat/bdd-example.svg b/docs/figures/unweighted-sat/bdd-example.svg new file mode 100644 index 0000000..91f2cfd --- /dev/null +++ b/docs/figures/unweighted-sat/bdd-example.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/figures/unweighted-sat/connectivity-layers.svg b/docs/figures/unweighted-sat/connectivity-layers.svg new file mode 100644 index 0000000..897841a --- /dev/null +++ b/docs/figures/unweighted-sat/connectivity-layers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/figures/unweighted-sat/frame-enumeration.svg b/docs/figures/unweighted-sat/frame-enumeration.svg new file mode 100644 index 0000000..ba48cb3 --- /dev/null +++ b/docs/figures/unweighted-sat/frame-enumeration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/figures/unweighted-sat/pipeline.svg b/docs/figures/unweighted-sat/pipeline.svg new file mode 100644 index 0000000..3b0566c --- /dev/null +++ b/docs/figures/unweighted-sat/pipeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/figures/unweighted-sat/verification.svg b/docs/figures/unweighted-sat/verification.svg new file mode 100644 index 0000000..d272968 --- /dev/null +++ b/docs/figures/unweighted-sat/verification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 6780756..8ae6afa 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -28,6 +28,19 @@ candidate is independently checked by both `is_gadget_replacement` and `max_frame_evaluations` separately bounds pin-and-ray geometry checks. Increase it together with `window_side` for exhaustive searches in larger windows. +### Checkpoint and resume + +Pass `checkpoint_path` to persist deterministic frame, label-order, and offset +cursors after every `checkpoint_interval` SAT evaluations. Repeating the same +search call with the same path resumes from those cursors and restores its +evaluation and staged-filter counters. The target, lattice, vertex range, and +window size must match the saved search. + +`read_unweighted_search_checkpoint(path)` returns the saved `lattice`, +`atom_count`, `window_shape`, `frame_cursor`, `order_cursor`, `offset_cursor`, +`evaluated`, `frame_evaluated`, `frame_candidates`, `first_lower_rejected`, +`second_lower_rejected`, and `full_solves` fields for progress reporting. + ```julia using GadgetSearch, Graphs @@ -52,3 +65,49 @@ and `window_side=4` for a four-pin target. The result contains the target, ordered boundary, lattice name, accepted gadgets, number of SAT solver calls, and one of four termination reasons: `:solution`, `:budget`, `:frame_budget`, or `:search_space_exhausted`. + +## Joint occupancy-and-frame search + +`search_unweighted_gadget_joint` is the direct formulation used for blank-window +discovery. One CNF chooses the occupied sites, pins, and rays together. The +caller supplies one exact window shape, atom count, and tensor offset; the +function returns either a verifier-accepted `UnweightedGadget` or `nothing`. +`canonical_shift` and `first_ray` identify the symmetry-fixed first port for +this exact CNF instance. A large search should schedule those independent +instances externally with separate solver budgets. + +```julia +gadget = search_unweighted_gadget_joint( + target, [1, 2, 3, 4], Triangular(); + window_shape=(8, 8), atom_count=28, offset=10, + seconds=600, seed=1, canonical_shift=(0, 0), first_ray=1, +) +``` + +## Rewrite optimization + +`optimize_unweighted_gadget` is a local downstream atom-count optimizer. It uses +semantic rewrite rules rather than arbitrary vertex deletion: + +- contract a two-edge boundary tail while promoting its endpoint to the pin; +- contract opposite leaf pins (`P1`–`P3` or `P2`–`P4`) as one paired rewrite; +- move one frame pin by one lattice step and let fixed-frame SAT re-synthesize + every interior atom at a smaller atom count. + +Every accepted step passes the unchanged reduced-alpha verifier and all four +crossing-frame checks. Direct rules are explored to a closure rather than +greedily committing to the first smaller graph. Fixed-frame SAT is then tried +from the direct descendants, so a smaller direct dead end does not hide a +rewrite-and-resynthesize path. The result includes the best gadget reached in +the selected one-step frame neighborhood, a replayable before/after rewrite +trace, the number of fixed-frame SAT calls, and its termination reason. A fixed +point under these rules and budgets is not a global minimum certificate. + +```julia +optimized = optimize_unweighted_gadget( + gadget, [1, 2, 3, 4]; + min_vertices=17, + max_sat_evaluations=256, + host_radius=1, +) +``` diff --git a/docs/unweighted-sat-search-report.pdf b/docs/unweighted-sat-search-report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..149330c6d888e2e92ae7cec6b8edf0fbb060e8eb GIT binary patch literal 230717 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á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Űehr~WYUd* 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 Q0V0qnG@i|ycWbHs4+8`CM*si- literal 0 HcmV?d00001 diff --git a/docs/unweighted-sat-search-report.typ b/docs/unweighted-sat-search-report.typ new file mode 100644 index 0000000..97d41d8 --- /dev/null +++ b/docs/unweighted-sat-search-report.typ @@ -0,0 +1,452 @@ +#set document( + title: "Certified Search and Rule-Based Optimization for Unweighted MIS Gadgets", + author: "GadgetSearch", +) +#set page( + paper: "a4", + margin: (left: 21mm, right: 21mm, top: 15mm, bottom: 17mm), + numbering: "1", + number-align: center + bottom, +) +#set text(font: "Libertinus Serif", size: 9.25pt) +#set math.equation(numbering: "(1)") +#set par(justify: true, leading: 0.58em) +#set heading(numbering: "1.1") +#set table(stroke: 0.45pt + rgb("#aeb7bf"), inset: (x: 5pt, y: 3.5pt)) +#show heading.where(level: 1): it => block( + above: 12pt, below: 5pt, + text(font: "Libertinus Sans", size: 14pt, weight: "semibold", it), +) +#show heading.where(level: 2): it => block( + above: 9pt, below: 3pt, + text(font: "Libertinus Sans", size: 11pt, weight: "semibold", it), +) +#show figure.caption: set text(size: 8.2pt) +#show raw: set text(font: "Libertinus Mono", size: 8pt) + +#let gray = rgb("#66727d") +#let fig(name, caption, width: 100%) = figure( + image("figures/unweighted-sat/" + name + ".svg", width: width), + caption: caption, +) + +#align(center)[ + #text(font: "Libertinus Serif Display", size: 19pt, weight: "semibold")[ + Certified Search and Rule-Based Optimization for Unweighted MIS Gadgets + ] + #v(2pt) + #text(size: 9pt)[GadgetSearch.jl] +] + +#v(4pt) +#block(inset: (x: 11mm, y: 7pt), stroke: ( + top: .6pt + gray, bottom: .6pt + gray, +))[ + #text(size: 9pt)[#text(font: "Libertinus Sans", weight: "semibold")[Abstract.] + We describe a certified synthesis framework for four-pin unweighted + maximum-independent-set gadgets on the triangular and king's graph + lattices. A joint SAT formulation discovers a first gadget by choosing its + occupied sites and four ports in one finite window. A complementary + frame-first formulation enumerates port geometry and solves a smaller + occupancy problem for each frame. Explicit independent-set witnesses impose + lower bounds on the reduced alpha tensor, frontier binary decision diagrams + impose upper bounds, and direct layered reachability imposes connectivity. + The second stage treats successful reductions as evidence from which + to extract reusable, interface-preserving rewrite schemas. Their common + invariant is equality, up to one constant, of the conditioned MIS signature + seen across a small separator. Exact local replacements and + interface-constrained SAT re-synthesis are instances of this rule. Every + application is certified by the unchanged verifier. A reduction from a + blank-window 28-site CROSS to 23 sites serves as a regression case, not as + the definition of the optimization algorithm.] +] + += Problem formulation + +Let $R$ be a target graph with four ordered boundary vertices +$partial R=(b_1,b_2,b_3,b_4)$. For a boundary state +$sigma=(sigma_1,...,sigma_4) in {0,1}^4$, the value $sigma_i=1$ requires +$b_i$ to belong to the independent set. Define +$ + alpha(R)_sigma = max{abs(I): I " independent in " R, + I ∩ partial R = {b_i:sigma_i=1}}. +$ +An infeasible boundary state has value $-infinity$. The *reduced alpha tensor* +$tilde(alpha)(R)$ removes entries dominated by a proper subconfiguration. +Denote the target tensor by $T=tilde(alpha)(R)$. + +Let $Lambda$ be a lattice and let $H$ be the blockade graph of a finite lattice +window. A selected site set $S subset.eq V(H)$ induces the replacement graph +$G=H[S]$. Four ordered selected sites $P=(p_1,p_2,p_3,p_4)$ represent the +boundary vertices, and lattice directions $bold(r)=(r_1,r_2,r_3,r_4)$ describe +the exterior wires. The search seeks $S$, $P$, $bold(r)$, and an integer offset +$c$ satisfying +$ + tilde(alpha)(G) = T+c. +$ + +For each pin, let $s_i=p_i+r_i$ be the first interface site outside the gadget. +The frame must satisfy four geometric conditions: + +- *(G1)* every $s_i$ is a strict vertex of the convex hull of + $S union {s_1,s_2,s_3,s_4}$; +- *(G2)* the cyclic hull order alternates the interface pairs $(s_1,s_3)$ and + $(s_2,s_4)$; +- *(G3)* every $r_i$ points strictly outward from the interface centroid; +- *(G4)* the exterior rays are unobstructed, touch only their own pins, and + remain pairwise outside blockade range. + +The selected graph must contain exactly $N$ sites, contain all four pins, and +be connected. A candidate is accepted only when the unchanged +`is_gadget_replacement` routine independently recomputes the tensor, agrees on +$c$, and the geometry checker confirms G1--G4. + += Two-stage algorithm + +The recommended workflow has two stages: *search* first produces one or more +certified seed gadgets, and *optimization* then attempts to reduce each seed. +The public API exposes the two stages separately so that searches and optimizer +budgets can be scheduled independently; a complete synthesis run calls them in +this order. + +#fig("pipeline", [The complete algorithm is Stage I search followed by Stage II +optimization. Joint and frame-first SAT are alternative engines inside the +search stage, not alternatives to optimization.]) + +Stage I has two search engines that share the tensor and connectivity encodings: + +- *Joint occupancy-and-frame SAT* chooses $S$, $P$, and $bold(r)$ in one CNF. + It is the primary blank-window discovery method. +- *Frame-first SAT* enumerates $(P,bold(r))$ geometrically and lets SAT choose + only $S$. It is suited to systematic finite-window traversal and local + re-synthesis around a known gadget. + +Stage II consumes every verifier-valid `UnweightedGadget` returned by Stage I. +It never assumes that the starting gadget came from a particular solver run. +If no smaller certified descendant exists within the rule and SAT budgets, the +seed itself is the final result of the two-stage algorithm. + += Joint occupancy-and-frame SAT + +Fix a lattice window $W$, atom count $N$, and offset $c$. For every site +$v in W$, introduce a selection variable $x_v$. For every geometrically +eligible site-direction pair and boundary label $i$, introduce a frame variable +$f_(i,v,d)$. The joint CNF imposes: + +1. $sum_v x_v=N$; +2. exactly one frame choice for each label $i$; +3. each chosen pin is selected and the four pins are distinct; +4. local corridor, outward-direction, and alternating-interface constraints; +5. the reduced-alpha tensor constraints described below; +6. connectivity of the selected induced graph. + +Only eligible port choices receive variables. This sparse allocation is much +smaller than allocating every $(i,v,d)$ combination and forbidding most of them. + +One reference port is fixed by a canonical translation and ray direction. This +breaks translation and rotation symmetry, but it also defines the represented +finite instance. A known abstract gadget is absent if no symmetry-equivalent +embedding fits the anchored window. Canonical placement is therefore part of +the instance specification, not an innocuous implementation detail. + +The formula is emitted as DIMACS and solved by Kissat. A model is materialized +and checked geometrically before the reduced tensor is recomputed. A rejected +model contributes a blocker and the solver is restarted; no rejected candidate +is reported as a gadget. + +== Solver portfolios + +Blank-window joint instances can be highly seed-sensitive even when they +represent the same finite search space. Independent Kissat seeds are therefore +a useful source of parallelism. A timed-out run is recorded as `UNKNOWN` and +may be rescheduled; it is never interpreted as an UNSAT certificate. + += Frame-first search + +Frame-first search moves geometry outside the occupancy CNF. It constructs a +compatibility graph whose vertices are locally valid $(p,r)$ candidates. Four +mutually compatible candidates form a possible four-port frame. Valid label +orders are then checked against the complete G1--G4 conditions and canonicalized +under the lattice symmetries. + +#fig("frame-enumeration", [Frame-first construction. Compatible port candidates +form a four-clique; a valid ordered frame determines the allowed host before +occupancy variables are introduced.]) + +For a surviving frame, each lattice site is tested against its pin corridors +and hull constraints. The allowed set $U(P,bold(r))$ defines the host +$H=G[U]$. The fixed-frame CNF contains site-selection, tensor, and connectivity +variables, but no frame-choice variables. + +For the known 23-site triangular CROSS frame, Kissat selects the occupied sites +and returns a verifier-valid gadget at offset 7. The hard part of a blank +frame-first search is reaching this rare frame, not solving its occupancy +instance. + +The production frame traversal has deterministic cursors and periodically +writes checkpoints containing the current frame, label order, offset, and +filter counters. Repeating the same bounded search with that checkpoint resumes +the traversal. This makes long finite traversals resumable, although their +total geometry space can still be very large. + += SAT encoding for a fixed host + +Fix $(H,P,N,c)$. The formula is satisfiable exactly when $H$ contains a +connected $N$-site induced subgraph with reduced alpha tensor $T+c$. + +== Atom selection + +For every host vertex $v$, introduce $x_v$, with +$ + x_(p_i)=1 quad (i=1,...,4), + quad sum_(v in V(H)) x_v=N. +$ + +== Direct layered connectivity + +Let $z_v^t$ mean that selected vertex $v$ is reachable from $p_1$ by a path of +at most $t$ host edges. The initial layer contains only the root, +$ + z_v^0 arrow.l.r.double v=p_1. +$ +For $t=0,...,N-2$, direct propagation is +$ + z_v^(t+1) arrow.l.r.double + (z_v^t or (x_v and or.big_(u in N_H(v)) z_u^t)). +$ +Finally, +$ + x_v => z_v^(N-1) quad (v in V(H)). +$ +No per-edge arrival variables are used. For a host with $h=abs(V(H))$ vertices +and $m=abs(E(H))$ undirected edges, this encoding contributes $N h$ auxiliary +variables and +$ + 2h+(N-1)(3h+2m) +$ +clauses. The dependence on host edges is linear, matching the sparse TLSG and +KSG hosts. + +#fig("connectivity-layers", [Direct layered reachability for a selected path +$p_1-u-v$ and an isolated host vertex $w$. Reachability persists and propagates +through selected vertices; $w$ cannot satisfy the final implication.], width: 78%) + +The depth $N-1$ is exact: every vertex in a connected $N$-vertex graph has a +simple path of length at most $N-1$ from $p_1$, whereas a selected vertex in +another component can never become reachable. + +== From the reduced tensor to bounds + +For a selected set $S$ and boundary state $sigma$, let $A_S(sigma)$ be the +largest independent-set size with exactly the pins indicated by $sigma$. +Define the monotone completion +$ + C_T(sigma)=max{T_tau: tau subset.eq sigma, T_tau != -infinity}. +$ +The equality $tilde(alpha)(G[S])=T+c$ is imposed by +$ + A_S(sigma) >= T_sigma+c quad "for finite " T_sigma, +$ +and +$ + A_S(sigma) <= C_T(sigma)+c quad "for every " sigma. +$ +Only lower and upper states not implied by other boundary states are emitted. + +== Lower bounds: explicit witnesses + +For each essential finite $T_sigma$, introduce witness variables +$y_v^sigma$. The clauses impose +$ + y_v^sigma => x_v, + quad y_u^sigma+y_v^sigma <= 1 quad ({u,v} in E(H)), +$ +$ + y_(p_i)^sigma=sigma_i, + quad sum_(v in V(H)) y_v^sigma=T_sigma+c. +$ +Thus the selected graph explicitly contains an independent set of the required +size and boundary state. + +== Upper bounds: frontier BDDs + +Upper bounds must exclude every oversized independent set. The implementation +constructs a frontier binary decision diagram under a fixed vertex order. A +path either skips or takes the current host vertex. A take edge is enabled only +when the site is selected and no taken frontier vertex conflicts with it. + +After each prefix, BDD states with the same active frontier are merged. The +count is capped at $C_T(sigma)+c+1$, and every terminal state at the cap is +forbidden. The implementation tries three lattice projections and their +reversals and retains the smallest of the six BDDs. + +#fig("bdd-example", [Frontier BDD for the path $a-b-c$ with upper bound one. +Skip and take branches represent every independent set; the two-vertex terminal +is forbidden.], width: 72%) + +For fixed $(H,P,N,c)$, the complete occupancy formula is +$ + Phi = Phi_"select" and Phi_"connect" + and ∧_(sigma in L) Phi_"witness"^sigma + and ∧_(sigma in U) Phi_"BDD"^sigma, +$ +where $L$ and $U$ are the essential lower and upper boundary states. + += Independent certification + +A satisfying assignment is not returned directly. The selected coordinates +are materialized as the exact blockade graph, after which the production +verifier recomputes $tilde(alpha)(G)$ and checks the common offset. Connectivity +and G1--G4 are checked independently. + +#fig("verification", [The SAT solver proposes; the unchanged verifier accepts. +The same certification boundary is used during discovery and after every +rewrite.]) + +This separation protects the result from an incomplete SAT-side optimization: +auxiliary encodings may change, but the semantic acceptance test does not. + += Reusable rewrite principle and implemented optimizer + +A successful reduction should not be stored merely as a sequence of edits. +The useful object is the *semantic reason* why the changed region can be +replaced in any compatible context. For MIS gadgets, that reason is a boundary +signature across a small separator. + +== Interface signatures + +Let $Q$ be a connected subgraph of a certified gadget and let +$C=(c_1,...,c_k)$ contain every vertex through which $Q$ meets the unchanged +exterior. For an interface state $tau in {0,1}^k$, define +$ + A_Q^C(tau)=max{abs(I): I " independent in " Q, + I ∩ C={c_i:tau_i=1}}. +$ +The vector $"Sig"_C(Q)=(A_Q^C(tau))_tau$ is everything the exterior needs to +know about $Q$ when maximizing an independent set. A local replacement +$Q arrow.r Q'$ is context-independent when +$ + A_(Q')^C(tau)=A_Q^C(tau)+delta + quad "for every feasible " tau, +$ +and the infeasible-state pattern is unchanged. Gluing either patch to the same +exterior then changes every global conditioned optimum by the same $delta$. +Consequently, the reduced alpha tensor of the whole gadget changes only by the +allowed constant offset. + +The signature condition is necessary but not sufficient for a lattice rewrite. +The replacement must also preserve the attachment coordinates or port roles, +introduce no unintended blockade edge to the exterior, maintain connectivity, +and satisfy G1--G4. The unchanged whole-gadget verifier remains the final +certificate. + +== Generalizing one successful case + +A successful before/after pair suggests a reusable rule only when its changed +region can be isolated behind a small separator and the two local interface +signatures differ by one constant. Absolute coordinates must then be replaced +by structural premises such as interface order, adjacency, pin role, channel +pairing, and lattice direction. This is the criterion for adding future rules: +the proof obligation is the interface contract, not reproduction of one edit +sequence. The current code does not automatically mine or minimize schemas. + +== Operators implemented today + +The current optimizer implements two operator families consistent with the +signature principle. + +- *Exact local replacement.* Two closed-form rules contract an even boundary + tail or a valid opposite pair of leaf pins. Each proposed whole gadget is + certified; the implementation does not yet enumerate arbitrary equivalent + local patches from their signatures. +- *Interface-constrained re-synthesis.* Hold the separator, external port roles, + and required signature fixed, then ask SAT to synthesize a smaller interior. + Moving a pin by one lattice step is one proposal mechanism for changing the + interface geometry, not the semantic rewrite rule. The rule is to preserve + the interface contract while re-solving the interior. + +The optimizer searches the graph generated by these operators. It explores +all certified direct descendants to a closure and only then spends SAT budget +on re-synthesis. Exact coordinate, pin, and ray state keys merge identical +converging paths. This non-greedy search is essential: the smallest immediate +child may be a dead end, while a different equivalent child exposes a stronger +subsequent rewrite. + +== What the CROSS case teaches + +The reduction from the independently discovered 28-site CROSS to a 23-site +gadget exercises both implemented operator families: certified structural +contractions and one interface-constrained interior re-synthesis. Its general +lesson is to preserve an interface contract, explore alternative certified +descendants, and invoke exact re-synthesis when a structural step changes the +available interior. The particular atom counts and rule names are regression +evidence, not assumptions of a new search. + +The same second stage is applied to CROSS+EDGE. Stage I finds a verified +9-site gadget; Stage II exhausts the direct contraction closure and a budget of +256 fixed-frame SAT evaluations without finding a smaller certified descendant. +The reported 9-site result is therefore the best result under the current rules +and budget, not a search-only result and not a proof of global minimality. + +The final result is certified relative to the available rule schemas, host +radius, minimum atom count, and SAT budget. It is not a proof that no smaller +gadget exists. + += Results and regression coverage + +#figure( + table( + columns: (1.35fr, 1.35fr, 1.35fr, 1.35fr, 2.3fr), + align: (left, center, center, center, left), + fill: (x, y) => if y == 0 { rgb("#edf4fa") } else { white }, + table.header( + [*target*], [*Stage I search*], [*Stage II optimize*], + [*offset*], [*final status*], + ), + [CROSS], [28 sites], [23 sites], [$10 arrow.r 7$], + [reduced; verified], + [CROSS+EDGE], [9 sites], [9 sites], [$1 arrow.r 1$], + [unchanged after budget; verified], + ), + caption: [End-to-end results of the complete two-stage algorithm.], +) + +Correctness is exercised at several levels: + +- direct layered connectivity is compared with explicit connected-subset + enumeration on small host graphs; +- fixed-frame and joint positive controls pass the unchanged verifier; +- Stage I search covers both CROSS and CROSS+EDGE, and Stage II optimization is + run on both results; +- the historical 28-site CROSS satisfies the production CNF and verifier; +- a regression starts from that real 28-site result and checks every certified + rewrite application down to 23 sites; +- the focused unweighted-search suite and the complete package test suite pass. + += Completeness and practical limits + +The procedure is complete only within its explicitly scheduled finite space. + +- A joint instance fixes one window, $N$, $c$, canonical anchor, and first ray. +- A frame-first run fixes a finite window, atom-count range, frame budget, and + SAT-call budget. +- Kissat `UNKNOWN` records expired work, not a negative proof. +- The rewrite optimizer explores a bounded signature-preserving rule + neighborhood and does not + certify global minimality. + +Frame-first enumeration can encounter millions of easy UNSAT frames before a +useful frame, with a small hard tail dominating wall time. Joint SAT avoids that +explicit traversal but exposes one larger, seed-sensitive CNF. The two modes are +therefore complementary rather than competing implementations. + += Recommended workflow + +For a new four-pin unweighted target: + +1. *Stage I -- search:* compute the target tensor, choose TLSG or KSG, and run + joint or frame-first SAT until at least one seed gadget is independently + verified; +2. *Stage II -- optimize:* run exact local replacements and + interface-constrained re-synthesis on every distinct seed, retaining the + smallest verified descendant or the unchanged seed at a fixed point; diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 7625e5d..193a557 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -79,7 +79,12 @@ export is_gadget_replacement # Unweighted search export UnweightedGadget export UnweightedSearchResult +export UnweightedRewriteStep +export UnweightedOptimizationResult export search_unweighted_gadgets +export search_unweighted_gadget_joint +export optimize_unweighted_gadget +export read_unweighted_search_checkpoint export check_crossing_frame end # module diff --git a/src/core/unweighted_sat.jl b/src/core/unweighted_sat.jl index 95c41f2..e7168cb 100644 --- a/src/core/unweighted_sat.jl +++ b/src/core/unweighted_sat.jl @@ -13,6 +13,62 @@ struct _SatFrameContext adjacent::Vector{Vector{Int}} end +mutable struct _SatSearchStats + frame_candidates::Int + first_lower_rejected::Int + second_lower_rejected::Int + full_solves::Int +end + +struct _SatSearchCheckpoint + target::Vector{Float64} + lattice::Symbol + min_vertices::Int + max_vertices::Int + window_side::Int + atom_count::Int + window_shape::Tuple{Int, Int} + frame_cursor::Int + order_cursor::Int + offset_cursor::Int + evaluated::Int + frame_evaluated::Int + stats::_SatSearchStats +end + +_SatSearchStats() = _SatSearchStats(0, 0, 0, 0) + +function _write_sat_search_checkpoint(path, checkpoint) + open(path, "w") do stream + serialize(stream, checkpoint) + end +end + +function _read_sat_search_checkpoint(path) + open(path) do stream + return deserialize(stream) + end +end + +"""Read progress and staged-filter counters from a saved unweighted search.""" +function read_unweighted_search_checkpoint(path::String) + checkpoint = _read_sat_search_checkpoint(path) + return ( + lattice=checkpoint.lattice, + atom_count=checkpoint.atom_count, + window_shape=checkpoint.window_shape, + frame_cursor=checkpoint.frame_cursor, + order_cursor=checkpoint.order_cursor, + offset_cursor=checkpoint.offset_cursor, + evaluated=checkpoint.evaluated, + frame_evaluated=checkpoint.frame_evaluated, + frame_candidates=checkpoint.stats.frame_candidates, + first_lower_rejected=checkpoint.stats.first_lower_rejected, + second_lower_rejected=checkpoint.stats.second_lower_rejected, + full_solves=checkpoint.stats.full_solves, + ) +end + _SatCnf() = _SatCnf(0, Vector{Int}[]) function _sat_variable!(cnf::_SatCnf) @@ -60,38 +116,63 @@ function _sat_exactly!(cnf::_SatCnf, literals::Vector{Int}, count::Int) _sat_at_most!(cnf, -literals, length(literals) - count) end +function _sat_exactly_one!(cnf::_SatCnf, literals::Vector{Int}) + _sat_clause!(cnf, literals...) + _sat_at_most!(cnf, literals, 1) +end + function _independent_set_bdd(coordinates, edge_list) adjacent = [Set{Int}() for _ in coordinates] for (first, second) in edge_list push!(adjacent[first], second) push!(adjacent[second], first) end - order = sortperm(coordinates) - position = zeros(Int, length(coordinates)) - for (step, vertex) in enumerate(order) - position[vertex] = step - end - last_neighbor = [maximum([position[vertex]; position[collect(adjacent[vertex])]]) - for vertex in eachindex(coordinates)] - layer = [Int[]] - layers = Vector{Vector{Tuple{Int, Int, Bool}}}() - for (step, vertex) in enumerate(order) - next_layer = Vector{Vector{Int}}() - next_index = Dict{Tuple{Vararg{Int}}, Int}() - arcs = Tuple{Int, Int, Bool}[] - for (source, occupied) in enumerate(layer), take in (false, true) - take && any(neighbor -> neighbor in occupied, adjacent[vertex]) && continue - next_occupied = take ? [occupied; vertex] : occupied - frontier = Tuple(sort!(filter(v -> last_neighbor[v] > step, next_occupied))) - destination = get!(next_index, frontier) do - push!(next_layer, collect(frontier)) - length(next_layer) + canonical = _offset_to_axial.(coordinates) + orderings = Vector{Vector{Int}}() + for projection in ( + point -> (point[1], point[2]), + point -> (point[2], point[1]), + point -> (point[1] + point[2], point[1]), + ) + order = sortperm(canonical; by=projection) + push!(orderings, order, reverse(order)) + end + function build_layers(order) + position = zeros(Int, length(coordinates)) + for (step, vertex) in enumerate(order) + position[vertex] = step + end + last_neighbor = [maximum([position[vertex]; position[collect(adjacent[vertex])]]) + for vertex in eachindex(coordinates)] + layer = [Int[]] + layers = Vector{Vector{Tuple{Int, Int, Bool}}}() + layer_sizes = [1] + for (step, vertex) in enumerate(order) + next_layer = Vector{Vector{Int}}() + next_index = Dict{Tuple{Vararg{Int}}, Int}() + arcs = Tuple{Int, Int, Bool}[] + for (source, occupied) in enumerate(layer), take in (false, true) + take && any(neighbor -> neighbor in occupied, adjacent[vertex]) && continue + next_occupied = take ? [occupied; vertex] : occupied + frontier = Tuple(sort!(filter(v -> last_neighbor[v] > step, next_occupied))) + destination = get!(next_index, frontier) do + push!(next_layer, collect(frontier)) + length(next_layer) + end + push!(arcs, (source, destination, take)) end - push!(arcs, (source, destination, take)) + push!(layers, arcs) + push!(layer_sizes, length(next_layer)) + layer = next_layer end - push!(layers, arcs) - layer = next_layer + return sum(layer_sizes), order, layers end + best = build_layers(first(orderings)) + for order in Iterators.drop(orderings, 1) + candidate = build_layers(order) + candidate[1] < best[1] && (best = candidate) + end + _, order, layers = best return order, layers end @@ -125,6 +206,10 @@ function _essential_upper_states(completion) return states end +function _centered_offsets(offsets, center=(first(offsets) + last(offsets)) / 2) + return sort!(collect(offsets); by=offset -> (abs(offset - center), offset)) +end + function _add_independent_set_upper_bound!( cnf, selected, pins, order, layers, boundary_state, bound, ) @@ -132,7 +217,7 @@ function _add_independent_set_upper_bound!( _sat_clause!(cnf, root) reachable = Dict((1, 0) => root) pin_slots = Dict(pin => slot - 1 for (slot, pin) in enumerate(pins)) - for (vertex, arcs) in zip(order, layers) + for (step, (vertex, arcs)) in enumerate(zip(order, layers)) next_reachable = Dict{Tuple{Int, Int}, Int}() required = haskey(pin_slots, vertex) ? !iszero(boundary_state & (1 << pin_slots[vertex])) : nothing @@ -156,7 +241,9 @@ function _add_independent_set_upper_bound!( end end -function _add_selected_connectivity!(cnf, selected, adjacent, root, selected_count) +function _add_selected_connectivity!( + cnf, selected, adjacent, root, selected_count, +) reachable = [_sat_variable!(cnf) for _ in selected] for vertex in eachindex(reachable) _sat_clause!(cnf, vertex == root ? reachable[vertex] : -reachable[vertex]) @@ -164,17 +251,18 @@ function _add_selected_connectivity!(cnf, selected, adjacent, root, selected_cou for _ in 1:selected_count-1 next_reachable = [_sat_variable!(cnf) for _ in selected] for vertex in eachindex(selected) - arrivals = Int[] + _sat_clause!(cnf, -reachable[vertex], next_reachable[vertex]) + _sat_clause!(cnf, -next_reachable[vertex], selected[vertex]) + _sat_clause!( + cnf, -next_reachable[vertex], reachable[vertex], + (reachable[neighbor] for neighbor in adjacent[vertex])..., + ) for neighbor in adjacent[vertex] - arrival = _sat_variable!(cnf) - push!(arrivals, arrival) - _sat_clause!(cnf, -arrival, selected[vertex]) - _sat_clause!(cnf, -arrival, reachable[neighbor]) - _sat_clause!(cnf, -selected[vertex], -reachable[neighbor], arrival) - _sat_clause!(cnf, -arrival, next_reachable[vertex]) + _sat_clause!( + cnf, -selected[vertex], -reachable[neighbor], + next_reachable[vertex], + ) end - _sat_clause!(cnf, -reachable[vertex], next_reachable[vertex]) - _sat_clause!(cnf, -next_reachable[vertex], reachable[vertex], arrivals...) end reachable = next_reachable end @@ -213,43 +301,435 @@ function _solve_fixed_crossing_sat( end function _fixed_crossing_sat_problem(target, context, atom_count, offset) + cnf, selected = _fixed_crossing_sat_cnf( + target, context, atom_count, offset, + ) + return _new_sat_solver(cnf), selected +end + +function _fixed_crossing_sat_cnf(target, context, atom_count, offset) coordinates = context.coordinates pins = context.boundary - edge_list = context.edge_list - order = context.order - layers = context.layers cnf = _SatCnf() selected = [_sat_variable!(cnf) for _ in coordinates] foreach(pin -> _sat_clause!(cnf, selected[pin]), pins) _sat_exactly!(cnf, selected, atom_count) for state in _essential_lower_states(target) - witness = [_sat_variable!(cnf) for _ in coordinates] - for vertex in eachindex(coordinates) - _sat_clause!(cnf, -witness[vertex], selected[vertex]) - end - for (slot, pin) in enumerate(pins) - _sat_clause!( - cnf, - iszero(state & (1 << (slot - 1))) ? -witness[pin] : witness[pin], - ) - end - for (first, second) in edge_list - _sat_clause!(cnf, -witness[first], -witness[second]) - end - _sat_exactly!(cnf, witness, Int(target[state+1] + offset)) + _add_lower_state_constraint!( + cnf, selected, target, context, state, offset, + ) end completion = _target_completion(target) for state in _essential_upper_states(completion) _add_independent_set_upper_bound!( - cnf, selected, pins, order, layers, state, + cnf, selected, pins, context.order, context.layers, state, Int(completion[state+1] + offset), ) end _add_selected_connectivity!( cnf, selected, context.adjacent, pins[1], atom_count, ) + return cnf, selected +end + +function _add_lower_state_constraint!( + cnf, selected, target, context, state, offset, +) + witness = [_sat_variable!(cnf) for _ in context.coordinates] + for vertex in eachindex(context.coordinates) + _sat_clause!(cnf, -witness[vertex], selected[vertex]) + end + for (slot, pin) in enumerate(context.boundary) + _sat_clause!( + cnf, iszero(state & (1 << (slot - 1))) ? + -witness[pin] : witness[pin], + ) + end + for (first, second) in context.edge_list + _sat_clause!(cnf, -witness[first], -witness[second]) + end + _sat_exactly!(cnf, witness, Int(target[state+1] + offset)) +end + +function _add_joint_lower_state_constraint!( + cnf, selected, target, edge_list, frame_choices, state, offset, +) + witness = [_sat_variable!(cnf) for _ in selected] + for vertex in eachindex(selected) + _sat_clause!(cnf, -witness[vertex], selected[vertex]) + end + for label in eachindex(frame_choices), + (vertex, _, port) in frame_choices[label] + occupied = !iszero(state & (1 << (label - 1))) + _sat_clause!(cnf, -port, + occupied ? witness[vertex] : -witness[vertex]) + end + for (first, second) in edge_list + _sat_clause!(cnf, -witness[first], -witness[second]) + end + _sat_exactly!(cnf, witness, Int(target[state+1] + offset)) +end + +function _add_joint_independent_set_upper_bound!( + cnf, selected, choices_by_slot_vertex, order, layers, boundary_state, bound, +) + maximum_remaining = [Dict{Int, Int}() for _ in 1:length(order)+1] + maximum_remaining[end][1] = 0 + for step in length(order):-1:1 + for (source, destination, take) in layers[step] + haskey(maximum_remaining[step+1], destination) || continue + value = Int(take) + maximum_remaining[step+1][destination] + maximum_remaining[step][source] = max( + get(maximum_remaining[step], source, -1), value, + ) + end + end + root = _sat_variable!(cnf) + _sat_clause!(cnf, root) + reachable = [(1, 0, root)] + for (step, (vertex, arcs)) in enumerate(zip(order, layers)) + required_taken = Int[] + required_skipped = Int[] + for label in eachindex(choices_by_slot_vertex) + destination = iszero(boundary_state & (1 << (label - 1))) ? + required_skipped : required_taken + append!(destination, choices_by_slot_vertex[label][vertex]) + end + next_reachable = Tuple{Int, Int, Int}[] + next_index = Dict{Tuple{Int, Int}, Int}() + for (source, destination, take) in arcs + for (source_state, count, source_variable) in reachable + source_state == source || continue + next_count = min(bound + 1, count + Int(take)) + next_count + get(maximum_remaining[step+1], destination, -1) < + bound + 1 && continue + key = (destination, next_count) + destination_variable = get(next_index, key, 0) + if iszero(destination_variable) + destination_variable = _sat_variable!(cnf) + next_index[key] = destination_variable + push!(next_reachable, (destination, next_count, destination_variable)) + end + if take + _sat_clause!( + cnf, -source_variable, -selected[vertex], + required_skipped..., destination_variable, + ) + else + _sat_clause!( + cnf, -source_variable, required_taken..., + destination_variable, + ) + end + end + end + reachable = next_reachable + end + for (_, count, variable) in reachable + count == bound + 1 && _sat_clause!(cnf, -variable) + end +end + +function _joint_frame_choice_is_outward(lattice, interface, direction, interfaces) + pin = (interface[1] - direction[1], interface[2] - direction[2]) + geometry = _geometry_coordinate.(Ref(lattice), _from_canonical.(Ref(lattice), interfaces)) + pin_geometry = _geometry_coordinate(lattice, _from_canonical(lattice, pin)) + hull = _strict_convex_hull([geometry; pin_geometry]) + all(in(hull), geometry) || return false + sum_q = sum(first, interfaces) + sum_r = sum(last, interfaces) + out_q = 4interface[1] - sum_q + out_r = 4interface[2] - sum_r + if lattice isa Square + return out_q * direction[1] + out_r * direction[2] > 0 + end + out_x = 2out_q + out_r + direction_x = 2direction[1] + direction[2] + return out_x * direction_x + 3out_r * direction[2] > 0 +end + +function _sat_group_indicator!(cnf, literals) + indicator = _sat_variable!(cnf) + for literal in literals + _sat_clause!(cnf, -literal, indicator) + end + _sat_clause!(cnf, -indicator, literals...) + return indicator +end + +function _interfaces_form_alternating_quadrilateral(interfaces) + length(unique(interfaces)) == 4 || return false + return ( + _orientation(interfaces[1], interfaces[3], interfaces[2]) * + _orientation(interfaces[1], interfaces[3], interfaces[4]) < 0 && + _orientation(interfaces[2], interfaces[4], interfaces[1]) * + _orientation(interfaces[2], interfaces[4], interfaces[3]) < 0 + ) +end + +function _joint_rays_touch(start1, direction1, start2, direction2, directions) + return any([_LatticeCoordinate[(0, 0)]; directions]) do offset + _rays_touch(start1, direction1, start2, direction2, offset) + end +end + +function _joint_site_blocks_ray(site, pin, direction, directions) + site == pin && return false + start = (pin[1] + direction[1], pin[2] + direction[2]) + return any([_LatticeCoordinate[(0, 0)]; directions]) do offset + _point_on_ray( + (site[1] + offset[1], site[2] + offset[2]), start, direction, + ) + end +end + +function _add_joint_frame_choices!( + cnf, selected, coordinates, lattice, first_direction_index, +) + directions = _lattice_directions(lattice) + canonical = _canonical_coordinate.(Ref(lattice), coordinates) + origin = only(findall(==((0, 0)), canonical)) + frame_choices = [Tuple{Int, Int, Int}[] for _ in 1:4] + push!(frame_choices[1], ( + origin, first_direction_index, _sat_variable!(cnf), + )) + fixed_start = directions[first_direction_index] + for label in 2:4, vertex in eachindex(coordinates), + direction_index in eachindex(directions) + direction = directions[direction_index] + pin = canonical[vertex] + start = (pin[1] + direction[1], pin[2] + direction[2]) + (vertex == origin || _joint_rays_touch( + fixed_start, directions[first_direction_index], start, direction, + directions, + )) && continue + push!(frame_choices[label], + (vertex, direction_index, _sat_variable!(cnf))) + end + + choices_by_slot_vertex = [ + [Int[] for _ in coordinates] for _ in 1:4 + ] + for label in eachindex(frame_choices) + _sat_exactly_one!(cnf, last.(frame_choices[label])) + for (vertex, _, variable) in frame_choices[label] + push!(choices_by_slot_vertex[label][vertex], variable) + _sat_clause!(cnf, -variable, selected[vertex]) + end + end + for first_label in 1:3, second_label in first_label+1:4, + vertex in eachindex(coordinates), + first in choices_by_slot_vertex[first_label][vertex], + second in choices_by_slot_vertex[second_label][vertex] + _sat_clause!(cnf, -first, -second) + end + + interface_variables = [Dict{_LatticeCoordinate, Int}() for _ in 1:4] + interface_order = [_LatticeCoordinate[] for _ in 1:4] + records = [ + Dict{_LatticeCoordinate, Vector{Tuple{Int, Int}}}() for _ in 1:4 + ] + for label in eachindex(frame_choices) + for (vertex, direction_index, choice) in frame_choices[label] + pin = canonical[vertex] + direction = directions[direction_index] + interface = (pin[1] + direction[1], pin[2] + direction[2]) + if !haskey(records[label], interface) + records[label][interface] = Tuple{Int, Int}[] + push!(interface_order[label], interface) + end + push!(records[label][interface], (direction_index, choice)) + end + for interface in interface_order[label] + choices = last.(records[label][interface]) + variable = _sat_group_indicator!(cnf, choices) + interface_variables[label][interface] = variable + end + end + + first_interface = only(interface_order[1]) + adjacent_pairs = Tuple{_LatticeCoordinate, _LatticeCoordinate, Int}[] + for adjacent_first in interface_order[2], adjacent_second in interface_order[4] + adjacent_first < adjacent_second || continue + pair = _sat_variable!(cnf) + first_variable = interface_variables[2][adjacent_first] + second_variable = interface_variables[4][adjacent_second] + _sat_clause!(cnf, -pair, first_variable) + _sat_clause!(cnf, -pair, second_variable) + _sat_clause!(cnf, -first_variable, -second_variable, pair) + push!(adjacent_pairs, (adjacent_first, adjacent_second, pair)) + end + for opposite in interface_order[3] + opposite_variable = interface_variables[3][opposite] + allowed_pairs = Int[] + for (adjacent_first, adjacent_second, pair) in adjacent_pairs + interfaces = [ + first_interface, adjacent_first, opposite, adjacent_second, + ] + _interfaces_form_alternating_quadrilateral(interfaces) || continue + allowed_choices = [ + [choice for (direction_index, choice) in records[label][interface] + if _joint_frame_choice_is_outward( + lattice, interface, directions[direction_index], interfaces, + )] + for (label, interface) in enumerate(interfaces) + ] + any(isempty, allowed_choices) && continue + push!(allowed_pairs, pair) + for choices in allowed_choices[2:4] + _sat_clause!(cnf, -opposite_variable, -pair, choices...) + end + end + _sat_clause!(cnf, -opposite_variable, allowed_pairs...) + end + + for choices in frame_choices, (vertex, direction_index, variable) in choices + pin = canonical[vertex] + direction = directions[direction_index] + for (site_vertex, site) in enumerate(canonical) + _joint_site_blocks_ray(site, pin, direction, directions) && + _sat_clause!(cnf, -variable, -selected[site_vertex]) + end + end + for first_label in 1:3, second_label in first_label+1:4, + (first_vertex, first_direction, first_variable) in frame_choices[first_label], + (second_vertex, second_direction, second_variable) in frame_choices[second_label] + first_pin = canonical[first_vertex] + second_pin = canonical[second_vertex] + first_ray = directions[first_direction] + second_ray = directions[second_direction] + first_start = (first_pin[1] + first_ray[1], first_pin[2] + first_ray[2]) + second_start = (second_pin[1] + second_ray[1], second_pin[2] + second_ray[2]) + _joint_rays_touch( + first_start, first_ray, second_start, second_ray, directions, + ) && _sat_clause!(cnf, -first_variable, -second_variable) + end + return frame_choices, choices_by_slot_vertex +end + +function _joint_crossing_sat_cnf( + target, lattice, shape, atom_count, offset; + canonical_shift=(0, 0), first_direction_index=1, +) + columns, rows = shape + coordinates = lattice isa Triangular ? sort!(_from_canonical.( + Ref(lattice), _LatticeCoordinate[ + (q + canonical_shift[1], r + canonical_shift[2]) + for q in -1:columns-2 for r in 1-rows:0 + ], + )) : _LatticeCoordinate[ + (column + canonical_shift[1], row + canonical_shift[2]) + for column in 0:columns-1 for row in 0:rows-1 + ] + canonical = _canonical_coordinate.(Ref(lattice), coordinates) + coordinate_index = Dict(point => vertex for (vertex, point) in enumerate(canonical)) + directions = _lattice_directions(lattice) + edge_list = Tuple{Int, Int}[] + for (vertex, point) in enumerate(canonical), direction in directions + other = get(coordinate_index, + (point[1] + direction[1], point[2] + direction[2]), 0) + vertex < other && push!(edge_list, (vertex, other)) + end + adjacent = [Int[] for _ in coordinates] + for (first, second) in edge_list + push!(adjacent[first], second) + push!(adjacent[second], first) + end + order, layers = _independent_set_bdd(coordinates, edge_list) + + cnf = _SatCnf() + selected = [_sat_variable!(cnf) for _ in coordinates] + _sat_exactly!(cnf, selected, atom_count) + origin = only(findall(==((0, 0)), canonical)) + _add_selected_connectivity!(cnf, selected, adjacent, origin, atom_count) + frame_choices, choices_by_slot_vertex = _add_joint_frame_choices!( + cnf, selected, coordinates, lattice, first_direction_index, + ) + for state in _essential_lower_states(target) + _add_joint_lower_state_constraint!( + cnf, selected, target, edge_list, frame_choices, state, offset, + ) + end + completion = _target_completion(target) + for state in _essential_upper_states(completion) + _add_joint_independent_set_upper_bound!( + cnf, selected, choices_by_slot_vertex, order, layers, state, + Int(completion[state+1] + offset), + ) + end + return cnf, selected, frame_choices, coordinates +end + +function _solve_joint_crossing_sat( + target, lattice, shape, atom_count, offset; + verbose=false, seconds=600, kissat_executable=nothing, initial_seed=1, + canonical_shift=(0, 0), first_direction_index=1, +) + cnf, selected, frame_choices, coordinates = _joint_crossing_sat_cnf( + target, lattice, shape, atom_count, offset; + canonical_shift, first_direction_index, + ) + solver = _new_sat_solver(cnf) + choice_variables = [choice[3] for choices in frame_choices for choice in choices] + projected = [selected; choice_variables] + model_index = 0 + deadline = time() + seconds + while time() < deadline + model_index += 1 + status, assignment = _next_joint_assignment!( + solver, projected; seed=initial_seed + model_index - 1, + seconds=max(1, floor(Int, deadline - time())), + kissat_executable, + ) + status == :unknown && return nothing + status == :unsat && return nothing + selected_assignment = assignment[1:length(selected)] + choice_assignment = assignment[length(selected)+1:end] + pins = _LatticeCoordinate[] + rays = Int[] + chosen_choices = Tuple{Int, Int, Int}[] + cursor = 0 + for choices in frame_choices + chosen = only(choice for choice in choices if choice_assignment[cursor += 1]) + push!(chosen_choices, chosen) + push!(pins, coordinates[chosen[1]]) + push!(rays, chosen[2]) + end + selected_indices = findall(identity, selected_assignment) + sites = coordinates[selected_indices] + checks = _check_crossing_frame(lattice, _LatticePatch(sites, pins, rays)) + if all(checks) + analysis = _analyze_crossing_candidate( + target, lattice, sites, pins, rays, + ) + analysis.solved && return analysis + error("joint SAT candidate failed a non-geometric encoded constraint") + end + chosen_ports = last.(chosen_choices) + intrinsic = _check_crossing_frame( + lattice, _LatticePatch(pins, pins, rays), + ) + clause = all(intrinsic) ? + [-selected[selected_indices]; -chosen_ports] : + -chosen_ports + _add_solver_clause!(solver, clause) + verbose && println((; model_index, frame_checks=checks, + intrinsic_frame_checks=intrinsic, blocker_length=length(clause))) + verbose && flush(stdout) + end + return nothing +end + +function _lower_state_filter_problem(target, context, atom_count, offset, state) + cnf = _SatCnf() + selected = [_sat_variable!(cnf) for _ in context.coordinates] + foreach(pin -> _sat_clause!(cnf, selected[pin]), context.boundary) + _sat_exactly!(cnf, selected, atom_count) + _add_lower_state_constraint!( + cnf, selected, target, context, state, offset, + ) return _new_sat_solver(cnf), selected end @@ -288,18 +768,55 @@ end CryptoMiniSat.add_clause(solver, blocking_clause) return assignment end + + function _next_joint_assignment!( + solver, variables; seed, seconds, kissat_executable, + ) + isnothing(kissat_executable) || + error("an external Kissat executable is not supported on Windows") + status = CryptoMiniSat.solve(solver) + status === false && return :unsat, nothing + status === true || error("SAT solver returned an undefined result") + model = CryptoMiniSat.get_model(solver) + return :sat, Bool[model[variable] for variable in variables] + end + + _add_solver_clause!(solver, clause) = CryptoMiniSat.add_clause(solver, clause) + + + function _next_selected_assignment_limited!( + solver, selected, conflict_limit; seed=1, + ) + error("conflict-limited frame scheduling requires Kissat") + end else mutable struct _KissatEnumerator + variables::Int clauses::Vector{Vector{Int}} end - _new_sat_solver(cnf::_SatCnf) = _KissatEnumerator(copy(cnf.clauses)) + _new_sat_solver(cnf::_SatCnf) = + _KissatEnumerator(cnf.variables, copy(cnf.clauses)) + _add_solver_clause!(solver::_KissatEnumerator, clause) = + push!(solver.clauses, collect(clause)) _kissat_init() = ccall((:kissat_init, Kissat_jll.libkissat), Ptr{Cvoid}, ()) _kissat_add(solver, literal) = ccall((:kissat_add, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid}, Cint), solver, literal) _kissat_solve(solver) = ccall((:kissat_solve, Kissat_jll.libkissat), Cint, (Ptr{Cvoid},), solver) _kissat_value(solver, variable) = ccall((:kissat_value, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cint), solver, variable) _kissat_release(solver) = ccall((:kissat_release, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid},), solver) _kissat_quiet(solver) = ccall((:kissat_set_option, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cstring, Cint), solver, "quiet", 1) + _kissat_seed(solver, seed) = ccall( + (:kissat_set_option, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cstring, Cint), solver, "seed", seed, + ) + _kissat_walkinitially(solver) = ccall( + (:kissat_set_option, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cstring, Cint), solver, "walkinitially", 1, + ) + _kissat_set_conflict_limit(solver, limit) = ccall( + (:kissat_set_conflict_limit, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cuint), solver, limit, + ) function _next_selected_assignment!(enumerator::_KissatEnumerator, selected) solver = _kissat_init() @@ -320,103 +837,445 @@ else ]) return assignment end + + function _next_joint_assignment!( + enumerator::_KissatEnumerator, variables; + seed, seconds, kissat_executable, + ) + path, stream = mktemp() + try + println(stream, "p cnf $(enumerator.variables) $(length(enumerator.clauses))") + for clause in enumerator.clauses + println(stream, join(clause, ' '), " 0") + end + close(stream) + output = IOBuffer() + executable = isnothing(kissat_executable) ? + Kissat_jll.kissat() : kissat_executable + command = `$executable --sat --walkinitially --seed=$seed -q --time=$seconds $path` + process = run(pipeline(ignorestatus(command), stdout=output, stderr=stderr)) + status = process.exitcode + status == 20 && return :unsat, nothing + status == 0 && return :unknown, nothing + status == 10 || error("Kissat exited with status $status") + positive = Set{Int}() + for line in eachline(seekstart(output)) + startswith(line, "v ") || continue + for literal in split(line)[2:end] + value = parse(Int, literal) + value > 0 && push!(positive, value) + end + end + return :sat, Bool[variable in positive for variable in variables] + finally + isopen(stream) && close(stream) + rm(path) + end + end + + + function _next_selected_assignment_limited!( + enumerator::_KissatEnumerator, selected, conflict_limit; seed=1, + ) + solver = _kissat_init() + _kissat_quiet(solver) + _kissat_seed(solver, seed) + for clause in enumerator.clauses + foreach(literal -> _kissat_add(solver, literal), clause) + _kissat_add(solver, 0) + end + _kissat_set_conflict_limit(solver, conflict_limit) + status = _kissat_solve(solver) + assignment = status == 10 ? + Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing + _kissat_release(solver) + status == 0 && return :unknown, nothing + status == 20 && return :unsat, nothing + status == 10 || error("SAT solver returned an undefined result") + push!(enumerator.clauses, [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ]) + return :sat, assignment + end end function _search_crossing_sat( target_graph, target_boundary, target_reduced, lattice; min_vertices, max_vertices, max_evaluations, max_frame_evaluations, - max_results, window_side, + max_results, window_side, checkpoint_path, checkpoint_interval, ) all(value -> isinf(value) || isinteger(value), target_reduced) || error("the reduced alpha tensor must contain integers or -Inf") completion = _target_completion(target_reduced) + lower_filter_states = _essential_lower_states(target_reduced) + resize!(lower_filter_states, min(2, length(lower_filter_states))) gadgets = UnweightedGadget[] seen_gadgets = Set{Tuple}() - evaluated = 0 - frame_evaluated = Ref(0) + stats = _SatSearchStats() + checkpoint = checkpoint_path === nothing || !isfile(checkpoint_path) ? + nothing : _read_sat_search_checkpoint(checkpoint_path) + if checkpoint !== nothing + checkpoint.target == Float64.(target_reduced) || + error("checkpoint target does not match this search") + checkpoint.lattice == _lattice_symbol(lattice) || + error("checkpoint lattice does not match this search") + checkpoint.min_vertices == min_vertices || + error("checkpoint min_vertices does not match this search") + checkpoint.max_vertices == max_vertices || + error("checkpoint max_vertices does not match this search") + checkpoint.window_side == window_side || + error("checkpoint window_side does not match this search") + stats = checkpoint.stats + end + evaluated = checkpoint === nothing ? 0 : checkpoint.evaluated + frame_evaluated = Ref(checkpoint === nothing ? 0 : checkpoint.frame_evaluated) + next_checkpoint_evaluation = evaluated + checkpoint_interval for atom_count in min_vertices:max_vertices + checkpoint !== nothing && atom_count < checkpoint.atom_count && continue atom_count > window_side^2 && break - stopped = _foreach_crossing_frame( - lattice, window_side, frame_evaluated, max_frame_evaluations, - ) do frame - atom_count <= length(frame.allowed) || return nothing - offsets = (-Int(minimum(completion))):(atom_count - Int(maximum(completion))) - isempty(offsets) && return nothing - evaluated == max_evaluations && return :budget - context = _prepare_sat_frame(lattice, frame) - for offset in offsets - evaluated == max_evaluations && return :budget - solver, selected = _fixed_crossing_sat_problem( - target_reduced, context, atom_count, offset, + seen_frames = Set{Tuple}() + offsets = _centered_offsets( + (-Int(minimum(completion))):(atom_count - Int(maximum(completion))), + ) + window_shapes = _crossing_window_shapes(window_side, atom_count) + for window_shape in window_shapes + if checkpoint !== nothing && atom_count == checkpoint.atom_count + shape_index = findfirst(==(checkpoint.window_shape), window_shapes) + shape_index === nothing && + error("checkpoint window shape is not part of this search") + current_index = findfirst(==(window_shape), window_shapes) + current_index < shape_index && continue + end + start_cursor = checkpoint !== nothing && + atom_count == checkpoint.atom_count && + window_shape == checkpoint.window_shape ? checkpoint.frame_cursor : 0 + start_offset = checkpoint !== nothing && + atom_count == checkpoint.atom_count && + window_shape == checkpoint.window_shape ? checkpoint.offset_cursor : 0 + start_order = checkpoint !== nothing && + atom_count == checkpoint.atom_count && + window_shape == checkpoint.window_shape ? checkpoint.order_cursor : 0 + save_checkpoint = (cursor, order, offset) -> begin + checkpoint_path === nothing && return + evaluated >= next_checkpoint_evaluation || return + _write_sat_search_checkpoint( + checkpoint_path, + _SatSearchCheckpoint( + Float64.(target_reduced), _lattice_symbol(lattice), + min_vertices, max_vertices, window_side, atom_count, + window_shape, cursor, order, offset, evaluated, + frame_evaluated[], stats, + ), ) - while true + next_checkpoint_evaluation = evaluated + checkpoint_interval + end + stopped = _foreach_crossing_frame_clique( + lattice, window_shape, frame_evaluated, max_frame_evaluations; + min_allowed=atom_count, seen=seen_frames, start_cursor, + start_order, + ) do frame, frame_cursor, order_cursor + stats.frame_candidates += 1 + isempty(offsets) && return nothing + evaluated == max_evaluations && return :budget + context = _prepare_sat_frame(lattice, frame) + resume_offset = frame_cursor == start_cursor && + order_cursor == start_order ? start_offset : 0 + for (offset_index, offset) in enumerate(offsets) + offset_index <= resume_offset && continue + save_checkpoint(frame_cursor, order_cursor, offset_index - 1) + rejected = false + for (filter_index, state) in enumerate(lower_filter_states) + evaluated == max_evaluations && return :budget + filter_solver, filter_selected = _lower_state_filter_problem( + target_reduced, context, atom_count, offset, state, + ) + evaluated += 1 + save_checkpoint(frame_cursor, order_cursor, offset_index - 1) + if _next_selected_assignment!( + filter_solver, filter_selected, + ) === nothing + filter_index == 1 ? + (stats.first_lower_rejected += 1) : + (stats.second_lower_rejected += 1) + rejected = true + break + end + end + if rejected + save_checkpoint(frame_cursor, order_cursor, offset_index) + continue + end evaluated == max_evaluations && return :budget - evaluated += 1 - analysis = _solve_next_fixed_crossing_sat!( - solver, selected, target_reduced, lattice, context, - ) - analysis === nothing && break - valid, verified_offset = is_gadget_replacement( - target_graph, analysis.graph, target_boundary, - analysis.boundary, + solver, selected = _fixed_crossing_sat_problem( + target_reduced, context, atom_count, offset, ) - valid || error("SAT candidate failed the fixed verifier") - analysis.offset == verified_offset || - error("SAT candidate offset mismatch") - key = _canonical_crossing_frame_key(lattice, ( - allowed=analysis.patch.coordinates, - pins=analysis.patch.pins, - rays=analysis.patch.rays, - )) - key in seen_gadgets && continue - push!(seen_gadgets, key) - push!( - gadgets, - _unweighted_gadget(target_graph, lattice, analysis), - ) - length(gadgets) == max_results && return :solution + stats.full_solves += 1 + while true + evaluated == max_evaluations && return :budget + evaluated += 1 + save_checkpoint(frame_cursor, order_cursor, offset_index - 1) + analysis = _solve_next_fixed_crossing_sat!( + solver, selected, target_reduced, lattice, context, + ) + analysis === nothing && break + valid, verified_offset = is_gadget_replacement( + target_graph, analysis.graph, target_boundary, + analysis.boundary, + ) + valid || error("SAT candidate failed the fixed verifier") + analysis.offset == verified_offset || + error("SAT candidate offset mismatch") + key = _canonical_crossing_frame_key(lattice, ( + allowed=analysis.patch.coordinates, + pins=analysis.patch.pins, + rays=analysis.patch.rays, + )) + key in seen_gadgets && continue + push!(seen_gadgets, key) + push!( + gadgets, + _unweighted_gadget(target_graph, lattice, analysis), + ) + length(gadgets) == max_results && return :solution + end + save_checkpoint(frame_cursor, order_cursor, offset_index) end + return nothing end - return nothing + stopped === nothing || return gadgets, evaluated, stopped end - stopped === nothing || return gadgets, evaluated, stopped end return gadgets, evaluated, :search_space_exhausted end -function _foreach_crossing_frame(visit, lattice, side, evaluated, limit) - window = _LatticeCoordinate[ - (column, row) for column in 0:side-1 for row in 0:side-1 +function _crossing_window_shapes(side, atom_count) + minimum_minor = cld(atom_count, side) + shapes = [(side, side)] + for minor in side-1:-1:minimum_minor + push!(shapes, (side, minor)) + push!(shapes, (minor, side)) + end + return shapes +end + +function _single_pin_corridor_clear(lattice, pins, index, ray_index) + direction = _lattice_directions(lattice)[ray_index] + interface = _lattice_step(lattice, pins[index], direction, 1) + start = _canonical_coordinate(lattice, interface) + canonical_pins = _canonical_coordinate.(Ref(lattice), pins) + adjacency_offsets = [_LatticeCoordinate[(0, 0)]; _lattice_directions(lattice)] + for pin_index in eachindex(pins) + pin_index == index && continue + site = canonical_pins[pin_index] + for offset in adjacency_offsets + _point_on_ray( + (site[1] + offset[1], site[2] + offset[2]), start, direction, + ) && return false + end + end + return true +end + +function _pin_rays_are_compatible(lattice, pins, rays) + directions = _lattice_directions(lattice)[collect(rays)] + interfaces = [ + _lattice_step(lattice, pin, direction, 1) + for (pin, direction) in zip(pins, directions) ] - ray_options = eachindex(_lattice_directions(lattice)) - seen = Set{Tuple}() - for pin_set in combinations(window, 4) - for pins in permutations(pin_set) - for rays in Iterators.product(ntuple(_ -> ray_options, 4)...) - evaluated[] == limit && return :frame_budget - evaluated[] += 1 - ordered_pins = collect(pins) - ray_indices = collect(rays) - patch = _LatticePatch(ordered_pins, ordered_pins, ray_indices) - all(_check_crossing_frame(lattice, patch)) || continue - allowed = [site for site in window if site in ordered_pins || all( - _check_crossing_frame( - lattice, - _LatticePatch( - [ordered_pins; site], ordered_pins, ray_indices, - ), - ), - )] - frame = ( - pins=ordered_pins, rays=ray_indices, allowed=allowed, + starts = _canonical_coordinate.(Ref(lattice), interfaces) + adjacency_offsets = [_LatticeCoordinate[(0, 0)]; _lattice_directions(lattice)] + for first in 1:3, second in first+1:4, offset in adjacency_offsets + _rays_touch( + starts[first], directions[first], starts[second], directions[second], + offset, + ) && return false + end + return true +end + +function _labels_alternate(lattice, pins, rays) + directions = _lattice_directions(lattice)[rays] + interfaces = [ + _lattice_step(lattice, pin, direction, 1) + for (pin, direction) in zip(pins, directions) + ] + interface_geometry = _geometry_coordinate.(Ref(lattice), interfaces) + occupied_geometry = _geometry_coordinate.(Ref(lattice), pins) + hull = _strict_convex_hull([occupied_geometry; interface_geometry]) + return _interfaces_alternate(hull, interface_geometry) +end + +function _allowed_crossing_sites(lattice, window, pins, rays, min_allowed) + directions = _lattice_directions(lattice)[rays] + interfaces = [ + _lattice_step(lattice, pin, direction, 1) + for (pin, direction) in zip(pins, directions) + ] + interface_geometry = _geometry_coordinate.(Ref(lattice), interfaces) + pin_geometry = _geometry_coordinate.(Ref(lattice), pins) + starts = _canonical_coordinate.(Ref(lattice), interfaces) + adjacency_offsets = [_LatticeCoordinate[(0, 0)]; _lattice_directions(lattice)] + allowed = copy(pins) + candidates = [site for site in window if site ∉ pins] + for (candidate_index, site) in enumerate(candidates) + canonical_site = _canonical_coordinate(lattice, site) + corridor_clear = all(eachindex(starts)) do ray_index + all(adjacency_offsets) do offset + !_point_on_ray( + (canonical_site[1] + offset[1], canonical_site[2] + offset[2]), + starts[ray_index], directions[ray_index], ) - key = _canonical_crossing_frame_key(lattice, frame) - key in seen && continue - push!(seen, key) - result = visit(frame) - result === nothing || return result end end + if corridor_clear + site_geometry = _geometry_coordinate(lattice, site) + hull = _strict_convex_hull([ + pin_geometry; site_geometry; interface_geometry + ]) + all(in(hull), interface_geometry) && push!(allowed, site) + end + remaining = length(candidates) - candidate_index + length(allowed) + remaining >= min_allowed || return nothing + end + sort!(allowed) + return allowed +end + +function _crossing_port_candidates(lattice, window) + return [ + (pin, ray) for pin in window + for ray in eachindex(_lattice_directions(lattice)) + ] +end + +function _crossing_ports_compatible(lattice, first, second) + first_pin, first_ray = first + second_pin, second_ray = second + first_pin == second_pin && return false + pins = [first_pin, second_pin] + _single_pin_corridor_clear(lattice, pins, 1, first_ray) || return false + _single_pin_corridor_clear(lattice, pins, 2, second_ray) || return false + directions = _lattice_directions(lattice) + first_direction = directions[first_ray] + second_direction = directions[second_ray] + first_start = _canonical_coordinate( + lattice, _lattice_step(lattice, first_pin, first_direction, 1), + ) + second_start = _canonical_coordinate( + lattice, _lattice_step(lattice, second_pin, second_direction, 1), + ) + return all([_LatticeCoordinate[(0, 0)]; directions]) do offset + !_rays_touch( + first_start, first_direction, second_start, second_direction, offset, + ) + end +end + +function _crossing_port_prefix(lattice, window, rank) + candidates = _crossing_port_candidates(lattice, window) + compatible_rank = 0 + for first in 1:length(candidates)-1, second in first+1:length(candidates) + _crossing_ports_compatible( + lattice, candidates[first], candidates[second], + ) || continue + compatible_rank += 1 + compatible_rank == rank && return (first, second) + end + error("compatible port-prefix rank $rank exceeds $compatible_rank") +end + +function _foreach_crossing_port_clique( + visit, lattice, window; shard_index=0, shard_count=1, + port_prefix=nothing, +) + candidates = _crossing_port_candidates(lattice, window) + compatible = falses(length(candidates), length(candidates)) + for first in 1:length(candidates)-1, second in first+1:length(candidates) + compatible[first, second] = _crossing_ports_compatible( + lattice, candidates[first], candidates[second], + ) + end + chosen = Int[] + function extend(available) + if length(chosen) == 4 + return visit(candidates[chosen]) + end + needed = 4 - length(chosen) + for position in 1:length(available)-needed+1 + candidate = available[position] + if isempty(chosen) + port_prefix !== nothing && candidate != port_prefix[1] && continue + elseif length(chosen) == 1 + port_prefix !== nothing && candidate != port_prefix[2] && continue + pair_key = (chosen[1] - 1) * length(candidates) + candidate - 1 + mod(pair_key, shard_count) == shard_index || continue + end + push!(chosen, candidate) + remaining = [ + other for other in @view(available[position+1:end]) + if compatible[candidate, other] + ] + result = extend(remaining) + pop!(chosen) + result === nothing || return result + end + return nothing + end + return extend(collect(eachindex(candidates))) +end + +function _foreach_crossing_frame_clique( + visit, lattice, shape, evaluated, limit; + min_allowed=0, seen=Set{Tuple}(), start_cursor=0, start_order=0, + shard_index=0, shard_count=1, port_prefix=nothing, +) + columns, rows = shape + window = _LatticeCoordinate[ + (column, row) for column in 0:columns-1 for row in 0:rows-1 + ] + cursor = 0 + return _foreach_crossing_port_clique( + lattice, window; shard_index, shard_count, port_prefix, + ) do ports + cursor += 1 + cursor < start_cursor && return nothing + evaluated[] == limit && return :frame_budget + evaluated[] += 1 + pins = first.(ports) + rays = last.(ports) + checks = _check_crossing_frame( + lattice, _LatticePatch(pins, pins, rays), + ) + checks[1] && checks[3] && checks[4] || return nothing + directions = _lattice_directions(lattice)[rays] + interfaces = [ + _lattice_step(lattice, pin, direction, 1) + for (pin, direction) in zip(pins, directions) + ] + interface_geometry = _geometry_coordinate.(Ref(lattice), interfaces) + occupied_geometry = _geometry_coordinate.(Ref(lattice), pins) + hull = _strict_convex_hull([occupied_geometry; interface_geometry]) + valid_orders = [ + collect(order) for order in permutations(1:4) + if _interfaces_alternate(hull, interface_geometry[collect(order)]) + ] + isempty(valid_orders) && return nothing + allowed = _allowed_crossing_sites( + lattice, window, pins, rays, min_allowed, + ) + allowed === nothing && return nothing + for (order_cursor, order) in enumerate(valid_orders) + cursor == start_cursor && order_cursor < start_order && continue + frame = ( + pins=pins[order], rays=rays[order], allowed=allowed, + ) + key = _canonical_crossing_frame_key(lattice, frame) + key in seen && continue + push!(seen, key) + result = visit(frame, cursor, order_cursor) + result === nothing || return result + end + return nothing end - return nothing end diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index f21ff90..9a24cee 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -26,6 +26,21 @@ struct UnweightedSearchResult termination_reason::Symbol end +"""One verifier-certified atom-reducing rewrite.""" +struct UnweightedRewriteStep + rule::Symbol + before::UnweightedGadget + after::UnweightedGadget +end + +"""Result of rewriting and re-synthesizing one realized lattice gadget.""" +struct UnweightedOptimizationResult + gadget::UnweightedGadget + steps::Vector{UnweightedRewriteStep} + sat_evaluations::Int + termination_reason::Symbol +end + struct _LatticePatch coordinates::Vector{_LatticeCoordinate} pins::Vector{_LatticeCoordinate} @@ -47,6 +62,8 @@ function search_unweighted_gadgets( max_frame_evaluations::Int=1_000_000, max_results::Int=1, window_side::Int=4, + checkpoint_path::Union{Nothing, String}=nothing, + checkpoint_interval::Int=10_000, ) boundary_count = length(target_boundary) boundary_count == 4 || @@ -61,6 +78,8 @@ function search_unweighted_gadgets( throw(ArgumentError("max_frame_evaluations must be positive")) max_results > 0 || throw(ArgumentError("max_results must be positive")) window_side >= 2 || throw(ArgumentError("window_side must be at least 2")) + checkpoint_interval > 0 || + throw(ArgumentError("checkpoint_interval must be positive")) target_reduced = vec(calculate_reduced_alpha_tensor(target_graph, target_boundary)) all(isinf, target_reduced) && @@ -68,7 +87,7 @@ function search_unweighted_gadgets( gadgets, evaluated, reason = _search_crossing_sat( target_graph, target_boundary, target_reduced, lattice; min_vertices, max_vertices, max_evaluations, max_frame_evaluations, - max_results, window_side, + max_results, window_side, checkpoint_path, checkpoint_interval, ) return UnweightedSearchResult( target_graph, copy(target_boundary), _lattice_symbol(lattice), @@ -76,6 +95,372 @@ function search_unweighted_gadgets( ) end +""" +Search one joint occupancy-and-frame SAT instance in a fixed lattice window. + +Unlike `search_unweighted_gadgets`, this formulation chooses the occupied sites, +four pins, and four outward rays in one CNF. `atom_count` and `offset` identify +the exact instance to solve. The unchanged gadget verifier checks every result. +""" +function search_unweighted_gadget_joint( + target_graph::SimpleGraph{Int}, + target_boundary::Vector{Int}, + lattice::LatticeType=Triangular(); + window_shape::Tuple{Int, Int}, + atom_count::Int, + offset::Int, + seconds::Int=600, + kissat_executable::Union{Nothing, String}=nothing, + seed::Int=1, + canonical_shift::Tuple{Int, Int}=(0, 0), + first_ray::Int=1, +) + length(target_boundary) == 4 || + throw(ArgumentError("joint unweighted search requires four boundary vertices")) + all(>=(2), window_shape) || + throw(ArgumentError("window dimensions must be at least 2")) + length(target_boundary) <= atom_count <= prod(window_shape) || + throw(ArgumentError("atom_count must fit in the window")) + seconds > 0 || throw(ArgumentError("seconds must be positive")) + seed > 0 || throw(ArgumentError("seed must be positive")) + directions = _lattice_directions(lattice) + first_ray in eachindex(directions) || + throw(ArgumentError("first_ray is not a lattice direction index")) + + target_reduced = vec(calculate_reduced_alpha_tensor( + target_graph, target_boundary, + )) + all(value -> isinf(value) || isinteger(value), target_reduced) || + error("the reduced alpha tensor must contain integers or -Inf") + analysis = _solve_joint_crossing_sat( + target_reduced, lattice, window_shape, atom_count, offset; + seconds, kissat_executable, initial_seed=seed, canonical_shift, + first_direction_index=first_ray, + ) + analysis === nothing && return nothing + verified, verified_offset = is_gadget_replacement( + target_graph, analysis.graph, target_boundary, analysis.boundary, + ) + verified || error("joint SAT result failed the gadget verifier") + verified_offset == analysis.offset || + error("joint SAT result and gadget verifier disagree on the offset") + return _unweighted_gadget(target_graph, lattice, analysis) +end + +""" + optimize_unweighted_gadget(gadget, target_boundary; kwargs...) + +Reduce a verifier-accepted four-pin lattice gadget with certified rewrite rules. +The optimizer contracts even boundary tails, contracts opposite leaf pins in +pairs, and uses fixed-frame SAT to re-synthesize the interior after a one-step +frame rewrite. Direct rewrites are explored to a closure before re-synthesis, +so the optimizer does not commit to the first smaller direct result. It never +proposes arbitrary vertex deletion. +""" +function optimize_unweighted_gadget( + gadget::UnweightedGadget, + target_boundary::Vector{Int}; + min_vertices::Int=length(target_boundary), + max_sat_evaluations::Int=256, + host_radius::Int=1, +) + length(target_boundary) == 4 || + throw(ArgumentError("unweighted rewrite optimization requires four target boundary vertices")) + min_vertices >= length(target_boundary) || + throw(ArgumentError("min_vertices must include all boundary vertices")) + min_vertices <= nv(gadget.replacement_graph) || + throw(ArgumentError("min_vertices exceeds the current gadget size")) + max_sat_evaluations >= 0 || + throw(ArgumentError("max_sat_evaluations must be nonnegative")) + host_radius >= 0 || throw(ArgumentError("host_radius must be nonnegative")) + + lattice = _gadget_lattice(gadget) + target_reduced = vec(calculate_reduced_alpha_tensor( + gadget.pattern_graph, target_boundary, + )) + valid, offset = is_gadget_replacement( + gadget.pattern_graph, gadget.replacement_graph, target_boundary, + gadget.boundary_vertices, + ) + valid || throw(ArgumentError("the input gadget does not replace the requested target boundary")) + offset == gadget.constant_offset || + throw(ArgumentError("the input gadget stores the wrong constant offset")) + current = gadget + steps = UnweightedRewriteStep[] + sat_evaluations = 0 + while true + nv(current.replacement_graph) == min_vertices && + return UnweightedOptimizationResult(current, steps, sat_evaluations, :minimum_vertices) + + current_key = _rewrite_state_key(current) + direct_states = [(gadget=current, steps=steps)] + seen_states = Set{Tuple}([current_key]) + for state in direct_states + for candidate in _direct_unweighted_rewrites( + state.gadget, target_boundary, target_reduced, lattice, min_vertices, + ) + candidate_steps = [ + state.steps; + UnweightedRewriteStep( + candidate.rule, state.gadget, candidate.gadget, + ) + ] + nv(candidate.gadget.replacement_graph) == min_vertices && + return UnweightedOptimizationResult( + candidate.gadget, candidate_steps, sat_evaluations, + :minimum_vertices, + ) + candidate_key = _rewrite_state_key(candidate.gadget) + candidate_key in seen_states && continue + push!(seen_states, candidate_key) + push!(direct_states, (; + gadget=candidate.gadget, steps=candidate_steps, + )) + end + end + + best = argmin( + state -> nv(state.gadget.replacement_graph), direct_states, + ) + sources = [ + sort(direct_states[2:end]; by=state -> -nv(state.gadget.replacement_graph)); + direct_states[1] + ] + resumed = false + for source in sources + candidate, used, status = _resynthesized_unweighted_rewrite( + source.gadget, target_boundary, target_reduced, lattice, min_vertices, + max_sat_evaluations - sat_evaluations, host_radius, + ) + sat_evaluations += used + if candidate !== nothing + current = candidate.gadget + steps = [ + source.steps; + UnweightedRewriteStep( + candidate.rule, source.gadget, candidate.gadget, + ) + ] + resumed = true + break + end + status == :budget && return UnweightedOptimizationResult( + best.gadget, best.steps, sat_evaluations, :sat_budget, + ) + end + resumed || return UnweightedOptimizationResult( + best.gadget, best.steps, sat_evaluations, :rewrite_fixed_point, + ) + end +end + +_rewrite_state_key(gadget) = ( + Tuple(gadget.lattice_coordinates), + Tuple(gadget.lattice_coordinates[gadget.boundary_vertices]), + Tuple(gadget.pin_rays), +) + +_gadget_lattice(gadget::UnweightedGadget) = + gadget.lattice == :KSG ? Square() : + gadget.lattice == :triangular ? Triangular() : + error("unknown gadget lattice $(gadget.lattice)") + +function _gadget_patch(gadget, lattice) + pins = gadget.lattice_coordinates[gadget.boundary_vertices] + directions = _lattice_directions(lattice) + rays = [_lattice_direction_index(directions, ray) for ray in gadget.pin_rays] + return _LatticePatch(copy(gadget.lattice_coordinates), pins, rays) +end + +function _rewrite_gadget( + gadget, target_boundary, target_reduced, lattice, sites, pins, rays, +) + analysis = _analyze_crossing_candidate( + target_reduced, lattice, collect(sites), collect(pins), collect(rays), + ) + analysis.solved || return nothing + return _certified_rewrite_gadget(gadget, target_boundary, lattice, analysis) +end + +function _certified_rewrite_gadget(gadget, target_boundary, lattice, analysis) + valid, offset = is_gadget_replacement( + gadget.pattern_graph, analysis.graph, target_boundary, analysis.boundary, + ) + valid || return nothing + offset == analysis.offset || error("rewrite analysis and verifier disagree on the offset") + return _unweighted_gadget(gadget.pattern_graph, lattice, analysis) +end + +function _direct_unweighted_rewrites( + gadget, target_boundary, target_reduced, lattice, min_vertices, +) + patch = _gadget_patch(gadget, lattice) + graph = gadget.replacement_graph + boundary = gadget.boundary_vertices + rewrites = NamedTuple[] + + nv(graph) - 2 >= min_vertices && for label in eachindex(boundary) + pin = boundary[label] + degree(graph, pin) == 1 || continue + middle = only(neighbors(graph, pin)) + for endpoint in neighbors(graph, middle) + endpoint == pin && continue + endpoint in boundary && continue + sites = setdiff(patch.coordinates, patch.coordinates[[pin, middle]]) + pins = copy(patch.pins) + pins[label] = patch.coordinates[endpoint] + rays = copy(patch.rays) + rays[label] = _direction_index_between( + lattice, pins[label], patch.coordinates[middle], + ) + rewritten = _rewrite_gadget( + gadget, target_boundary, target_reduced, lattice, sites, pins, rays, + ) + rewritten === nothing || push!(rewrites, (; + rule=:even_boundary_tail_contraction, gadget=rewritten, + )) + end + end + + nv(graph) - 2 >= min_vertices && for (first_label, second_label) in ((1, 3), (2, 4)) + first_pin = boundary[first_label] + second_pin = boundary[second_label] + degree(graph, first_pin) == 1 || continue + degree(graph, second_pin) == 1 || continue + first_neighbor = only(neighbors(graph, first_pin)) + second_neighbor = only(neighbors(graph, second_pin)) + first_neighbor == second_neighbor && continue + first_neighbor in boundary && continue + second_neighbor in boundary && continue + sites = setdiff( + patch.coordinates, patch.coordinates[[first_pin, second_pin]], + ) + pins = copy(patch.pins) + pins[first_label] = patch.coordinates[first_neighbor] + pins[second_label] = patch.coordinates[second_neighbor] + rays = copy(patch.rays) + rays[first_label] = _direction_index_between( + lattice, pins[first_label], patch.coordinates[first_pin], + ) + rays[second_label] = _direction_index_between( + lattice, pins[second_label], patch.coordinates[second_pin], + ) + rewritten = _rewrite_gadget( + gadget, target_boundary, target_reduced, lattice, sites, pins, rays, + ) + rewritten === nothing || push!(rewrites, (; + rule=:opposite_leaf_pin_contraction, gadget=rewritten, + )) + end + return rewrites +end + +function _direction_index_between(lattice, source, destination) + source_canonical = _canonical_coordinate(lattice, source) + destination_canonical = _canonical_coordinate(lattice, destination) + direction = ( + destination_canonical[1] - source_canonical[1], + destination_canonical[2] - source_canonical[2], + ) + return _lattice_direction_index(_lattice_directions(lattice), direction) +end + +function _expanded_lattice_host(lattice, coordinates, radius) + host = Set(coordinates) + frontier = Set(coordinates) + directions = _lattice_directions(lattice) + for _ in 1:radius + next_frontier = Set{_LatticeCoordinate}() + for coordinate in frontier, direction in directions + neighbor = _lattice_step(lattice, coordinate, direction, 1) + neighbor in host || push!(next_frontier, neighbor) + end + union!(host, next_frontier) + frontier = next_frontier + end + return sort!(collect(host)) +end + +function _one_step_frame_rewrites(lattice, patch) + directions = _lattice_directions(lattice) + frames = NamedTuple[] + for label in eachindex(patch.pins), step in directions + pins = copy(patch.pins) + pins[label] = _lattice_step(lattice, pins[label], step, 1) + length(unique(pins)) == 4 || continue + for ray in eachindex(directions) + rays = copy(patch.rays) + rays[label] = ray + _pin_rays_are_compatible(lattice, pins, rays) || continue + _labels_alternate(lattice, pins, rays) || continue + push!(frames, (; label, pins, rays)) + end + end + return frames +end + +function _resynthesized_unweighted_rewrite( + gadget, target_boundary, target_reduced, lattice, min_vertices, + sat_budget, host_radius, +) + iszero(sat_budget) && return nothing, 0, :budget + patch = _gadget_patch(gadget, lattice) + window = _expanded_lattice_host(lattice, patch.coordinates, host_radius) + rewritten_frames = _one_step_frame_rewrites(lattice, patch) + sort!(rewritten_frames; by=frame -> ( + degree(gadget.replacement_graph, gadget.boundary_vertices[frame.label]) == 1, + frame.rays[frame.label] != patch.rays[frame.label], + frame.label, + )) + frame_hosts = [begin + allowed = _allowed_crossing_sites( + lattice, window, frame.pins, frame.rays, min_vertices, + ) + allowed === nothing ? nothing : (; frame, allowed) + end for frame in rewritten_frames] + filter!(!isnothing, frame_hosts) + contexts = Dict{Int, _SatFrameContext}() + completion = _target_completion(target_reduced) + evaluated = 0 + for atom_count in nv(gadget.replacement_graph)-1:-1:min_vertices + expected_offset = Int(gadget.constant_offset) - + (nv(gadget.replacement_graph) - atom_count) + offsets = _centered_offsets( + -Int(minimum(completion)): + atom_count-Int(maximum(completion)), expected_offset, + ) + for offset in offsets + for (frame_index, frame_host) in enumerate(frame_hosts) + evaluated == sat_budget && return nothing, evaluated, :budget + length(frame_host.allowed) >= atom_count || continue + frame = (; + pins=frame_host.frame.pins, + rays=frame_host.frame.rays, + allowed=frame_host.allowed, + ) + context = get!(contexts, frame_index) do + _prepare_sat_frame(lattice, frame) + end + evaluated += 1 + analysis = _solve_fixed_crossing_sat( + target_reduced, lattice, context, atom_count, offset, + ) + analysis === nothing && continue + rewritten = _certified_rewrite_gadget( + gadget, target_boundary, lattice, analysis, + ) + rewritten === nothing && + error("fixed-frame SAT result failed the gadget verifier") + return (; + rule=:frame_rewrite_resynthesis, + gadget=rewritten, + ), evaluated, :found + end + end + end + return nothing, evaluated, :exhausted +end + function _analyze_crossing_candidate(target_reduced, lattice, sites, pins, rays) patch = _LatticePatch(sort(copy(sites)), copy(pins), copy(rays)) graph, boundary, positions = _materialize_lattice_patch(lattice, patch) diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 73fb4d1..5499fb9 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -1,4 +1,5 @@ using GadgetSearch +using Combinatorics using Graphs using Test @@ -8,7 +9,45 @@ function cross_graph() add_edge!(graph, 2, 4) return graph end + +function cross_edge_graph() + graph = SimpleGraph(6) + for edge in ((1,2), (1,5), (2,6), (3,5), (4,6)) + add_edge!(graph, edge...) + end + return graph +end @testset "Unweighted search" begin + @testset "selected connectivity matches enumeration" begin + lattice_host, _, _ = GadgetSearch._materialize_lattice_patch( + Triangular(), GadgetSearch._LatticePatch( + [(0,0),(1,0),(0,1),(1,1),(2,1)], [(0,0)], [1], + ), + ) + for host in (path_graph(5), cycle_graph(5), lattice_host) + adjacent = [collect(neighbors(host, vertex)) for vertex in vertices(host)] + for mask in 0:(1 << nv(host))-1 + !iszero(mask & 1) || continue + chosen = [vertex for vertex in vertices(host) + if !iszero(mask & (1 << (vertex - 1)))] + cnf = GadgetSearch._SatCnf() + selected = [GadgetSearch._sat_variable!(cnf) for _ in vertices(host)] + GadgetSearch._add_selected_connectivity!( + cnf, selected, adjacent, 1, length(chosen), + ) + for vertex in vertices(host) + GadgetSearch._sat_clause!( + cnf, vertex in chosen ? selected[vertex] : -selected[vertex], + ) + end + actual = GadgetSearch._next_selected_assignment!( + GadgetSearch._new_sat_solver(cnf), selected, + ) !== nothing + @test actual == is_connected(induced_subgraph(host, chosen)[1]) + end + end + end + @testset "fixed verifier" begin reduced = calculate_reduced_alpha_tensor(cross_graph(), [1, 2, 3, 4]) @test GadgetSearch.inf_mask(reduced) == BigInt(60576) @@ -37,6 +76,120 @@ end )) end + @testset "pruned frame generation preserves geometric candidates" begin + lattice = Triangular() + pins = [(2,0),(2,3),(0,1),(1,0)] + ray_indices = eachindex(GadgetSearch._lattice_directions(lattice)) + brute_force = Set{Tuple}() + for order in permutations(1:4) + ordered_pins = pins[collect(order)] + for rays in Iterators.product(ntuple(_ -> ray_indices, 4)...) + all(GadgetSearch._check_crossing_frame( + lattice, + GadgetSearch._LatticePatch( + ordered_pins, ordered_pins, collect(rays), + ), + )) || continue + push!(brute_force, (Tuple(ordered_pins), rays)) + end + end + + pruned = Set{Tuple}() + ray_options = [ + [ray for ray in ray_indices if GadgetSearch._single_pin_corridor_clear( + lattice, pins, index, ray, + )] for index in eachindex(pins) + ] + for rays_tuple in Iterators.product(ray_options...) + GadgetSearch._pin_rays_are_compatible( + lattice, pins, rays_tuple, + ) || continue + physical_rays = collect(rays_tuple) + checks = GadgetSearch._check_crossing_frame( + lattice, GadgetSearch._LatticePatch(pins, pins, physical_rays), + ) + checks[1] && checks[3] && checks[4] || continue + for order_tuple in permutations(1:4) + order = collect(order_tuple) + ordered_pins = pins[order] + ordered_rays = physical_rays[order] + GadgetSearch._labels_alternate( + lattice, ordered_pins, ordered_rays, + ) || continue + push!(pruned, (Tuple(ordered_pins), Tuple(ordered_rays))) + end + end + @test pruned == brute_force + + clique_frames = Set{Tuple}() + expected_prefixed_frames = Set{Tuple}() + clique_evaluated = Ref(0) + candidates = GadgetSearch._crossing_port_candidates( + lattice, [(column, row) for column in 0:2 for row in 0:2], + ) + GadgetSearch._foreach_crossing_frame_clique( + lattice, (3,3), clique_evaluated, typemax(Int); min_allowed=4, + ) do frame, _, _ + key = GadgetSearch._canonical_crossing_frame_key(lattice, frame) + push!(clique_frames, key) + indices = sort([findfirst(==(port), candidates) + for port in zip(frame.pins, frame.rays)]) + indices[1:2] == [1, 9] && push!(expected_prefixed_frames, key) + return nothing + end + sharded_frames = Set{Tuple}() + for shard_index in 0:2 + evaluated = Ref(0) + GadgetSearch._foreach_crossing_frame_clique( + lattice, (3,3), evaluated, typemax(Int); + min_allowed=4, shard_index, shard_count=3, + ) do frame, _, _ + push!(sharded_frames, + GadgetSearch._canonical_crossing_frame_key(lattice, frame)) + return nothing + end + end + @test sharded_frames == clique_frames + + prefixed_frames = Set{Tuple}() + prefixed_evaluated = Ref(0) + GadgetSearch._foreach_crossing_frame_clique( + lattice, (3,3), prefixed_evaluated, typemax(Int); + min_allowed=4, port_prefix=(1, 9), + ) do frame, _, _ + push!(prefixed_frames, + GadgetSearch._canonical_crossing_frame_key(lattice, frame)) + return nothing + end + @test prefixed_frames == expected_prefixed_frames + cross23_window = [(column, row) + for column in 0:7 for row in 0:7] + @test GadgetSearch._crossing_port_prefix( + Triangular(), cross23_window, 5117, + ) == (21, 177) + + for (candidate_pins, candidate_rays, side) in ( + (pins, [6,2,4,5], 4), + ([(5,1),(0,3),(3,5),(6,6)], [6,3,3,2], 8), + ) + window = [(column, row) + for column in 0:side-1 for row in 0:side-1] + expected = sort([site for site in window + if site in candidate_pins || begin + checks = GadgetSearch._check_crossing_frame( + lattice, GadgetSearch._LatticePatch( + [candidate_pins; site], candidate_pins, candidate_rays, + ), + ) + checks[1] && checks[3] && checks[4] + end]) + @test GadgetSearch._allowed_crossing_sites( + lattice, window, candidate_pins, candidate_rays, 0, + ) == expected + end + + end + @testset "fixed SAT positive control" begin coordinates = [ (0,3),(6,6),(1,3),(2,1),(2,2),(2,3),(3,1),(3,3), @@ -56,6 +209,14 @@ end ) @test analysis !== nothing @test analysis.offset == 7 + context = GadgetSearch._prepare_sat_frame(Triangular(), frame) + filter_solver, filter_selected = GadgetSearch._lower_state_filter_problem( + target_reduced, context, 23, 7, + first(GadgetSearch._essential_lower_states(target_reduced)), + ) + @test GadgetSearch._next_selected_assignment!( + filter_solver, filter_selected, + ) !== nothing @test nv(analysis.graph) == 23 @test is_gadget_replacement( cross_graph(), analysis.graph, [1,2,3,4], analysis.boundary, @@ -64,6 +225,196 @@ end Triangular(), analysis.patch.coordinates, analysis.patch.pins, GadgetSearch._patch_ray_directions(Triangular(), analysis.patch), )) + + extended_sites = [coordinates; (6,0); (3,6)] + extended_pins = [(6,0), (0,3), (3,6), (6,6)] + extended_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), extended_sites, extended_pins, + frame.rays, + ) + @test extended_analysis.solved + @test extended_analysis.offset == 8 + extended_gadget = GadgetSearch._unweighted_gadget( + cross_graph(), Triangular(), extended_analysis, + ) + optimized = optimize_unweighted_gadget( + extended_gadget, [1,2,3,4]; + min_vertices=23, max_sat_evaluations=0, + ) + @test nv(optimized.gadget.replacement_graph) == 23 + @test optimized.gadget.constant_offset == 7 + @test only(optimized.steps).rule == :opposite_leaf_pin_contraction + @test nv(only(optimized.steps).before.replacement_graph) == 25 + @test nv(only(optimized.steps).after.replacement_graph) == 23 + @test optimized.termination_reason == :minimum_vertices + @test is_gadget_replacement( + cross_graph(), optimized.gadget.replacement_graph, [1,2,3,4], + optimized.gadget.boundary_vertices, + ) == (true, 7.0) + + directions = GadgetSearch._lattice_directions(Triangular()) + tail_middle = GadgetSearch._lattice_step( + Triangular(), frame.pins[1], directions[frame.rays[1]], 1, + ) + tail_pin = GadgetSearch._lattice_step( + Triangular(), frame.pins[1], directions[frame.rays[1]], 2, + ) + tail_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), + [coordinates; tail_middle; tail_pin], + [tail_pin; frame.pins[2:4]], frame.rays, + ) + @test tail_analysis.solved + tail_gadget = GadgetSearch._unweighted_gadget( + cross_graph(), Triangular(), tail_analysis, + ) + tail_optimized = optimize_unweighted_gadget( + tail_gadget, [1,2,3,4]; + min_vertices=23, max_sat_evaluations=0, + ) + @test only(tail_optimized.steps).rule == + :even_boundary_tail_contraction + @test nv(tail_optimized.gadget.replacement_graph) == 23 + + rewrite_start_coordinates = [ + (4,7),(2,0),(6,0),(7,3),(1,3),(2,4),(3,6),(3,7), + (2,2),(3,4),(3,5),(2,1),(3,3),(4,4),(4,5),(3,0), + (4,2),(5,4),(5,5),(4,0),(4,1),(5,2),(5,3),(6,5), + (5,1),(6,2),(6,3),(7,4), + ] + rewrite_start_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), rewrite_start_coordinates, + [(4,7),(2,0),(6,0),(7,3)], [2,5,5,1], + ) + @test rewrite_start_analysis.solved + @test rewrite_start_analysis.offset == 10 + rewrite_start = GadgetSearch._unweighted_gadget( + cross_graph(), Triangular(), rewrite_start_analysis, + ) + rewritten = optimize_unweighted_gadget( + rewrite_start, [1,2,3,4]; + min_vertices=23, max_sat_evaluations=8, host_radius=1, + ) + @test rewritten.termination_reason == :minimum_vertices + @test rewritten.sat_evaluations <= 8 + @test [step.rule for step in rewritten.steps] == [ + :even_boundary_tail_contraction, + :frame_rewrite_resynthesis, + :opposite_leaf_pin_contraction, + ] + @test [ + nv(step.before.replacement_graph) => nv(step.after.replacement_graph) + for step in rewritten.steps + ] == [28 => 26, 26 => 25, 25 => 23] + @test is_gadget_replacement( + cross_graph(), rewritten.gadget.replacement_graph, [1,2,3,4], + rewritten.gadget.boundary_vertices, + ) == (true, 7.0) + + rewrite_budget = optimize_unweighted_gadget( + GadgetSearch._unweighted_gadget( + cross_graph(), Triangular(), analysis, + ), + [1,2,3,4]; + min_vertices=22, max_sat_evaluations=1, host_radius=0, + ) + @test isempty(rewrite_budget.steps) + @test rewrite_budget.sat_evaluations == 1 + @test rewrite_budget.termination_reason == :sat_budget + if !Sys.iswindows() + canonical_coordinates = [ + (0,-5),(-1,-4),(-1,-3),(1,-5),(0,-3),(3,-7),(2,-6), + (2,-5),(1,-4),(1,-3),(0,-2),(0,-1),(3,-6),(2,-4), + (1,-2),(0,0),(5,-7),(4,-6),(3,-4),(3,-3),(2,-2), + (6,-7),(4,-4),(4,-3),(6,-6),(6,-5),(5,-4),(4,-2), + ] + joint_frame = [ + ((0,0),(1,0)), ((-1,-3),(-1,1)), + ((3,-7),(0,-1)), ((4,-2),(1,0)), + ] + joint_solution = GadgetSearch._from_canonical.( + Ref(Triangular()), canonical_coordinates, + ) + joint_pins = GadgetSearch._from_canonical.( + Ref(Triangular()), first.(joint_frame), + ) + directions = GadgetSearch._lattice_directions(Triangular()) + joint_rays = [findfirst(==(ray), directions) for ray in last.(joint_frame)] + joint_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), joint_solution, joint_pins, joint_rays, + ) + @test joint_analysis.solved + @test joint_analysis.offset == 10 + joint_cnf, joint_selected, joint_choices, joint_coordinates = + GadgetSearch._joint_crossing_sat_cnf( + target_reduced, Triangular(), (8,8), 28, 10, + ) + selected_coordinates = Set(joint_solution) + for (vertex, coordinate) in enumerate(joint_coordinates) + GadgetSearch._sat_clause!( + joint_cnf, coordinate in selected_coordinates ? + joint_selected[vertex] : -joint_selected[vertex], + ) + end + for label in 1:4, + (vertex, direction, variable) in joint_choices[label] + chosen = joint_coordinates[vertex] == joint_pins[label] && + direction == joint_rays[label] + GadgetSearch._sat_clause!( + joint_cnf, chosen ? variable : -variable, + ) + end + status, _ = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(joint_cnf), joint_selected, 100_000, + ) + @test status == :sat + + cross23_sites = [ + (0,0),(3,-7),(0,-1),(-2,-1),(-1,-1),(0,-2),(-2,-2), + (0,-3),(1,-3),(2,-4),(-2,-3),(-1,-3),(1,-4),(2,-5), + (-2,-4),(-1,-4),(0,-5),(1,-5),(2,-6),(-1,-5),(0,-6), + (2,-7),(1,-7), + ] + cross23_pins = [(0,0),(-2,-4),(3,-7),(2,-4)] + cross23_rays = [1,4,6,1] + cross23_solution = GadgetSearch._from_canonical.( + Ref(Triangular()), cross23_sites, + ) + cross23_boundary = GadgetSearch._from_canonical.( + Ref(Triangular()), cross23_pins, + ) + cross23_cnf, cross23_selected, cross23_choices, cross23_coordinates = + GadgetSearch._joint_crossing_sat_cnf( + target_reduced, Triangular(), (8,8), 23, 7; + canonical_shift=(-1,0), + ) + selected_coordinates = Set(cross23_solution) + for (vertex, coordinate) in enumerate(cross23_coordinates) + GadgetSearch._sat_clause!( + cross23_cnf, coordinate in selected_coordinates ? + cross23_selected[vertex] : -cross23_selected[vertex], + ) + end + for label in 1:4, + (vertex, direction, variable) in cross23_choices[label] + chosen = cross23_coordinates[vertex] == cross23_boundary[label] && + direction == cross23_rays[label] + GadgetSearch._sat_clause!( + cross23_cnf, chosen ? variable : -variable, + ) + end + status, _ = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(cross23_cnf), cross23_selected, + 100_000, + ) + @test status == :sat + cross23_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), cross23_solution, + cross23_boundary, cross23_rays, + ) + @test cross23_analysis.solved + @test cross23_analysis.offset == 7 + end shifted_target = map(value -> isfinite(value) ? value + 8 : value, target_reduced) negative_offset = GadgetSearch._solve_fixed_crossing_sat( shifted_target, Triangular(), frame, 23, -1, @@ -98,6 +449,72 @@ end )) end + @testset "joint frame and occupancy SAT" begin + target = complete_graph(4) + target_reduced = vec(calculate_reduced_alpha_tensor( + target, collect(1:4), + )) + public_joint = search_unweighted_gadget_joint( + target, collect(1:4), Square(); + window_shape=(2,2), atom_count=4, offset=0, seconds=30, + ) + @test public_joint !== nothing + @test nv(public_joint.replacement_graph) == 4 + @test first(is_gadget_replacement( + target, public_joint.replacement_graph, collect(1:4), + public_joint.boundary_vertices, + )) + @test all(check_crossing_frame( + Square(), public_joint.lattice_coordinates, + public_joint.lattice_coordinates[public_joint.boundary_vertices], + public_joint.pin_rays, + )) + cross_edge_target = cross_edge_graph() + cross_edge = search_unweighted_gadget_joint( + cross_edge_target, collect(1:4), Triangular(); + window_shape=(3,4), atom_count=9, offset=1, seconds=30, + canonical_shift=(-1,1), first_ray=1, + ) + @test cross_edge !== nothing + @test first(is_gadget_replacement( + cross_edge_target, cross_edge.replacement_graph, collect(1:4), + cross_edge.boundary_vertices, + )) + optimized_cross_edge = optimize_unweighted_gadget( + cross_edge, collect(1:4); + min_vertices=4, max_sat_evaluations=256, host_radius=1, + ) + @test nv(optimized_cross_edge.gadget.replacement_graph) == 9 + @test isempty(optimized_cross_edge.steps) + @test optimized_cross_edge.sat_evaluations == 256 + @test optimized_cross_edge.termination_reason == :sat_budget + rotated_cross_edge = search_unweighted_gadget_joint( + cross_edge_target, collect(1:4), Triangular(); + window_shape=(3,4), atom_count=9, offset=1, seconds=30, + canonical_shift=(0,0), first_ray=2, + ) + @test rotated_cross_edge !== nothing + @test first(is_gadget_replacement( + cross_edge_target, rotated_cross_edge.replacement_graph, + collect(1:4), rotated_cross_edge.boundary_vertices, + )) + @test isnothing(GadgetSearch._solve_joint_crossing_sat( + target_reduced, Square(), (2,2), 4, 1, + )) + + crossing_points = [(0,0), (0,2), (2,0), (2,2)] + for chosen in Iterators.product(ntuple(_ -> 1:4, 4)...) + points = crossing_points[collect(chosen)] + hull = GadgetSearch._strict_convex_hull(points) + expected = length(unique(points)) == 4 && length(hull) == 4 && + GadgetSearch._interfaces_alternate(hull, points) + actual = GadgetSearch._interfaces_form_alternating_quadrilateral( + points, + ) + @test actual == expected + end + end + @testset "SAT model enumeration" begin cnf = GadgetSearch._SatCnf() foreach(_ -> GadgetSearch._sat_variable!(cnf), 1:3) @@ -111,15 +528,42 @@ end @test assignments == Set([ (false, false), (false, true), (true, false), (true, true), ]) + if !Sys.iswindows() + sat_cnf = GadgetSearch._SatCnf() + variable = GadgetSearch._sat_variable!(sat_cnf) + GadgetSearch._sat_clause!(sat_cnf, variable) + status, assignment = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(sat_cnf), [variable], 10, + ) + @test status == :sat + @test assignment == [true] + + unsat_cnf = GadgetSearch._SatCnf() + variable = GadgetSearch._sat_variable!(unsat_cnf) + GadgetSearch._sat_clause!(unsat_cnf, variable) + GadgetSearch._sat_clause!(unsat_cnf, -variable) + status, assignment = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(unsat_cnf), [variable], 10, + ) + @test status == :unsat + @test assignment === nothing + end end @testset "public bounded search" begin + @test GadgetSearch._crossing_window_shapes(8, 23) == [ + (8,8), (8,7), (7,8), (8,6), (6,8), + (8,5), (5,8), (8,4), (4,8), (8,3), (3,8), + ] @test_throws ArgumentError search_unweighted_gadgets( path_graph(3), [1,2,3], Triangular(), ) @test_throws ArgumentError search_unweighted_gadgets( path_graph(4), [1,2,3,4]; window_side=1, ) + @test_throws ArgumentError search_unweighted_gadgets( + path_graph(4), [1,2,3,4]; checkpoint_interval=0, + ) ksg = search_unweighted_gadgets( cross_graph(), [1,2,3,4], Square(); min_vertices=4, max_vertices=4, max_evaluations=1, @@ -157,6 +601,31 @@ end @test fully_enumerated.termination_reason == :search_space_exhausted @test length(fully_enumerated.gadgets) == 1 @test fully_enumerated.evaluated > solved.evaluated + + mktempdir() do directory + checkpoint_path = joinpath(directory, "search.checkpoint") + interrupted = search_unweighted_gadgets( + ksg_target, collect(1:4), Square(); + min_vertices=4, max_vertices=4, max_evaluations=3, + max_results=2, max_frame_evaluations=100_000, + window_side=2, checkpoint_path, checkpoint_interval=1, + ) + @test interrupted.termination_reason == :budget + @test isfile(checkpoint_path) + checkpoint = read_unweighted_search_checkpoint(checkpoint_path) + @test checkpoint.window_shape == (2, 2) + @test checkpoint.frame_cursor >= 1 + @test checkpoint.order_cursor >= 1 + @test checkpoint.offset_cursor >= 0 + resumed = search_unweighted_gadgets( + ksg_target, collect(1:4), Square(); + min_vertices=4, max_vertices=4, max_evaluations=20, + max_results=2, max_frame_evaluations=100_000, + window_side=2, checkpoint_path, checkpoint_interval=1, + ) + @test resumed.termination_reason == :search_space_exhausted + @test length(resumed.gadgets) == 1 + end point = (3, 4) @test GadgetSearch._from_canonical(Square(), point) == point canonical = GadgetSearch._canonical_coordinate(Triangular(), point) From 30b79f88fd6a0617d9804b7215ea35fa8cd7163e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 18:27:19 +0800 Subject: [PATCH 7/7] Bound rewrite SAT effort with Kissat --- .github/workflows/CI.yml | 3 +- Project.toml | 2 - README.md | 4 +- docs/src/unweighted_search.md | 7 +- src/GadgetSearch.jl | 6 +- src/core/unweighted_sat.jl | 237 ++++++++++++++------------------- src/core/unweighted_search.jl | 57 ++++++-- test/core/search.jl | 3 +- test/core/unweighted_search.jl | 226 ++++++++++++++++--------------- 9 files changed, 270 insertions(+), 275 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index ea5893d..059a13a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -28,7 +28,6 @@ jobs: os: - ubuntu-latest - macos-latest - - windows-latest arch: - x64 steps: @@ -76,4 +75,4 @@ jobs: using Documenter: DocMeta, doctest using GadgetSearch DocMeta.setdocmeta!(GadgetSearch, :DocTestSetup, :(using GadgetSearch); recursive=true) - doctest(GadgetSearch) \ No newline at end of file + doctest(GadgetSearch) diff --git a/Project.toml b/Project.toml index ee5b7c8..857636f 100644 --- a/Project.toml +++ b/Project.toml @@ -7,7 +7,6 @@ authors = ["Xiwei Pan"] ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4" Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" Combinatorics = "861a8166-3701-5b0c-9a16-15d98fcdc6aa" -CryptoMiniSat = "8e9d5576-ec03-11e9-187e-e534548435ab" GenericTensorNetworks = "3521c873-ad32-4bb4-b63d-f4f178f42b49" GraphIO = "aa1b3936-2fda-51b9-ab35-c553d3a640a2" Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6" @@ -27,7 +26,6 @@ Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" ColorSchemes = "3.29.0" Colors = "0.13" Combinatorics = "1" -CryptoMiniSat = "0.3.0" GenericTensorNetworks = "4.1.0" GraphIO = "0.7.1" Graphs = "1.9.0" diff --git a/README.md b/README.md index 791758c..68a9878 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,7 @@ Unweighted mode ignores vertex weights, treats the pins as open vertices, and computes the reduced alpha tensor. The lattice dimensions can be adjusted from 2×2 to 20×20 and are preserved in exported JSON. -The unweighted SAT search uses Kissat on Linux and macOS. Windows uses -CryptoMiniSat because the Julia Kissat binary is unavailable there. Windows is -supported for tests, but not recommended for long searches; use Linux or macOS. +The unweighted SAT search uses Kissat and supports Linux and macOS. ### Rydberg Model (MIS-based) diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 8ae6afa..84b5002 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -101,13 +101,18 @@ from the direct descendants, so a smaller direct dead end does not hide a rewrite-and-resynthesize path. The result includes the best gadget reached in the selected one-step frame neighborhood, a replayable before/after rewrite trace, the number of fixed-frame SAT calls, and its termination reason. A fixed -point under these rules and budgets is not a global minimum certificate. +point under these rules and budgets is not a global minimum certificate. Each +fixed-frame solve is capped by `max_sat_conflicts`; capped calls are counted in +`unresolved_sat_evaluations`. If the neighborhood is exhausted while any such +call remains unresolved, the termination reason is `:sat_unknown`, not +`:rewrite_fixed_point`. ```julia optimized = optimize_unweighted_gadget( gadget, [1, 2, 3, 4]; min_vertices=17, max_sat_evaluations=256, + max_sat_conflicts=100_000, host_radius=1, ) ``` diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 193a557..1269d70 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -9,11 +9,7 @@ using IterTools using Combinatorics using Random using Serialization -@static if Sys.iswindows() - import CryptoMiniSat -else - import Kissat_jll -end +import Kissat_jll # Tensor network dependencies for alpha tensor computation using GenericTensorNetworks: GenericTensorNetwork, IndependentSet, SizeMax, solve diff --git a/src/core/unweighted_sat.jl b/src/core/unweighted_sat.jl index e7168cb..3f77c2f 100644 --- a/src/core/unweighted_sat.jl +++ b/src/core/unweighted_sat.jl @@ -748,156 +748,113 @@ function _solve_next_fixed_crossing_sat!( return analysis end -@static if Sys.iswindows() - function _new_sat_solver(cnf::_SatCnf) - solver = CryptoMiniSat.CMS(cnf.variables; num_threads=1) - foreach(clause -> CryptoMiniSat.add_clause(solver, clause), cnf.clauses) - return solver - end - - function _next_selected_assignment!(solver, selected) - status = CryptoMiniSat.solve(solver) - status === false && return nothing - status === true || error("SAT solver returned an undefined result") - model = CryptoMiniSat.get_model(solver) - assignment = Bool[model[variable] for variable in selected] - blocking_clause = [ - assignment[index] ? -variable : variable - for (index, variable) in enumerate(selected) - ] - CryptoMiniSat.add_clause(solver, blocking_clause) - return assignment - end - - function _next_joint_assignment!( - solver, variables; seed, seconds, kissat_executable, - ) - isnothing(kissat_executable) || - error("an external Kissat executable is not supported on Windows") - status = CryptoMiniSat.solve(solver) - status === false && return :unsat, nothing - status === true || error("SAT solver returned an undefined result") - model = CryptoMiniSat.get_model(solver) - return :sat, Bool[model[variable] for variable in variables] - end - - _add_solver_clause!(solver, clause) = CryptoMiniSat.add_clause(solver, clause) - +mutable struct _KissatEnumerator + variables::Int + clauses::Vector{Vector{Int}} +end - function _next_selected_assignment_limited!( - solver, selected, conflict_limit; seed=1, - ) - error("conflict-limited frame scheduling requires Kissat") - end -else - mutable struct _KissatEnumerator - variables::Int - clauses::Vector{Vector{Int}} - end +_new_sat_solver(cnf::_SatCnf) = + _KissatEnumerator(cnf.variables, copy(cnf.clauses)) +_add_solver_clause!(solver::_KissatEnumerator, clause) = + push!(solver.clauses, collect(clause)) +_kissat_init() = ccall((:kissat_init, Kissat_jll.libkissat), Ptr{Cvoid}, ()) +_kissat_add(solver, literal) = ccall((:kissat_add, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid}, Cint), solver, literal) +_kissat_solve(solver) = ccall((:kissat_solve, Kissat_jll.libkissat), Cint, (Ptr{Cvoid},), solver) +_kissat_value(solver, variable) = ccall((:kissat_value, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cint), solver, variable) +_kissat_release(solver) = ccall((:kissat_release, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid},), solver) +_kissat_quiet(solver) = ccall((:kissat_set_option, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cstring, Cint), solver, "quiet", 1) +_kissat_seed(solver, seed) = ccall( + (:kissat_set_option, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cstring, Cint), solver, "seed", seed, +) +_kissat_walkinitially(solver) = ccall( + (:kissat_set_option, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cstring, Cint), solver, "walkinitially", 1, +) +_kissat_set_conflict_limit(solver, limit) = ccall( + (:kissat_set_conflict_limit, Kissat_jll.libkissat), Cint, + (Ptr{Cvoid}, Cuint), solver, limit, +) - _new_sat_solver(cnf::_SatCnf) = - _KissatEnumerator(cnf.variables, copy(cnf.clauses)) - _add_solver_clause!(solver::_KissatEnumerator, clause) = - push!(solver.clauses, collect(clause)) - _kissat_init() = ccall((:kissat_init, Kissat_jll.libkissat), Ptr{Cvoid}, ()) - _kissat_add(solver, literal) = ccall((:kissat_add, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid}, Cint), solver, literal) - _kissat_solve(solver) = ccall((:kissat_solve, Kissat_jll.libkissat), Cint, (Ptr{Cvoid},), solver) - _kissat_value(solver, variable) = ccall((:kissat_value, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cint), solver, variable) - _kissat_release(solver) = ccall((:kissat_release, Kissat_jll.libkissat), Cvoid, (Ptr{Cvoid},), solver) - _kissat_quiet(solver) = ccall((:kissat_set_option, Kissat_jll.libkissat), Cint, (Ptr{Cvoid}, Cstring, Cint), solver, "quiet", 1) - _kissat_seed(solver, seed) = ccall( - (:kissat_set_option, Kissat_jll.libkissat), Cint, - (Ptr{Cvoid}, Cstring, Cint), solver, "seed", seed, - ) - _kissat_walkinitially(solver) = ccall( - (:kissat_set_option, Kissat_jll.libkissat), Cint, - (Ptr{Cvoid}, Cstring, Cint), solver, "walkinitially", 1, - ) - _kissat_set_conflict_limit(solver, limit) = ccall( - (:kissat_set_conflict_limit, Kissat_jll.libkissat), Cint, - (Ptr{Cvoid}, Cuint), solver, limit, - ) +function _next_selected_assignment!(enumerator::_KissatEnumerator, selected) + solver = _kissat_init() + _kissat_quiet(solver) + for clause in enumerator.clauses + foreach(literal -> _kissat_add(solver, literal), clause) + _kissat_add(solver, 0) + end + status = _kissat_solve(solver) + assignment = status == 10 ? + Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing + _kissat_release(solver) + status == 20 && return nothing + status == 10 || error("SAT solver returned an undefined result") + push!(enumerator.clauses, [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ]) + return assignment +end - function _next_selected_assignment!(enumerator::_KissatEnumerator, selected) - solver = _kissat_init() - _kissat_quiet(solver) +function _next_joint_assignment!( + enumerator::_KissatEnumerator, variables; + seed, seconds, kissat_executable, +) + path, stream = mktemp() + try + println(stream, "p cnf $(enumerator.variables) $(length(enumerator.clauses))") for clause in enumerator.clauses - foreach(literal -> _kissat_add(solver, literal), clause) - _kissat_add(solver, 0) + println(stream, join(clause, ' '), " 0") end - status = _kissat_solve(solver) - assignment = status == 10 ? - Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing - _kissat_release(solver) - status == 20 && return nothing - status == 10 || error("SAT solver returned an undefined result") - push!(enumerator.clauses, [ - assignment[index] ? -variable : variable - for (index, variable) in enumerate(selected) - ]) - return assignment - end - - function _next_joint_assignment!( - enumerator::_KissatEnumerator, variables; - seed, seconds, kissat_executable, - ) - path, stream = mktemp() - try - println(stream, "p cnf $(enumerator.variables) $(length(enumerator.clauses))") - for clause in enumerator.clauses - println(stream, join(clause, ' '), " 0") - end - close(stream) - output = IOBuffer() - executable = isnothing(kissat_executable) ? - Kissat_jll.kissat() : kissat_executable - command = `$executable --sat --walkinitially --seed=$seed -q --time=$seconds $path` - process = run(pipeline(ignorestatus(command), stdout=output, stderr=stderr)) - status = process.exitcode - status == 20 && return :unsat, nothing - status == 0 && return :unknown, nothing - status == 10 || error("Kissat exited with status $status") - positive = Set{Int}() - for line in eachline(seekstart(output)) - startswith(line, "v ") || continue - for literal in split(line)[2:end] - value = parse(Int, literal) - value > 0 && push!(positive, value) - end + close(stream) + output = IOBuffer() + executable = isnothing(kissat_executable) ? + Kissat_jll.kissat() : kissat_executable + command = `$executable --sat --walkinitially --seed=$seed -q --time=$seconds $path` + process = run(pipeline(ignorestatus(command), stdout=output, stderr=stderr)) + status = process.exitcode + status == 20 && return :unsat, nothing + status == 0 && return :unknown, nothing + status == 10 || error("Kissat exited with status $status") + positive = Set{Int}() + for line in eachline(seekstart(output)) + startswith(line, "v ") || continue + for literal in split(line)[2:end] + value = parse(Int, literal) + value > 0 && push!(positive, value) end - return :sat, Bool[variable in positive for variable in variables] - finally - isopen(stream) && close(stream) - rm(path) end + return :sat, Bool[variable in positive for variable in variables] + finally + isopen(stream) && close(stream) + rm(path) end +end - function _next_selected_assignment_limited!( - enumerator::_KissatEnumerator, selected, conflict_limit; seed=1, - ) - solver = _kissat_init() - _kissat_quiet(solver) - _kissat_seed(solver, seed) - for clause in enumerator.clauses - foreach(literal -> _kissat_add(solver, literal), clause) - _kissat_add(solver, 0) - end - _kissat_set_conflict_limit(solver, conflict_limit) - status = _kissat_solve(solver) - assignment = status == 10 ? - Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing - _kissat_release(solver) - status == 0 && return :unknown, nothing - status == 20 && return :unsat, nothing - status == 10 || error("SAT solver returned an undefined result") - push!(enumerator.clauses, [ - assignment[index] ? -variable : variable - for (index, variable) in enumerate(selected) - ]) - return :sat, assignment - end +function _next_selected_assignment_limited!( + enumerator::_KissatEnumerator, selected, conflict_limit; seed=1, +) + solver = _kissat_init() + _kissat_quiet(solver) + _kissat_seed(solver, seed) + for clause in enumerator.clauses + foreach(literal -> _kissat_add(solver, literal), clause) + _kissat_add(solver, 0) + end + _kissat_set_conflict_limit(solver, conflict_limit) + status = _kissat_solve(solver) + assignment = status == 10 ? + Bool[_kissat_value(solver, variable) > 0 for variable in selected] : nothing + _kissat_release(solver) + status == 0 && return :unknown, nothing + status == 20 && return :unsat, nothing + status == 10 || error("SAT solver returned an undefined result") + push!(enumerator.clauses, [ + assignment[index] ? -variable : variable + for (index, variable) in enumerate(selected) + ]) + return :sat, assignment end function _search_crossing_sat( diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index 9a24cee..d74004b 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -38,6 +38,7 @@ struct UnweightedOptimizationResult gadget::UnweightedGadget steps::Vector{UnweightedRewriteStep} sat_evaluations::Int + unresolved_sat_evaluations::Int termination_reason::Symbol end @@ -162,6 +163,7 @@ function optimize_unweighted_gadget( target_boundary::Vector{Int}; min_vertices::Int=length(target_boundary), max_sat_evaluations::Int=256, + max_sat_conflicts::Int=100_000, host_radius::Int=1, ) length(target_boundary) == 4 || @@ -172,6 +174,8 @@ function optimize_unweighted_gadget( throw(ArgumentError("min_vertices exceeds the current gadget size")) max_sat_evaluations >= 0 || throw(ArgumentError("max_sat_evaluations must be nonnegative")) + max_sat_conflicts >= 0 || + throw(ArgumentError("max_sat_conflicts must be nonnegative")) host_radius >= 0 || throw(ArgumentError("host_radius must be nonnegative")) lattice = _gadget_lattice(gadget) @@ -188,9 +192,13 @@ function optimize_unweighted_gadget( current = gadget steps = UnweightedRewriteStep[] sat_evaluations = 0 + unresolved_sat_evaluations = 0 while true nv(current.replacement_graph) == min_vertices && - return UnweightedOptimizationResult(current, steps, sat_evaluations, :minimum_vertices) + return UnweightedOptimizationResult( + current, steps, sat_evaluations, unresolved_sat_evaluations, + :minimum_vertices, + ) current_key = _rewrite_state_key(current) direct_states = [(gadget=current, steps=steps)] @@ -208,6 +216,7 @@ function optimize_unweighted_gadget( nv(candidate.gadget.replacement_graph) == min_vertices && return UnweightedOptimizationResult( candidate.gadget, candidate_steps, sat_evaluations, + unresolved_sat_evaluations, :minimum_vertices, ) candidate_key = _rewrite_state_key(candidate.gadget) @@ -228,11 +237,13 @@ function optimize_unweighted_gadget( ] resumed = false for source in sources - candidate, used, status = _resynthesized_unweighted_rewrite( + candidate, used, unresolved, status = _resynthesized_unweighted_rewrite( source.gadget, target_boundary, target_reduced, lattice, min_vertices, - max_sat_evaluations - sat_evaluations, host_radius, + max_sat_evaluations - sat_evaluations, max_sat_conflicts, + host_radius, ) sat_evaluations += used + unresolved_sat_evaluations += unresolved if candidate !== nothing current = candidate.gadget steps = [ @@ -245,11 +256,15 @@ function optimize_unweighted_gadget( break end status == :budget && return UnweightedOptimizationResult( - best.gadget, best.steps, sat_evaluations, :sat_budget, + best.gadget, best.steps, sat_evaluations, + unresolved_sat_evaluations, :sat_budget, ) end resumed || return UnweightedOptimizationResult( - best.gadget, best.steps, sat_evaluations, :rewrite_fixed_point, + best.gadget, best.steps, sat_evaluations, + unresolved_sat_evaluations, + iszero(unresolved_sat_evaluations) ? + :rewrite_fixed_point : :sat_unknown, ) end end @@ -401,9 +416,9 @@ end function _resynthesized_unweighted_rewrite( gadget, target_boundary, target_reduced, lattice, min_vertices, - sat_budget, host_radius, + sat_budget, max_sat_conflicts, host_radius, ) - iszero(sat_budget) && return nothing, 0, :budget + iszero(sat_budget) && return nothing, 0, 0, :budget patch = _gadget_patch(gadget, lattice) window = _expanded_lattice_host(lattice, patch.coordinates, host_radius) rewritten_frames = _one_step_frame_rewrites(lattice, patch) @@ -422,6 +437,7 @@ function _resynthesized_unweighted_rewrite( contexts = Dict{Int, _SatFrameContext}() completion = _target_completion(target_reduced) evaluated = 0 + unresolved = 0 for atom_count in nv(gadget.replacement_graph)-1:-1:min_vertices expected_offset = Int(gadget.constant_offset) - (nv(gadget.replacement_graph) - atom_count) @@ -431,7 +447,8 @@ function _resynthesized_unweighted_rewrite( ) for offset in offsets for (frame_index, frame_host) in enumerate(frame_hosts) - evaluated == sat_budget && return nothing, evaluated, :budget + evaluated == sat_budget && + return nothing, evaluated, unresolved, :budget length(frame_host.allowed) >= atom_count || continue frame = (; pins=frame_host.frame.pins, @@ -442,10 +459,24 @@ function _resynthesized_unweighted_rewrite( _prepare_sat_frame(lattice, frame) end evaluated += 1 - analysis = _solve_fixed_crossing_sat( - target_reduced, lattice, context, atom_count, offset, + solver, selected = _fixed_crossing_sat_problem( + target_reduced, context, atom_count, offset, + ) + status, assignment = _next_selected_assignment_limited!( + solver, selected, max_sat_conflicts; seed=evaluated, + ) + if status == :unknown + unresolved += 1 + continue + end + status == :unsat && continue + chosen = findall(identity, assignment) + sites = context.coordinates[chosen] + analysis = _analyze_crossing_candidate( + target_reduced, lattice, sites, frame.pins, frame.rays, ) - analysis === nothing && continue + analysis.solved || + error("SAT candidate failed its encoded constraints") rewritten = _certified_rewrite_gadget( gadget, target_boundary, lattice, analysis, ) @@ -454,11 +485,11 @@ function _resynthesized_unweighted_rewrite( return (; rule=:frame_rewrite_resynthesis, gadget=rewritten, - ), evaluated, :found + ), evaluated, unresolved, :found end end end - return nothing, evaluated, :exhausted + return nothing, evaluated, unresolved, :exhausted end function _analyze_crossing_candidate(target_reduced, lattice, sites, pins, rays) diff --git a/test/core/search.jl b/test/core/search.jl index c691f8e..a218bf7 100644 --- a/test/core/search.jl +++ b/test/core/search.jl @@ -313,7 +313,6 @@ end # Check that save file is created when results are found all_results = vcat(results...) if !isempty(all_results) && isfile(temp_file) - # Windows: avoid holding an open file handle during cleanup saved_data = JSON3.read(read(temp_file, String)) @test saved_data isa Union{Vector, JSON3.Array} @test length(saved_data) > 0 @@ -321,7 +320,7 @@ end finally # Clean up temp file - isfile(temp_file) && rm(temp_file; force=true, allow_delayed_delete=true) + isfile(temp_file) && rm(temp_file; force=true) end end diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 5499fb9..6527d44 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -293,10 +293,12 @@ end ) rewritten = optimize_unweighted_gadget( rewrite_start, [1,2,3,4]; - min_vertices=23, max_sat_evaluations=8, host_radius=1, + min_vertices=23, max_sat_evaluations=8, + max_sat_conflicts=100_000, host_radius=1, ) @test rewritten.termination_reason == :minimum_vertices @test rewritten.sat_evaluations <= 8 + @test rewritten.unresolved_sat_evaluations < rewritten.sat_evaluations @test [step.rule for step in rewritten.steps] == [ :even_boundary_tail_contraction, :frame_rewrite_resynthesis, @@ -321,100 +323,111 @@ end @test isempty(rewrite_budget.steps) @test rewrite_budget.sat_evaluations == 1 @test rewrite_budget.termination_reason == :sat_budget - if !Sys.iswindows() - canonical_coordinates = [ - (0,-5),(-1,-4),(-1,-3),(1,-5),(0,-3),(3,-7),(2,-6), - (2,-5),(1,-4),(1,-3),(0,-2),(0,-1),(3,-6),(2,-4), - (1,-2),(0,0),(5,-7),(4,-6),(3,-4),(3,-3),(2,-2), - (6,-7),(4,-4),(4,-3),(6,-6),(6,-5),(5,-4),(4,-2), - ] - joint_frame = [ - ((0,0),(1,0)), ((-1,-3),(-1,1)), - ((3,-7),(0,-1)), ((4,-2),(1,0)), - ] - joint_solution = GadgetSearch._from_canonical.( - Ref(Triangular()), canonical_coordinates, - ) - joint_pins = GadgetSearch._from_canonical.( - Ref(Triangular()), first.(joint_frame), + + rewrite_unknown = optimize_unweighted_gadget( + GadgetSearch._unweighted_gadget( + cross_graph(), Triangular(), analysis, + ), + [1,2,3,4]; + min_vertices=22, max_sat_evaluations=10_000, + max_sat_conflicts=0, host_radius=0, + ) + @test isempty(rewrite_unknown.steps) + @test rewrite_unknown.sat_evaluations < 10_000 + @test rewrite_unknown.unresolved_sat_evaluations > 0 + @test rewrite_unknown.termination_reason == :sat_unknown + canonical_coordinates = [ + (0,-5),(-1,-4),(-1,-3),(1,-5),(0,-3),(3,-7),(2,-6), + (2,-5),(1,-4),(1,-3),(0,-2),(0,-1),(3,-6),(2,-4), + (1,-2),(0,0),(5,-7),(4,-6),(3,-4),(3,-3),(2,-2), + (6,-7),(4,-4),(4,-3),(6,-6),(6,-5),(5,-4),(4,-2), + ] + joint_frame = [ + ((0,0),(1,0)), ((-1,-3),(-1,1)), + ((3,-7),(0,-1)), ((4,-2),(1,0)), + ] + joint_solution = GadgetSearch._from_canonical.( + Ref(Triangular()), canonical_coordinates, + ) + joint_pins = GadgetSearch._from_canonical.( + Ref(Triangular()), first.(joint_frame), + ) + directions = GadgetSearch._lattice_directions(Triangular()) + joint_rays = [findfirst(==(ray), directions) for ray in last.(joint_frame)] + joint_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), joint_solution, joint_pins, joint_rays, + ) + @test joint_analysis.solved + @test joint_analysis.offset == 10 + joint_cnf, joint_selected, joint_choices, joint_coordinates = + GadgetSearch._joint_crossing_sat_cnf( + target_reduced, Triangular(), (8,8), 28, 10, ) - directions = GadgetSearch._lattice_directions(Triangular()) - joint_rays = [findfirst(==(ray), directions) for ray in last.(joint_frame)] - joint_analysis = GadgetSearch._analyze_crossing_candidate( - target_reduced, Triangular(), joint_solution, joint_pins, joint_rays, + selected_coordinates = Set(joint_solution) + for (vertex, coordinate) in enumerate(joint_coordinates) + GadgetSearch._sat_clause!( + joint_cnf, coordinate in selected_coordinates ? + joint_selected[vertex] : -joint_selected[vertex], ) - @test joint_analysis.solved - @test joint_analysis.offset == 10 - joint_cnf, joint_selected, joint_choices, joint_coordinates = - GadgetSearch._joint_crossing_sat_cnf( - target_reduced, Triangular(), (8,8), 28, 10, - ) - selected_coordinates = Set(joint_solution) - for (vertex, coordinate) in enumerate(joint_coordinates) - GadgetSearch._sat_clause!( - joint_cnf, coordinate in selected_coordinates ? - joint_selected[vertex] : -joint_selected[vertex], - ) - end - for label in 1:4, - (vertex, direction, variable) in joint_choices[label] - chosen = joint_coordinates[vertex] == joint_pins[label] && - direction == joint_rays[label] - GadgetSearch._sat_clause!( - joint_cnf, chosen ? variable : -variable, - ) - end - status, _ = GadgetSearch._next_selected_assignment_limited!( - GadgetSearch._new_sat_solver(joint_cnf), joint_selected, 100_000, + end + for label in 1:4, + (vertex, direction, variable) in joint_choices[label] + chosen = joint_coordinates[vertex] == joint_pins[label] && + direction == joint_rays[label] + GadgetSearch._sat_clause!( + joint_cnf, chosen ? variable : -variable, ) - @test status == :sat + end + status, _ = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(joint_cnf), joint_selected, 100_000, + ) + @test status == :sat - cross23_sites = [ - (0,0),(3,-7),(0,-1),(-2,-1),(-1,-1),(0,-2),(-2,-2), - (0,-3),(1,-3),(2,-4),(-2,-3),(-1,-3),(1,-4),(2,-5), - (-2,-4),(-1,-4),(0,-5),(1,-5),(2,-6),(-1,-5),(0,-6), - (2,-7),(1,-7), - ] - cross23_pins = [(0,0),(-2,-4),(3,-7),(2,-4)] - cross23_rays = [1,4,6,1] - cross23_solution = GadgetSearch._from_canonical.( - Ref(Triangular()), cross23_sites, - ) - cross23_boundary = GadgetSearch._from_canonical.( - Ref(Triangular()), cross23_pins, + cross23_sites = [ + (0,0),(3,-7),(0,-1),(-2,-1),(-1,-1),(0,-2),(-2,-2), + (0,-3),(1,-3),(2,-4),(-2,-3),(-1,-3),(1,-4),(2,-5), + (-2,-4),(-1,-4),(0,-5),(1,-5),(2,-6),(-1,-5),(0,-6), + (2,-7),(1,-7), + ] + cross23_pins = [(0,0),(-2,-4),(3,-7),(2,-4)] + cross23_rays = [1,4,6,1] + cross23_solution = GadgetSearch._from_canonical.( + Ref(Triangular()), cross23_sites, + ) + cross23_boundary = GadgetSearch._from_canonical.( + Ref(Triangular()), cross23_pins, + ) + cross23_cnf, cross23_selected, cross23_choices, cross23_coordinates = + GadgetSearch._joint_crossing_sat_cnf( + target_reduced, Triangular(), (8,8), 23, 7; + canonical_shift=(-1,0), ) - cross23_cnf, cross23_selected, cross23_choices, cross23_coordinates = - GadgetSearch._joint_crossing_sat_cnf( - target_reduced, Triangular(), (8,8), 23, 7; - canonical_shift=(-1,0), - ) - selected_coordinates = Set(cross23_solution) - for (vertex, coordinate) in enumerate(cross23_coordinates) - GadgetSearch._sat_clause!( - cross23_cnf, coordinate in selected_coordinates ? - cross23_selected[vertex] : -cross23_selected[vertex], - ) - end - for label in 1:4, - (vertex, direction, variable) in cross23_choices[label] - chosen = cross23_coordinates[vertex] == cross23_boundary[label] && - direction == cross23_rays[label] - GadgetSearch._sat_clause!( - cross23_cnf, chosen ? variable : -variable, - ) - end - status, _ = GadgetSearch._next_selected_assignment_limited!( - GadgetSearch._new_sat_solver(cross23_cnf), cross23_selected, - 100_000, + selected_coordinates = Set(cross23_solution) + for (vertex, coordinate) in enumerate(cross23_coordinates) + GadgetSearch._sat_clause!( + cross23_cnf, coordinate in selected_coordinates ? + cross23_selected[vertex] : -cross23_selected[vertex], ) - @test status == :sat - cross23_analysis = GadgetSearch._analyze_crossing_candidate( - target_reduced, Triangular(), cross23_solution, - cross23_boundary, cross23_rays, + end + for label in 1:4, + (vertex, direction, variable) in cross23_choices[label] + chosen = cross23_coordinates[vertex] == cross23_boundary[label] && + direction == cross23_rays[label] + GadgetSearch._sat_clause!( + cross23_cnf, chosen ? variable : -variable, ) - @test cross23_analysis.solved - @test cross23_analysis.offset == 7 end + status, _ = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(cross23_cnf), cross23_selected, + 100_000, + ) + @test status == :sat + cross23_analysis = GadgetSearch._analyze_crossing_candidate( + target_reduced, Triangular(), cross23_solution, + cross23_boundary, cross23_rays, + ) + @test cross23_analysis.solved + @test cross23_analysis.offset == 7 shifted_target = map(value -> isfinite(value) ? value + 8 : value, target_reduced) negative_offset = GadgetSearch._solve_fixed_crossing_sat( shifted_target, Triangular(), frame, 23, -1, @@ -482,7 +495,8 @@ end )) optimized_cross_edge = optimize_unweighted_gadget( cross_edge, collect(1:4); - min_vertices=4, max_sat_evaluations=256, host_radius=1, + min_vertices=4, max_sat_evaluations=256, + max_sat_conflicts=100_000, host_radius=1, ) @test nv(optimized_cross_edge.gadget.replacement_graph) == 9 @test isempty(optimized_cross_edge.steps) @@ -528,26 +542,24 @@ end @test assignments == Set([ (false, false), (false, true), (true, false), (true, true), ]) - if !Sys.iswindows() - sat_cnf = GadgetSearch._SatCnf() - variable = GadgetSearch._sat_variable!(sat_cnf) - GadgetSearch._sat_clause!(sat_cnf, variable) - status, assignment = GadgetSearch._next_selected_assignment_limited!( - GadgetSearch._new_sat_solver(sat_cnf), [variable], 10, - ) - @test status == :sat - @test assignment == [true] + sat_cnf = GadgetSearch._SatCnf() + variable = GadgetSearch._sat_variable!(sat_cnf) + GadgetSearch._sat_clause!(sat_cnf, variable) + status, assignment = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(sat_cnf), [variable], 10, + ) + @test status == :sat + @test assignment == [true] - unsat_cnf = GadgetSearch._SatCnf() - variable = GadgetSearch._sat_variable!(unsat_cnf) - GadgetSearch._sat_clause!(unsat_cnf, variable) - GadgetSearch._sat_clause!(unsat_cnf, -variable) - status, assignment = GadgetSearch._next_selected_assignment_limited!( - GadgetSearch._new_sat_solver(unsat_cnf), [variable], 10, - ) - @test status == :unsat - @test assignment === nothing - end + unsat_cnf = GadgetSearch._SatCnf() + variable = GadgetSearch._sat_variable!(unsat_cnf) + GadgetSearch._sat_clause!(unsat_cnf, variable) + GadgetSearch._sat_clause!(unsat_cnf, -variable) + status, assignment = GadgetSearch._next_selected_assignment_limited!( + GadgetSearch._new_sat_solver(unsat_cnf), [variable], 10, + ) + @test status == :unsat + @test assignment === nothing end @testset "public bounded search" begin