From 0e9ceb638ffb6c30a08f131949707db25109a1e0 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 8 Aug 2026 12:58:24 +0800 Subject: [PATCH 1/5] Refactor unweighted search around dynamic lattice patches --- docs/make.jl | 1 + docs/src/unweighted_search.md | 82 +++++ examples/pr1_crossing_pipeline.jl | 62 ++-- src/GadgetSearch.jl | 3 + src/core/unweighted_search.jl | 519 ++++++++++++++++++++++++++---- test/core/unweighted_search.jl | 245 ++++++++------ 6 files changed, 713 insertions(+), 199 deletions(-) create mode 100644 docs/src/unweighted_search.md 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..65ef90e --- /dev/null +++ b/docs/src/unweighted_search.md @@ -0,0 +1,82 @@ +# Unweighted gadget search + +The unweighted search dynamically constructs concrete induced lattice patches. +Call it with `Square()` for KSG or `Triangular()` for the triangular lattice. It +does not enumerate subsets of a fixed rectangular canvas, and it never reports +an abstract graph without an embedding. + +## Search state + +A state consists of: + +- a finite set of integer lattice coordinates; +- an ordered list of pin coordinates. + +The induced graph and boundary vertex indices are derived from those coordinates. +The boundary order is part of the state, so two patches with the same pins in a +different logical order are evaluated separately. + +## Search loop + +`search_unweighted_gadgets` starts from small connected lattice animals and +repeats four steps until it exhausts the evaluation budget: + +1. Propose geometric edits: add, remove, or relocate a site; move or swap pins; + extend an arm by two sites; or locally split a crowded non-pin site. +2. Add fresh small lattice patches so the search does not depend on one lineage. +3. Rebuild the induced lattice graph and compute its reduced alpha tensor. +4. Keep the best-scoring states plus a random exploration fraction for the next + beam. + +The coordinate plane is unbounded. The patch grows only where an action adds a +site, so increasing the allowed vertex count does not create a rectangular +combinatorial search space. + +The ranking score is lexicographic: + +1. number of positions where only one tensor is infinite; +2. spread of the finite entry-wise offsets; +3. for four-pin states, whether the straight 1-3 and 2-4 pin segments cross; +4. vertex count; +5. edge count. + +The first two terms guide candidates toward the verifier contract. Port crossing +is only a preference among equal tensor scores, not an additional validity rule. +The last two terms prefer smaller candidates when the earlier terms tie. A score +of zero on the first two terms is still checked by `is_diff_by_constant`; the +ranking score never replaces the verifier. + +The default budget is 2,000 distinct tensor evaluations. Increase it only in a +controlled compute environment. Accepted candidates do not stop the run early: +the search keeps the best `max_results` embeddings so later mutations can improve +their port geometry. + +## Search trace + +The returned `UnweightedSearchResult.trace` contains one +`UnweightedSearchRecord` per distinct tensor evaluation. Each record stores: + +- the lattice type, occupied coordinates, ordered pin coordinates, graph6 state, + and derived boundary indices; +- the target graph6 state and target boundary in JSONL exports; +- its parent state and graph edit action; +- tensor-distance components; +- whether the state survived beam selection; +- whether the final verifier accepted it and, if so, the constant offset. + +This is a direct transition dataset for a later learned proposal or ranking +policy: the model can consume `(parent patch, geometric action, next patch, +score, selected, is_solution)` while the exact verifier remains unchanged. The +current implementation does not contain a machine-learning dependency. + +`save_unweighted_trace(path, result)` writes the same records as JSON Lines. This +keeps large future runs streamable and makes the trajectory directly consumable +from Python without serializing Julia graph objects. + +`UnweightedSearchResult.termination_reason` is `:solution`, `:budget`, or +`:search_space_exhausted`, so an empty result does not hide whether the configured +budget or the reachable candidate space ended the run. + +Every returned `UnweightedGadget` includes `lattice`, `lattice_coordinates`, and +physical `pos`. Rebuilding a unit-disk graph from `pos` reproduces the verified +replacement graph exactly. diff --git a/examples/pr1_crossing_pipeline.jl b/examples/pr1_crossing_pipeline.jl index bc2f2a9..35db6d2 100644 --- a/examples/pr1_crossing_pipeline.jl +++ b/examples/pr1_crossing_pipeline.jl @@ -1,15 +1,13 @@ # # PR1 Crossing Pipeline (line-by-line runnable) # -# This script demonstrates two parts of the unweighted crossing workflow: -# 1) logical flip utilities -# 2) search_unweighted_gadgets +# This script demonstrates the unweighted crossing search workflow. # # It is intentionally organized into small, single-purpose functions so users can # execute each section line by line in the REPL and inspect output immediately. using GadgetSearch using Graphs -using GenericTensorNetworks: content +using Random const OUTPUT_DIR = pkgdir(GadgetSearch, "examples", "pr1_pipeline_output") @@ -43,40 +41,30 @@ function plot_found_replacement(g::SimpleGraph{Int}) return path end -function run_flip_demo(target_graph::SimpleGraph{Int}, target_boundary::Vector{Int}) - println("\n=== Module: flip ===") - reduced = Float64.(content.(calculate_reduced_alpha_tensor(target_graph, target_boundary))) - patterns = generate_flip_patterns(length(target_boundary)) - println("Flip patterns: $(length(patterns))") - for (mask, desc) in patterns - flipped = apply_flip_to_tensor(reduced, mask) - finite_deltas = [f - b for (f, b) in zip(vec(flipped), vec(reduced)) if isfinite(f) && isfinite(b)] - example_delta = isempty(finite_deltas) ? "n/a" : string(first(finite_deltas)) - println(" $desc, mask=$mask, finite_delta_example=$example_delta") - end -end - -function build_loader_from_candidates(candidate_graphs::Vector{SimpleGraph{Int}}, target_boundary::Vector{Int}) - isempty(candidate_graphs) && throw(ArgumentError("candidate_graphs must be non-empty")) - dataset = GraphDataset(graph_to_g6.(candidate_graphs)) - return GraphLoader(dataset, pinset=target_boundary) -end - -function run_search_demo(target_graph::SimpleGraph{Int}, target_boundary::Vector{Int}, candidate_graphs::Vector{SimpleGraph{Int}}) +function run_search_demo(target_graph::SimpleGraph{Int}, target_boundary::Vector{Int}) println("\n=== Module: search ===") - loader = build_loader_from_candidates(candidate_graphs, target_boundary) - results = search_unweighted_gadgets( + report = search_unweighted_gadgets( target_graph, target_boundary, - loader; - include_logical_flips=true, - max_results=10, + Triangular(); + min_vertices=5, + max_vertices=11, + max_evaluations=2_000, + beam_width=48, + mutations_per_candidate=10, + random_candidates_per_generation=16, + max_results=4, + rng=MersenneTwister(2026), ) - println("Search hits: $(length(results))") - for (i, result) in enumerate(results) - println(" hit[$i]: boundary=$(result.boundary_vertices), offset=$(result.constant_offset), vertices=$(nv(result.replacement_graph))") + println("Evaluated: $(report.evaluated) candidates in $(report.generations) generations") + println("Termination: $(report.termination_reason)") + println("Best distance: mask mismatches=$(report.best_mask_mismatches), offset spread=$(report.best_offset_spread)") + println("Search hits: $(length(report.gadgets))") + for (i, result) in enumerate(report.gadgets) + println(" hit[$i]: lattice=$(result.lattice), coordinates=$(result.lattice_coordinates)") + println(" boundary=$(result.boundary_vertices), offset=$(result.constant_offset), vertices=$(nv(result.replacement_graph))") end - return results + return report end if abspath(PROGRAM_FILE) == @__FILE__ @@ -90,12 +78,10 @@ if abspath(PROGRAM_FILE) == @__FILE__ print_graph_summary("canonical", target_graph) plot_canonical_crossing(target_graph) - run_flip_demo(target_graph, target_boundary) - candidates = [target_graph] - results = run_search_demo(target_graph, target_boundary, candidates) + report = run_search_demo(target_graph, target_boundary) - if !isempty(results) - plot_found_replacement(results[1].replacement_graph) + if !isempty(report.gadgets) + plot_found_replacement(report.gadgets[1].replacement_graph) end println("\nDone. You can now inspect outputs in: $OUTPUT_DIR") diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 430f671..2e8d56f 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -72,6 +72,9 @@ export is_gadget_replacement # Unweighted search export UnweightedGadget +export UnweightedSearchResult +export UnweightedSearchRecord export search_unweighted_gadgets +export save_unweighted_trace end # module diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index dd28596..612efe3 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -1,90 +1,489 @@ # ============================================================================ -# 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}} + port_crossing_penalty::Int end -# ============================================================================ -# Unweighted Filter Construction -# ============================================================================ +"""One evaluated transition of the dynamically constructed lattice patch.""" +struct UnweightedSearchRecord + generation::Int + key::String + lattice::Symbol + lattice_coordinates::Vector{_LatticeCoordinate} + pin_coordinates::Vector{_LatticeCoordinate} + graph6::String + boundary_vertices::Vector{Int} + parent_key::Union{Nothing, String} + action::Symbol + vertices::Int + edges::Int + mask_mismatches::Int + offset_spread::Float64 + port_crossing_penalty::Int + is_solution::Bool + constant_offset::Union{Nothing, Float64} + selected::Bool +end + +"""Outcome of a bounded dynamic search on one concrete lattice.""" +struct UnweightedSearchResult + target_graph::SimpleGraph{Int} + target_boundary::Vector{Int} + lattice::Symbol + gadgets::Vector{UnweightedGadget} + evaluated::Int + generations::Int + best_mask_mismatches::Int + best_offset_spread::Float64 + termination_reason::Symbol + trace::Vector{UnweightedSearchRecord} +end + +struct _LatticePatch + coordinates::Vector{_LatticeCoordinate} + pins::Vector{_LatticeCoordinate} +end + +struct _UnweightedProposal + patch::_LatticePatch + parent_key::Union{Nothing, String} + action::Symbol +end + +struct _UnweightedEvaluation + proposal::_UnweightedProposal + key::String + graph::SimpleGraph{Int} + boundary::Vector{Int} + score::Tuple{Int, Float64, Int, Int, Int} + valid::Bool + constant_offset::Float64 +end """ - _make_unweighted_filter(pattern_graph, pattern_boundary; prefilter) + search_unweighted_gadgets(target_graph, target_boundary, lattice; kwargs...) -Build a filter closure that checks candidate graphs against the target pattern. +Dynamically grow and reshape an induced patch of `Square()` (KSG) or +`Triangular()`. There is no fixed canvas and no abstract-graph stage: every +evaluated state is an explicit lattice coordinate set, and its edges are derived +from the selected lattice sites. Two-site arm extension and crowded-site split +are proposal moves, while the existing reduced-alpha-tensor verifier remains the +only acceptance criterion. The full evaluation budget is used so later accepted +states can improve the soft four-port crossing preference. """ -function _make_unweighted_filter( - pattern_graph::SimpleGraph{Int}, - pattern_boundary::Vector{Int}; - prefilter::Bool=true, +function search_unweighted_gadgets( + target_graph::SimpleGraph{Int}, + target_boundary::Vector{Int}, + lattice::LatticeType; + min_vertices::Int=length(target_boundary) + 1, + max_vertices::Int=min_vertices + 8, + max_evaluations::Int=2_000, + beam_width::Int=32, + mutations_per_candidate::Int=8, + random_candidates_per_generation::Int=8, + exploration_fraction::Float64=0.25, + max_results::Int=1, + rng::AbstractRNG=Random.default_rng(), ) - k = length(pattern_boundary) - target_reduced = vec(calculate_reduced_alpha_tensor(pattern_graph, pattern_boundary)) + boundary_count = length(target_boundary) + 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")) + beam_width > 0 || throw(ArgumentError("beam_width must be positive")) + mutations_per_candidate > 0 || throw(ArgumentError("mutations_per_candidate must be positive")) + random_candidates_per_generation > 0 || throw(ArgumentError("random_candidates_per_generation must be positive")) + 0.0 <= exploration_fraction < 1.0 || throw(ArgumentError("exploration_fraction must be in [0, 1)")) + max_results > 0 || throw(ArgumentError("max_results must be positive")) + + 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") - 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 + + initial_max = min(max_vertices, min_vertices + 2) + beam = [_random_lattice_patch(rng, lattice, boundary_count, min_vertices, initial_max) for _ in 1:beam_width] + cache = Dict{String, Tuple{Tuple{Int, Float64, Int, Int, Int}, Float64, Bool}}() + gadgets = UnweightedGadget[] + gadget_keys = Set{String}() + evaluated = 0 + generations = 0 + best_score = (typemax(Int), Inf, typemax(Int), typemax(Int), typemax(Int)) + trace = UnweightedSearchRecord[] + + while evaluated < max_evaluations + generations += 1 + evaluated_before_generation = evaluated + pool = [_UnweightedProposal(patch, nothing, generations == 1 ? :seed : :retained) for patch in beam] + for patch in beam, _ in 1:mutations_per_candidate + mutated, action = _mutate_lattice_patch(rng, lattice, patch, min_vertices, max_vertices) + push!(pool, _UnweightedProposal(mutated, _lattice_patch_key(lattice, patch), action)) 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) + for _ in 1:random_candidates_per_generation + restart = _random_lattice_patch(rng, lattice, boundary_count, min_vertices, initial_max) + push!(pool, _UnweightedProposal(restart, nothing, :restart)) + end + + ranked = Tuple{_LatticePatch, Tuple{Int, Float64, Int, Int, Int}}[] + ranked_keys = Set{String}() + generation_evaluations = _UnweightedEvaluation[] + for proposal in pool + key = _lattice_patch_key(lattice, proposal.patch) + key in ranked_keys && continue + push!(ranked_keys, key) + graph, boundary, positions = _materialize_lattice_patch(lattice, proposal.patch) + if !haskey(cache, key) + evaluated == max_evaluations && break + candidate_reduced = vec(calculate_reduced_alpha_tensor(graph, boundary)) + valid, constant_offset = is_diff_by_constant(candidate_reduced, target_reduced) + port_crossing_penalty = _port_crossing_penalty(positions, boundary) + score = _unweighted_tensor_distance( + candidate_reduced, target_reduced, graph, port_crossing_penalty, + ) + cache[key] = (score, Float64(constant_offset), valid) + push!(generation_evaluations, _UnweightedEvaluation( + proposal, key, graph, boundary, score, valid, Float64(constant_offset), + )) + evaluated += 1 end + + score, constant_offset, valid = cache[key] + best_score = min(best_score, score) + push!(ranked, (proposal.patch, score)) + if valid && !(key in gadget_keys) + push!(gadget_keys, key) + push!(gadgets, UnweightedGadget( + target_graph, + graph, + boundary, + constant_offset, + _lattice_symbol(lattice), + copy(proposal.patch.coordinates), + positions, + score[3], + )) + sort!(gadgets; by=gadget -> ( + gadget.port_crossing_penalty, + nv(gadget.replacement_graph), + ne(gadget.replacement_graph), + )) + resize!(gadgets, min(length(gadgets), max_results)) + end + end + + sort!(ranked; by=last) + beam = _select_unweighted_beam(rng, ranked, beam_width, exploration_fraction) + selected_keys = Set(_lattice_patch_key(lattice, patch) for patch in beam) + for item in generation_evaluations + patch = item.proposal.patch + push!(trace, UnweightedSearchRecord( + generations, + item.key, + _lattice_symbol(lattice), + copy(patch.coordinates), + copy(patch.pins), + graph_to_g6(item.graph), + copy(item.boundary), + item.proposal.parent_key, + item.proposal.action, + nv(item.graph), + ne(item.graph), + item.score[1], + item.score[2], + item.score[3], + item.valid, + item.valid ? item.constant_offset : nothing, + item.key in selected_keys, + )) end - return nothing + evaluated == evaluated_before_generation && break + end + + termination_reason = if !isempty(gadgets) + :solution + elseif evaluated == max_evaluations + :budget + else + :search_space_exhausted end + + return UnweightedSearchResult( + target_graph, + copy(target_boundary), + _lattice_symbol(lattice), + gadgets, + evaluated, + generations, + best_score[1], + best_score[2], + termination_reason, + trace, + ) end -# ============================================================================ -# Unweighted Search -# ============================================================================ +function _select_unweighted_beam( + rng::AbstractRNG, + ranked::Vector{Tuple{_LatticePatch, Tuple{Int, Float64, Int, Int, Int}}}, + beam_width::Int, + exploration_fraction::Float64, +) + selected_count = min(beam_width, length(ranked)) + selected_count == 0 && return _LatticePatch[] + exploration_count = min(floor(Int, selected_count * exploration_fraction), selected_count - 1) + elite_count = selected_count - exploration_count + selected = collect(Iterators.take(ranked, elite_count)) + if exploration_count > 0 + remaining = @view ranked[elite_count+1:end] + chosen = randperm(rng, length(remaining))[1:exploration_count] + append!(selected, remaining[chosen]) + end + return [patch for (patch, _) in selected] +end -""" - search_unweighted_gadgets(target_graph, target_boundary, loader; kwargs...) +function _random_lattice_patch( + rng::AbstractRNG, + lattice::LatticeType, + boundary_count::Int, + min_vertices::Int, + max_vertices::Int, +) + target_size = rand(rng, min_vertices:max_vertices) + occupied = Set{_LatticeCoordinate}([(0, 0)]) + while length(occupied) < target_size + push!(occupied, rand(rng, _lattice_frontier(lattice, occupied))) + end + coordinates = sort!(collect(occupied)) + pins = coordinates[randperm(rng, length(coordinates))[1:boundary_count]] + return _normalize_lattice_patch(lattice, coordinates, pins) +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 _mutate_lattice_patch( + rng::AbstractRNG, + lattice::LatticeType, + patch::_LatticePatch, + min_vertices::Int, + max_vertices::Int, ) - 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 + operations = Symbol[:move_pin, :swap_pins] + length(patch.coordinates) < max_vertices && push!(operations, :add_site) + length(patch.coordinates) + 2 <= max_vertices && push!(operations, :extend_arm) + removable = setdiff(patch.coordinates, patch.pins) + if !isempty(removable) + length(patch.coordinates) > min_vertices && push!(operations, :remove_site) + push!(operations, :relocate_site) + length(patch.coordinates) < max_vertices && push!(operations, :split_crowded_site) + end + + for _ in 1:16 + action = rand(rng, operations) + mutated = _apply_lattice_action(rng, lattice, patch, action, removable) + mutated === nothing && continue + normalized = _normalize_lattice_patch(lattice, mutated.coordinates, mutated.pins) + _lattice_patch_key(lattice, normalized) != _lattice_patch_key(lattice, patch) && + return normalized, action + end + return patch, :rejected_edit +end + +function _apply_lattice_action( + rng::AbstractRNG, + lattice::LatticeType, + patch::_LatticePatch, + action::Symbol, + removable::Vector{_LatticeCoordinate}, +) + occupied = Set(patch.coordinates) + if action == :add_site + coordinates = [patch.coordinates; rand(rng, _lattice_frontier(lattice, occupied))] + return _LatticePatch(coordinates, copy(patch.pins)) + elseif action == :remove_site + removed = rand(rng, removable) + coordinates = setdiff(patch.coordinates, [removed]) + return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins)) : nothing + elseif action == :relocate_site + removed = rand(rng, removable) + coordinates = setdiff(patch.coordinates, [removed]) + isempty(coordinates) && return nothing + moved = rand(rng, _lattice_frontier(lattice, Set(coordinates))) + relocated = [coordinates; moved] + return _connected_lattice_patch(lattice, relocated) ? _LatticePatch(relocated, copy(patch.pins)) : nothing + elseif action == :extend_arm + base = rand(rng, patch.coordinates) + direction = rand(rng, _lattice_directions(lattice)) + first = _lattice_step(lattice, base, direction, 1) + second = _lattice_step(lattice, base, direction, 2) + (first in occupied || second in occupied) && return nothing + return _LatticePatch([patch.coordinates; first; second], copy(patch.pins)) + elseif action == :split_crowded_site + graph, _, _ = _materialize_lattice_patch(lattice, patch) + coordinate_index = Dict(coordinate => index for (index, coordinate) in enumerate(patch.coordinates)) + crowded = [coordinate for coordinate in removable if degree(graph, coordinate_index[coordinate]) >= 3] + isempty(crowded) && return nothing + removed = rand(rng, crowded) + empty_neighbors = setdiff(_lattice_neighbors(lattice, removed), patch.coordinates) + length(empty_neighbors) < 2 && return nothing + chosen = empty_neighbors[randperm(rng, length(empty_neighbors))[1:2]] + coordinates = [setdiff(patch.coordinates, [removed]); chosen] + return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins)) : nothing + elseif action == :move_pin + choices = setdiff(patch.coordinates, patch.pins) + isempty(choices) && return nothing + pins = copy(patch.pins) + pins[rand(rng, eachindex(pins))] = rand(rng, choices) + return _LatticePatch(copy(patch.coordinates), pins) + else + length(patch.pins) < 2 && return nothing + first, second = randperm(rng, length(patch.pins))[1:2] + pins = copy(patch.pins) + pins[first], pins[second] = pins[second], pins[first] + return _LatticePatch(copy(patch.coordinates), pins) + end +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 _connected_lattice_patch(lattice::LatticeType, coordinates::Vector{_LatticeCoordinate}) + positions = get_physical_positions(lattice, sort(coordinates)) + return is_connected(unit_disk_graph(positions, get_radius(lattice))) +end + +function _normalize_lattice_patch( + ::Square, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, +) + 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)) +end + +function _normalize_lattice_patch( + ::Triangular, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, +) + 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) +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_neighbors(lattice::LatticeType, point::_LatticeCoordinate) = + [_lattice_step(lattice, point, direction, 1) for direction in _lattice_directions(lattice)] + +function _lattice_frontier(lattice::LatticeType, occupied::Set{_LatticeCoordinate}) + frontier = Set{_LatticeCoordinate}() + for point in occupied, neighbor in _lattice_neighbors(lattice, point) + neighbor in occupied || push!(frontier, neighbor) + end + return collect(frontier) +end + +_lattice_symbol(::Square) = :KSG +_lattice_symbol(::Triangular) = :triangular + +function _lattice_patch_key(lattice::LatticeType, patch::_LatticePatch) + coordinates = join(("$(x),$(y)" for (x, y) in patch.coordinates), ';') + pins = join(("$(x),$(y)" for (x, y) in patch.pins), ';') + return string(_lattice_symbol(lattice), ':', coordinates, '|', pins) +end + +function _unweighted_tensor_distance( + candidate::AbstractArray, + target::AbstractArray, + graph::SimpleGraph, + port_crossing_penalty::Int, +) + mask_mismatches = count(isinf(a) != isinf(b) for (a, b) in zip(candidate, target)) + differences = [a - b for (a, b) in zip(candidate, target) if isfinite(a) && isfinite(b)] + offset_spread = Float64(maximum(differences) - minimum(differences)) + return mask_mismatches, offset_spread, port_crossing_penalty, nv(graph), ne(graph) +end + +function _port_crossing_penalty( + positions::Vector{Tuple{Float64, Float64}}, + boundary::Vector{Int}, +) + length(boundary) == 4 || return 0 + first_start, second_start, first_end, second_end = positions[boundary] + orientation(a, b, c) = + (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]) + first_side = orientation(first_start, first_end, second_start) * + orientation(first_start, first_end, second_end) + second_side = orientation(second_start, second_end, first_start) * + orientation(second_start, second_end, first_end) + return first_side < 0 && second_side < 0 ? 0 : 1 +end + +"""Write the self-contained lattice search trajectory as JSON Lines.""" +function save_unweighted_trace(path::AbstractString, result::UnweightedSearchResult) + target_graph6 = graph_to_g6(result.target_graph) + open(path, "w") do io + for record in result.trace + JSON3.write(io, ( + target_graph6=target_graph6, + target_boundary=result.target_boundary, + lattice=String(record.lattice), + generation=record.generation, + key=record.key, + lattice_coordinates=record.lattice_coordinates, + pin_coordinates=record.pin_coordinates, + graph6=record.graph6, + boundary_vertices=record.boundary_vertices, + parent_key=record.parent_key, + action=String(record.action), + vertices=record.vertices, + edges=record.edges, + mask_mismatches=record.mask_mismatches, + offset_spread=record.offset_spread, + port_crossing_penalty=record.port_crossing_penalty, + is_solution=record.is_solution, + constant_offset=record.constant_offset, + selected=record.selected, + )) + write(io, '\n') + end end - return results + return String(path) end # ============================================================================ diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 3a7fa70..1d4aa5e 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -1,128 +1,171 @@ using GadgetSearch using Graphs +using JSON3 +using Random using Test function _cross_graph() - g = SimpleGraph(4) - add_edge!(g, 1, 3) - add_edge!(g, 2, 4) - return g + 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 +function _reconstruct_record(lattice, record) + positions = GadgetSearch.get_physical_positions(lattice, record.lattice_coordinates) + graph = GadgetSearch.unit_disk_graph(positions, get_radius(lattice)) + indices = Dict(coordinate => index for (index, coordinate) in enumerate(record.lattice_coordinates)) + boundary = [indices[pin] for pin in record.pin_coordinates] + return graph, boundary end -function _edge_graph() - g = SimpleGraph(2) - add_edge!(g, 1, 2) - return g -end +@testset "Unweighted Search" begin + @testset "every state is a concrete induced lattice patch" begin + for lattice in (Square(), Triangular()) + target = SimpleGraph(1) + report = search_unweighted_gadgets( + target, + [1], + lattice; + min_vertices=3, + max_vertices=5, + max_evaluations=50, + beam_width=4, + mutations_per_candidate=3, + random_candidates_per_generation=2, + rng=MersenneTwister(12), + ) -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 + @test report isa UnweightedSearchResult + @test report.lattice == (lattice isa Square ? :KSG : :triangular) + @test report.target_graph == target + @test report.target_boundary == [1] + @test !isempty(report.gadgets) + @test report.evaluated <= 50 + @test length(report.trace) == report.evaluated -function _isolated_graph() - g = SimpleGraph(3) - add_edge!(g, 1, 2) - return g -end + for record in report.trace + graph, boundary = _reconstruct_record(lattice, record) + @test graph_to_g6(graph) == record.graph6 + @test boundary == record.boundary_vertices + @test is_connected(graph) + end -function _to_g6(g) - return graph_to_g6(g) -end + gadget = only(report.gadgets) + reconstructed = GadgetSearch.unit_disk_graph(gadget.pos, get_radius(lattice)) + @test reconstructed == gadget.replacement_graph + @test any(record -> + record.lattice_coordinates == gadget.lattice_coordinates && + record.boundary_vertices == gadget.boundary_vertices, + report.trace, + ) + @test is_gadget_replacement( + target, + gadget.replacement_graph, + [1], + gadget.boundary_vertices, + ) == (true, gadget.constant_offset) + end + 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 "dynamically finds embedded crossing-equivalent patches" begin + target = _cross_graph() + for (lattice, seed) in ((Square(), 2026), (Triangular(), 2027)) + report = search_unweighted_gadgets( + target, + [1, 2, 3, 4], + lattice; + min_vertices=5, + max_vertices=17, + max_evaluations=1_000, + beam_width=32, + mutations_per_candidate=8, + random_candidates_per_generation=8, + rng=MersenneTwister(seed), + ) + + @test !isempty(report.gadgets) + @test report.termination_reason == :solution + gadget = only(report.gadgets) + @test nv(gadget.replacement_graph) > nv(target) + @test is_connected(gadget.replacement_graph) + @test length(unique(gadget.boundary_vertices)) == 4 + @test gadget.port_crossing_penalty in (0, 1) + @test is_gadget_replacement( + target, + gadget.replacement_graph, + [1, 2, 3, 4], + gadget.boundary_vertices, + ) == (true, gadget.constant_offset) + @test any(record -> record.parent_key !== nothing, report.trace) + @test all(record -> record.lattice == report.lattice, report.trace) + end 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 "records self-contained dynamic transitions" begin + report = search_unweighted_gadgets( + _cross_graph(), + [1, 2, 3, 4], + Triangular(); + min_vertices=5, + max_vertices=9, + max_evaluations=80, + beam_width=6, + mutations_per_candidate=6, + random_candidates_per_generation=3, + max_results=4, + rng=MersenneTwister(8), ) - 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 any(record -> record.action == :extend_arm, report.trace) + @test any(record -> record.action == :split_crowded_site, report.trace) + evaluated_keys = Set(record.key for record in report.trace) + @test all( + record.parent_key === nothing || record.parent_key in evaluated_keys + for record in report.trace + ) + + path = tempname() + try + @test save_unweighted_trace(path, report) == path + rows = JSON3.read.(readlines(path)) + @test length(rows) == report.evaluated + @test rows[1].target_graph6 == graph_to_g6(report.target_graph) + @test rows[1].lattice == "triangular" + @test Tuple.(rows[1].lattice_coordinates) == report.trace[1].lattice_coordinates + @test Tuple.(rows[1].pin_coordinates) == report.trace[1].pin_coordinates + finally + isfile(path) && rm(path) + end end - @testset "UnweightedGadget has no target_index" begin - @test !(:target_index in fieldnames(UnweightedGadget)) + @testset "validates the explicit search budget" begin + target = SimpleGraph(1) + @test_throws ArgumentError search_unweighted_gadgets( + target, + [1], + Square(); + min_vertices=2, + max_vertices=1, + ) + @test_throws ArgumentError search_unweighted_gadgets( + target, + [1], + Square(); + max_evaluations=0, + ) + @test_throws ArgumentError search_unweighted_gadgets( + target, + [1], + Square(); + exploration_fraction=1.0, + ) end - @testset "inf_mask (internal)" begin + @testset "verifier behavior remains covered" 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) 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 "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 - end end From fe3059a4dc42793cf583bda8e1f504c1e535f0bd Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 8 Aug 2026 13:14:20 +0800 Subject: [PATCH 2/5] Require complete crossing frame geometry --- docs/src/unweighted_search.md | 29 ++-- examples/pr1_crossing_pipeline.jl | 3 +- src/GadgetSearch.jl | 1 + src/core/unweighted_search.jl | 263 +++++++++++++++++++++++++----- test/core/unweighted_search.jl | 57 +++++-- 5 files changed, 282 insertions(+), 71 deletions(-) diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 65ef90e..b0ab285 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -10,7 +10,8 @@ an abstract graph without an embedding. A state consists of: - a finite set of integer lattice coordinates; -- an ordered list of pin coordinates. +- an ordered list of pin coordinates; +- one outward lattice ray for each pin. The induced graph and boundary vertex indices are derived from those coordinates. The boundary order is part of the state, so two patches with the same pins in a @@ -22,7 +23,8 @@ different logical order are evaluated separately. repeats four steps until it exhausts the evaluation budget: 1. Propose geometric edits: add, remove, or relocate a site; move or swap pins; - extend an arm by two sites; or locally split a crowded non-pin site. + change a pin ray; extend an arm by two sites; or locally split a crowded + non-pin site. 2. Add fresh small lattice patches so the search does not depend on one lineage. 3. Rebuild the induced lattice graph and compute its reduced alpha tensor. 4. Keep the best-scoring states plus a random exploration fraction for the next @@ -36,28 +38,29 @@ The ranking score is lexicographic: 1. number of positions where only one tensor is infinite; 2. spread of the finite entry-wise offsets; -3. for four-pin states, whether the straight 1-3 and 2-4 pin segments cross; +3. number of failed crossing-frame conditions G1-G4; 4. vertex count; 5. edge count. -The first two terms guide candidates toward the verifier contract. Port crossing -is only a preference among equal tensor scores, not an additional validity rule. -The last two terms prefer smaller candidates when the earlier terms tie. A score -of zero on the first two terms is still checked by `is_diff_by_constant`; the -ranking score never replaces the verifier. +The first two terms guide candidates toward the verifier contract. For a four-pin +search, a result is returned only when all four geometric conditions also pass: +the interfaces are strict convex-hull vertices (G1), the two channels alternate +around the hull (G2), every ray points outward (G3), and the infinite exterior +corridors are empty, touch only their own pin, and are pairwise non-adjacent +(G4). The last two terms prefer smaller candidates when the earlier terms tie. +The reduced-alpha score never replaces `is_diff_by_constant`. The default budget is 2,000 distinct tensor evaluations. Increase it only in a -controlled compute environment. Accepted candidates do not stop the run early: -the search keeps the best `max_results` embeddings so later mutations can improve -their port geometry. +controlled compute environment. Accepted candidates do not stop the run early; +the search keeps the best `max_results` complete crossing frames. ## Search trace The returned `UnweightedSearchResult.trace` contains one `UnweightedSearchRecord` per distinct tensor evaluation. Each record stores: -- the lattice type, occupied coordinates, ordered pin coordinates, graph6 state, - and derived boundary indices; +- the lattice type, occupied coordinates, ordered pin coordinates and rays, + graph6 state, and derived boundary indices; - the target graph6 state and target boundary in JSONL exports; - its parent state and graph edit action; - tensor-distance components; diff --git a/examples/pr1_crossing_pipeline.jl b/examples/pr1_crossing_pipeline.jl index 35db6d2..1547fec 100644 --- a/examples/pr1_crossing_pipeline.jl +++ b/examples/pr1_crossing_pipeline.jl @@ -62,7 +62,8 @@ function run_search_demo(target_graph::SimpleGraph{Int}, target_boundary::Vector println("Search hits: $(length(report.gadgets))") for (i, result) in enumerate(report.gadgets) println(" hit[$i]: lattice=$(result.lattice), coordinates=$(result.lattice_coordinates)") - println(" boundary=$(result.boundary_vertices), offset=$(result.constant_offset), vertices=$(nv(result.replacement_graph))") + println(" boundary=$(result.boundary_vertices), rays=$(result.pin_rays)") + println(" offset=$(result.constant_offset), vertices=$(nv(result.replacement_graph))") end return report end diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 2e8d56f..4ae42c7 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -76,5 +76,6 @@ export UnweightedSearchResult export UnweightedSearchRecord export search_unweighted_gadgets export save_unweighted_trace +export check_crossing_frame end # module diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index 612efe3..1403f18 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -13,7 +13,7 @@ struct UnweightedGadget lattice::Symbol lattice_coordinates::Vector{_LatticeCoordinate} pos::Vector{Tuple{Float64, Float64}} - port_crossing_penalty::Int + pin_rays::Vector{_LatticeCoordinate} end """One evaluated transition of the dynamically constructed lattice patch.""" @@ -23,6 +23,7 @@ struct UnweightedSearchRecord lattice::Symbol lattice_coordinates::Vector{_LatticeCoordinate} pin_coordinates::Vector{_LatticeCoordinate} + pin_rays::Vector{_LatticeCoordinate} graph6::String boundary_vertices::Vector{Int} parent_key::Union{Nothing, String} @@ -31,7 +32,7 @@ struct UnweightedSearchRecord edges::Int mask_mismatches::Int offset_spread::Float64 - port_crossing_penalty::Int + frame_violations::Int is_solution::Bool constant_offset::Union{Nothing, Float64} selected::Bool @@ -54,6 +55,7 @@ end struct _LatticePatch coordinates::Vector{_LatticeCoordinate} pins::Vector{_LatticeCoordinate} + rays::Vector{Int} end struct _UnweightedProposal @@ -80,8 +82,8 @@ Dynamically grow and reshape an induced patch of `Square()` (KSG) or evaluated state is an explicit lattice coordinate set, and its edges are derived from the selected lattice sites. Two-site arm extension and crowded-site split are proposal moves, while the existing reduced-alpha-tensor verifier remains the -only acceptance criterion. The full evaluation budget is used so later accepted -states can improve the soft four-port crossing preference. +only logical acceptance criterion. Four-pin searches additionally require the +complete G1-G4 crossing-frame geometry. """ function search_unweighted_gadgets( target_graph::SimpleGraph{Int}, @@ -145,9 +147,10 @@ function search_unweighted_gadgets( evaluated == max_evaluations && break candidate_reduced = vec(calculate_reduced_alpha_tensor(graph, boundary)) valid, constant_offset = is_diff_by_constant(candidate_reduced, target_reduced) - port_crossing_penalty = _port_crossing_penalty(positions, boundary) + frame = _check_crossing_frame(lattice, proposal.patch) + frame_violations = count(!, frame) score = _unweighted_tensor_distance( - candidate_reduced, target_reduced, graph, port_crossing_penalty, + candidate_reduced, target_reduced, graph, frame_violations, ) cache[key] = (score, Float64(constant_offset), valid) push!(generation_evaluations, _UnweightedEvaluation( @@ -159,7 +162,7 @@ function search_unweighted_gadgets( score, constant_offset, valid = cache[key] best_score = min(best_score, score) push!(ranked, (proposal.patch, score)) - if valid && !(key in gadget_keys) + if valid && score[3] == 0 && !(key in gadget_keys) push!(gadget_keys, key) push!(gadgets, UnweightedGadget( target_graph, @@ -169,10 +172,9 @@ function search_unweighted_gadgets( _lattice_symbol(lattice), copy(proposal.patch.coordinates), positions, - score[3], + _patch_ray_directions(lattice, proposal.patch), )) sort!(gadgets; by=gadget -> ( - gadget.port_crossing_penalty, nv(gadget.replacement_graph), ne(gadget.replacement_graph), )) @@ -191,6 +193,7 @@ function search_unweighted_gadgets( _lattice_symbol(lattice), copy(patch.coordinates), copy(patch.pins), + _patch_ray_directions(lattice, patch), graph_to_g6(item.graph), copy(item.boundary), item.proposal.parent_key, @@ -200,8 +203,8 @@ function search_unweighted_gadgets( item.score[1], item.score[2], item.score[3], - item.valid, - item.valid ? item.constant_offset : nothing, + item.valid && item.score[3] == 0, + item.valid && item.score[3] == 0 ? item.constant_offset : nothing, item.key in selected_keys, )) end @@ -263,7 +266,8 @@ function _random_lattice_patch( end coordinates = sort!(collect(occupied)) pins = coordinates[randperm(rng, length(coordinates))[1:boundary_count]] - return _normalize_lattice_patch(lattice, coordinates, pins) + rays = rand(rng, eachindex(_lattice_directions(lattice)), boundary_count) + return _normalize_lattice_patch(lattice, coordinates, pins, rays) end function _mutate_lattice_patch( @@ -273,7 +277,7 @@ function _mutate_lattice_patch( min_vertices::Int, max_vertices::Int, ) - operations = Symbol[:move_pin, :swap_pins] + operations = Symbol[:move_pin, :swap_pins, :change_ray] length(patch.coordinates) < max_vertices && push!(operations, :add_site) length(patch.coordinates) + 2 <= max_vertices && push!(operations, :extend_arm) removable = setdiff(patch.coordinates, patch.pins) @@ -287,7 +291,7 @@ function _mutate_lattice_patch( action = rand(rng, operations) mutated = _apply_lattice_action(rng, lattice, patch, action, removable) mutated === nothing && continue - normalized = _normalize_lattice_patch(lattice, mutated.coordinates, mutated.pins) + normalized = _normalize_lattice_patch(lattice, mutated.coordinates, mutated.pins, mutated.rays) _lattice_patch_key(lattice, normalized) != _lattice_patch_key(lattice, patch) && return normalized, action end @@ -304,25 +308,25 @@ function _apply_lattice_action( occupied = Set(patch.coordinates) if action == :add_site coordinates = [patch.coordinates; rand(rng, _lattice_frontier(lattice, occupied))] - return _LatticePatch(coordinates, copy(patch.pins)) + return _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) elseif action == :remove_site removed = rand(rng, removable) coordinates = setdiff(patch.coordinates, [removed]) - return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins)) : nothing + return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) : nothing elseif action == :relocate_site removed = rand(rng, removable) coordinates = setdiff(patch.coordinates, [removed]) isempty(coordinates) && return nothing moved = rand(rng, _lattice_frontier(lattice, Set(coordinates))) relocated = [coordinates; moved] - return _connected_lattice_patch(lattice, relocated) ? _LatticePatch(relocated, copy(patch.pins)) : nothing + return _connected_lattice_patch(lattice, relocated) ? _LatticePatch(relocated, copy(patch.pins), copy(patch.rays)) : nothing elseif action == :extend_arm base = rand(rng, patch.coordinates) direction = rand(rng, _lattice_directions(lattice)) first = _lattice_step(lattice, base, direction, 1) second = _lattice_step(lattice, base, direction, 2) (first in occupied || second in occupied) && return nothing - return _LatticePatch([patch.coordinates; first; second], copy(patch.pins)) + return _LatticePatch([patch.coordinates; first; second], copy(patch.pins), copy(patch.rays)) elseif action == :split_crowded_site graph, _, _ = _materialize_lattice_patch(lattice, patch) coordinate_index = Dict(coordinate => index for (index, coordinate) in enumerate(patch.coordinates)) @@ -333,19 +337,27 @@ function _apply_lattice_action( length(empty_neighbors) < 2 && return nothing chosen = empty_neighbors[randperm(rng, length(empty_neighbors))[1:2]] coordinates = [setdiff(patch.coordinates, [removed]); chosen] - return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins)) : nothing + return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) : nothing elseif action == :move_pin choices = setdiff(patch.coordinates, patch.pins) isempty(choices) && return nothing pins = copy(patch.pins) pins[rand(rng, eachindex(pins))] = rand(rng, choices) - return _LatticePatch(copy(patch.coordinates), pins) - else + return _LatticePatch(copy(patch.coordinates), pins, copy(patch.rays)) + elseif action == :swap_pins length(patch.pins) < 2 && return nothing first, second = randperm(rng, length(patch.pins))[1:2] pins = copy(patch.pins) pins[first], pins[second] = pins[second], pins[first] - return _LatticePatch(copy(patch.coordinates), pins) + rays = copy(patch.rays) + rays[first], rays[second] = rays[second], rays[first] + return _LatticePatch(copy(patch.coordinates), pins, rays) + else + rays = copy(patch.rays) + slot = rand(rng, eachindex(rays)) + choices = setdiff(eachindex(_lattice_directions(lattice)), [rays[slot]]) + rays[slot] = rand(rng, choices) + return _LatticePatch(copy(patch.coordinates), copy(patch.pins), rays) end end @@ -366,17 +378,19 @@ 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)) + 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] @@ -385,7 +399,7 @@ function _normalize_lattice_patch( 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) + return _LatticePatch(sort!(translated), translated_pins, copy(rays)) end _offset_to_axial(point::_LatticeCoordinate) = (point[1] - fld(point[2], 2), point[2]) @@ -424,34 +438,196 @@ _lattice_symbol(::Triangular) = :triangular function _lattice_patch_key(lattice::LatticeType, patch::_LatticePatch) coordinates = join(("$(x),$(y)" for (x, y) in patch.coordinates), ';') pins = join(("$(x),$(y)" for (x, y) in patch.pins), ';') - return string(_lattice_symbol(lattice), ':', coordinates, '|', pins) + return string(_lattice_symbol(lattice), ':', coordinates, '|', pins, '|', join(patch.rays, ',')) +end + +_patch_ray_directions(lattice::LatticeType, patch::_LatticePatch) = + _lattice_directions(lattice)[patch.rays] + +""" + 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}, +) + 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")) + 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) +_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 + 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 + push!(upper, point) + end + return [lower[1:end-1]; upper[1:end-1]] +end + +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 + +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 + +function _corridors_are_clear( + lattice::LatticeType, + coordinates::Vector{_LatticeCoordinate}, + pins::Vector{_LatticeCoordinate}, + interfaces::Vector{_LatticeCoordinate}, + directions::Vector{_LatticeCoordinate}, +) + 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 + multiple = _direction_multiple(right, direction1) + return multiple !== nothing && multiple >= 0 end function _unweighted_tensor_distance( candidate::AbstractArray, target::AbstractArray, graph::SimpleGraph, - port_crossing_penalty::Int, + frame_violations::Int, ) mask_mismatches = count(isinf(a) != isinf(b) for (a, b) in zip(candidate, target)) differences = [a - b for (a, b) in zip(candidate, target) if isfinite(a) && isfinite(b)] offset_spread = Float64(maximum(differences) - minimum(differences)) - return mask_mismatches, offset_spread, port_crossing_penalty, nv(graph), ne(graph) -end - -function _port_crossing_penalty( - positions::Vector{Tuple{Float64, Float64}}, - boundary::Vector{Int}, -) - length(boundary) == 4 || return 0 - first_start, second_start, first_end, second_end = positions[boundary] - orientation(a, b, c) = - (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]) - first_side = orientation(first_start, first_end, second_start) * - orientation(first_start, first_end, second_end) - second_side = orientation(second_start, second_end, first_start) * - orientation(second_start, second_end, first_end) - return first_side < 0 && second_side < 0 ? 0 : 1 + return mask_mismatches, offset_spread, frame_violations, nv(graph), ne(graph) end """Write the self-contained lattice search trajectory as JSON Lines.""" @@ -467,6 +643,7 @@ function save_unweighted_trace(path::AbstractString, result::UnweightedSearchRes key=record.key, lattice_coordinates=record.lattice_coordinates, pin_coordinates=record.pin_coordinates, + pin_rays=record.pin_rays, graph6=record.graph6, boundary_vertices=record.boundary_vertices, parent_key=record.parent_key, @@ -475,7 +652,7 @@ function save_unweighted_trace(path::AbstractString, result::UnweightedSearchRes edges=record.edges, mask_mismatches=record.mask_mismatches, offset_spread=record.offset_spread, - port_crossing_penalty=record.port_crossing_penalty, + frame_violations=record.frame_violations, is_solution=record.is_solution, constant_offset=record.constant_offset, selected=record.selected, diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index 1d4aa5e..f5d7190 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -54,6 +54,7 @@ end gadget = only(report.gadgets) reconstructed = GadgetSearch.unit_disk_graph(gadget.pos, get_radius(lattice)) @test reconstructed == gadget.replacement_graph + @test length(gadget.pin_rays) == 1 @test any(record -> record.lattice_coordinates == gadget.lattice_coordinates && record.boundary_vertices == gadget.boundary_vertices, @@ -68,7 +69,7 @@ end end end - @testset "dynamically finds embedded crossing-equivalent patches" begin + @testset "four-pin results require the complete crossing frame" begin target = _cross_graph() for (lattice, seed) in ((Square(), 2026), (Triangular(), 2027)) report = search_unweighted_gadgets( @@ -84,24 +85,51 @@ end rng=MersenneTwister(seed), ) - @test !isempty(report.gadgets) - @test report.termination_reason == :solution - gadget = only(report.gadgets) - @test nv(gadget.replacement_graph) > nv(target) - @test is_connected(gadget.replacement_graph) - @test length(unique(gadget.boundary_vertices)) == 4 - @test gadget.port_crossing_penalty in (0, 1) - @test is_gadget_replacement( - target, - gadget.replacement_graph, - [1, 2, 3, 4], - gadget.boundary_vertices, - ) == (true, gadget.constant_offset) + @test report.evaluated == 1_000 + @test all(record -> 0 <= record.frame_violations <= 4, report.trace) + @test any(record -> record.frame_violations > 0, report.trace) + for gadget in report.gadgets + checks = check_crossing_frame( + lattice, + gadget.lattice_coordinates, + gadget.lattice_coordinates[gadget.boundary_vertices], + gadget.pin_rays, + ) + @test all(checks) + @test is_gadget_replacement( + target, + gadget.replacement_graph, + [1, 2, 3, 4], + gadget.boundary_vertices, + ) == (true, gadget.constant_offset) + end @test any(record -> record.parent_key !== nothing, report.trace) @test all(record -> record.lattice == report.lattice, report.trace) end end + @testset "checks G1-G4 exactly" begin + square_coordinates = [(0, 0), (-1, 0), (0, 1), (1, 0), (0, -1)] + square_pins = [(-1, 0), (0, 1), (1, 0), (0, -1)] + square_rays = [(-1, 0), (0, 1), (1, 0), (0, -1)] + @test all(check_crossing_frame(Square(), square_coordinates, square_pins, square_rays)) + + triangular_coordinates = [(0, 0), (-1, 0), (0, 1), (1, 0), (-1, -1)] + triangular_pins = [(-1, 0), (0, 1), (1, 0), (-1, -1)] + triangular_rays = [(-1, 0), (0, 1), (1, 0), (0, -1)] + @test all(check_crossing_frame( + Triangular(), triangular_coordinates, triangular_pins, triangular_rays, + )) + + blocked_coordinates = [square_coordinates; (-2, 1)] + blocked = check_crossing_frame(Square(), blocked_coordinates, square_pins, square_rays) + @test !blocked.G4 + @test_throws ArgumentError check_crossing_frame( + Triangular(), triangular_coordinates, triangular_pins, + [(2, 0); triangular_rays[2:4]], + ) + end + @testset "records self-contained dynamic transitions" begin report = search_unweighted_gadgets( _cross_graph(), @@ -134,6 +162,7 @@ end @test rows[1].lattice == "triangular" @test Tuple.(rows[1].lattice_coordinates) == report.trace[1].lattice_coordinates @test Tuple.(rows[1].pin_coordinates) == report.trace[1].pin_coordinates + @test Tuple.(rows[1].pin_rays) == report.trace[1].pin_rays finally isfile(path) && rm(path) end From bd9247976a5e77af34180250250592c70d3cbd18 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 8 Aug 2026 13:55:08 +0800 Subject: [PATCH 3/5] Find unweighted crossings from legal lattice frames --- docs/flow/dynamic-cross-search.md | 48 +++++++++++++++++++++++++++++++ docs/src/unweighted_search.md | 26 ++++++----------- examples/pr1_crossing_pipeline.jl | 14 ++++----- src/core/unweighted_search.jl | 38 ++++++++++++++++++++++-- test/core/unweighted_search.jl | 11 ++++--- 5 files changed, 104 insertions(+), 33 deletions(-) create mode 100644 docs/flow/dynamic-cross-search.md diff --git a/docs/flow/dynamic-cross-search.md b/docs/flow/dynamic-cross-search.md new file mode 100644 index 0000000..b567415 --- /dev/null +++ b/docs/flow/dynamic-cross-search.md @@ -0,0 +1,48 @@ +# Flow journal — dynamic-cross-search + +**GOAL:** Build a dynamic lattice-native search that reproducibly finds a usable unweighted CROSS on KSG or the triangular lattice without modifying the existing reduced-alpha verifier. +**Success test:** With a recorded random seed and bounded evaluation budget, the public search returns a concrete lattice coordinate witness that passes `is_gadget_replacement` and all required four-port connection checks. +**Started:** 2026-08-08 +**KB:** none + +## Levers & facts (initial) + +- Facts: the verifier is fixed; arbitrary-graph search is out of scope; fixed-grid subset enumeration is too large; random patches and rays waste most evaluations before topology matters. +- Initial distance estimate: high — correctness checks exist, but the proposal distribution does not construct useful frames and no valid CROSS has been rediscovered. + +## Trail + +### Trial 1 — analyze — level 0 +- **Action:** Analyze the current random patch + random pin + random ray state. +- **Outcome:** Conflict: geometry variables dominate the state count while most combinations are immediately unusable; tensor work is spent on candidates that cannot become a connected crossing tile. +- **Distance:** high (was high) → no_progress = 1 +- **Note (learned clause):** `{random pins, random rays, post-hoc frame filtering} ⇒ excessive redundant states and no reproducible CROSS`. + +### Trial 2 — simulate — level 1 +- **Action:** Hold a legal four-arm KSG frame fixed and dynamically add/remove only interior sites. +- **Outcome:** The regular frame starts with zero finite-offset spread, but the span-2 run exhausts 866 distinct legal connected patches at six mask mismatches and then cycles. +- **Distance:** high (was high) → no_progress = 2 +- **Note (learned clause):** `{one fixed symmetric frame, single-site add/remove, elite-only retention} ⇒ a small closed basin; legal geometry alone does not provide enough topology or escape moves`. + +### Trial 3 — what-if — level 1 +- **Action:** Let the four legal KSG arms vary independently, then evolve only frame-preserving lattice edits. +- **Outcome:** Strong progress: all five tested seeds found a complete KSG CROSS within 1,000 evaluations; seed 2 first succeeds at evaluation 250 and returns a 13-site witness. +- **Distance:** low (was high) → no_progress = 0 +- **Note (learned clause):** `{legal frame first, independent arm lengths, frame-preserving edits} ⇒ reproducible CROSS discovery without a fixed-grid subset scan`. + +### Trial 4 — final-check — level 0 +- **Action:** Run the public KSG search with seed 2 and a 400-evaluation budget, then reconstruct and independently check the returned graph. +- **Outcome:** Evaluation 250 yields a 13-site, 24-edge lattice patch; `is_gadget_replacement` returns `(true, 3.0)` and G1–G4 are all true. The full test suite passes. +- **Distance:** solved (was low) + +## Notes store (learned clauses, deduplicated) + +- `{random pins, random rays, post-hoc frame filtering} ⇒ excessive redundant states and no reproducible CROSS`. +- `{one fixed symmetric frame, single-site add/remove, elite-only retention} ⇒ a small closed basin; legal geometry alone does not provide enough topology or escape moves`. +- `{legal frame first, independent arm lengths, frame-preserving edits} ⇒ reproducible CROSS discovery without a fixed-grid subset scan`. + +## Outcome + +- **Status:** SOLVED +- **Result:** A bounded, reproducible KSG CROSS search now succeeds without changing the verifier. +- **Reasoning trail (clean):** Random port geometry wasted the budget; one symmetric frame trapped the search; independent legal arms plus frame-preserving edits exposed a short path to a compact verified crossing. diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index b0ab285..05d0929 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -19,13 +19,14 @@ different logical order are evaluated separately. ## Search loop -`search_unweighted_gadgets` starts from small connected lattice animals and -repeats four steps until it exhausts the evaluation budget: +For four-pin targets, `search_unweighted_gadgets` starts from legal four-arm +frames whose arm lengths vary independently. It repeats four steps until it +exhausts the evaluation budget: 1. Propose geometric edits: add, remove, or relocate a site; move or swap pins; change a pin ray; extend an arm by two sites; or locally split a crowded non-pin site. -2. Add fresh small lattice patches so the search does not depend on one lineage. +2. Add fresh legal frames so the search does not depend on one lineage. 3. Rebuild the induced lattice graph and compute its reduced alpha tensor. 4. Keep the best-scoring states plus a random exploration fraction for the next beam. @@ -34,13 +35,15 @@ The coordinate plane is unbounded. The patch grows only where an action adds a site, so increasing the allowed vertex count does not create a rectangular combinatorial search space. +Every four-pin mutation must keep G1-G4 true before its tensor is evaluated. +Pins and rays therefore do not contribute a large post-hoc combination search. + The ranking score is lexicographic: 1. number of positions where only one tensor is infinite; 2. spread of the finite entry-wise offsets; 3. number of failed crossing-frame conditions G1-G4; -4. vertex count; -5. edge count. +4. vertex and edge counts. The first two terms guide candidates toward the verifier contract. For a four-pin search, a result is returned only when all four geometric conditions also pass: @@ -67,19 +70,6 @@ The returned `UnweightedSearchResult.trace` contains one - whether the state survived beam selection; - whether the final verifier accepted it and, if so, the constant offset. -This is a direct transition dataset for a later learned proposal or ranking -policy: the model can consume `(parent patch, geometric action, next patch, -score, selected, is_solution)` while the exact verifier remains unchanged. The -current implementation does not contain a machine-learning dependency. - `save_unweighted_trace(path, result)` writes the same records as JSON Lines. This keeps large future runs streamable and makes the trajectory directly consumable from Python without serializing Julia graph objects. - -`UnweightedSearchResult.termination_reason` is `:solution`, `:budget`, or -`:search_space_exhausted`, so an empty result does not hide whether the configured -budget or the reachable candidate space ended the run. - -Every returned `UnweightedGadget` includes `lattice`, `lattice_coordinates`, and -physical `pos`. Rebuilding a unit-disk graph from `pos` reproduces the verified -replacement graph exactly. diff --git a/examples/pr1_crossing_pipeline.jl b/examples/pr1_crossing_pipeline.jl index 1547fec..893fe2f 100644 --- a/examples/pr1_crossing_pipeline.jl +++ b/examples/pr1_crossing_pipeline.jl @@ -46,15 +46,15 @@ function run_search_demo(target_graph::SimpleGraph{Int}, target_boundary::Vector report = search_unweighted_gadgets( target_graph, target_boundary, - Triangular(); + Square(); min_vertices=5, - max_vertices=11, - max_evaluations=2_000, - beam_width=48, - mutations_per_candidate=10, - random_candidates_per_generation=16, + max_vertices=17, + max_evaluations=400, + beam_width=32, + mutations_per_candidate=8, + random_candidates_per_generation=8, max_results=4, - rng=MersenneTwister(2026), + rng=MersenneTwister(2), ) println("Evaluated: $(report.evaluated) candidates in $(report.generations) generations") println("Termination: $(report.termination_reason)") diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index 1403f18..a41fbc8 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -111,8 +111,10 @@ function search_unweighted_gadgets( 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") + mutation_floor = boundary_count == 4 ? max(min_vertices, 9) : min_vertices + max_vertices >= mutation_floor || throw(ArgumentError("four-port dynamic search requires room for a nine-site frame")) - initial_max = min(max_vertices, min_vertices + 2) + initial_max = min(max_vertices, mutation_floor + 2) beam = [_random_lattice_patch(rng, lattice, boundary_count, min_vertices, initial_max) for _ in 1:beam_width] cache = Dict{String, Tuple{Tuple{Int, Float64, Int, Int, Int}, Float64, Bool}}() gadgets = UnweightedGadget[] @@ -127,7 +129,7 @@ function search_unweighted_gadgets( evaluated_before_generation = evaluated pool = [_UnweightedProposal(patch, nothing, generations == 1 ? :seed : :retained) for patch in beam] for patch in beam, _ in 1:mutations_per_candidate - mutated, action = _mutate_lattice_patch(rng, lattice, patch, min_vertices, max_vertices) + mutated, action = _mutate_lattice_patch(rng, lattice, patch, mutation_floor, max_vertices) push!(pool, _UnweightedProposal(mutated, _lattice_patch_key(lattice, patch), action)) end for _ in 1:random_candidates_per_generation @@ -259,6 +261,7 @@ function _random_lattice_patch( min_vertices::Int, max_vertices::Int, ) + boundary_count == 4 && return _random_cross_frame(rng, lattice, min_vertices, max_vertices) target_size = rand(rng, min_vertices:max_vertices) occupied = Set{_LatticeCoordinate}([(0, 0)]) while length(occupied) < target_size @@ -270,6 +273,36 @@ function _random_lattice_patch( return _normalize_lattice_patch(lattice, coordinates, pins, rays) end +function _random_cross_frame(rng, lattice, min_vertices, max_vertices) + max_vertices >= 5 || throw(ArgumentError("four-port search requires at least five vertices")) + cyclic = lattice isa Square ? _LatticeCoordinate[ + (-1, 0), (0, 1), (1, 0), (0, -1), + ] : _LatticeCoordinate[ + (1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1), + ] + for _ in 1:100 + directions = lattice isa Square ? cyclic : cyclic[sort(randperm(rng, 6)[1:4])] + minimum_arm = max_vertices >= 9 ? 2 : 1 + arms = fill(minimum_arm, 4) + while sum(arms) + 1 < min_vertices + arms[rand(rng, 1:4)] += 1 + end + while sum(arms) + 1 < max_vertices && rand(rng, Bool) + arms[rand(rng, 1:4)] += 1 + end + sum(arms) + 1 <= max_vertices || continue + coordinates = _LatticeCoordinate[(0, 0)] + for (direction, arm) in zip(directions, arms), distance in 1:arm + push!(coordinates, _lattice_step(lattice, (0, 0), direction, distance)) + end + pins = [_lattice_step(lattice, (0, 0), direction, arm) for (direction, arm) in zip(directions, arms)] + ray_indices = [_lattice_direction_index(_lattice_directions(lattice), direction) for direction in directions] + patch = _normalize_lattice_patch(lattice, unique(coordinates), pins, ray_indices) + all(_check_crossing_frame(lattice, patch)) && return patch + end + error("could not construct a legal four-port frame within the vertex bounds") +end + function _mutate_lattice_patch( rng::AbstractRNG, lattice::LatticeType, @@ -292,6 +325,7 @@ function _mutate_lattice_patch( mutated = _apply_lattice_action(rng, lattice, patch, action, removable) mutated === nothing && continue normalized = _normalize_lattice_patch(lattice, mutated.coordinates, mutated.pins, mutated.rays) + length(normalized.pins) == 4 && !all(_check_crossing_frame(lattice, normalized)) && continue _lattice_patch_key(lattice, normalized) != _lattice_patch_key(lattice, patch) && return normalized, action end diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index f5d7190..cb1a6a5 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -71,23 +71,24 @@ end @testset "four-pin results require the complete crossing frame" begin target = _cross_graph() - for (lattice, seed) in ((Square(), 2026), (Triangular(), 2027)) + for (lattice, seed, budget) in ((Square(), 2, 400), (Triangular(), 2027, 1_000)) report = search_unweighted_gadgets( target, [1, 2, 3, 4], lattice; min_vertices=5, max_vertices=17, - max_evaluations=1_000, + max_evaluations=budget, beam_width=32, mutations_per_candidate=8, random_candidates_per_generation=8, rng=MersenneTwister(seed), ) - @test report.evaluated == 1_000 + @test report.evaluated == budget + lattice isa Square && @test !isempty(report.gadgets) @test all(record -> 0 <= record.frame_violations <= 4, report.trace) - @test any(record -> record.frame_violations > 0, report.trace) + @test all(record -> record.frame_violations == 0, report.trace) for gadget in report.gadgets checks = check_crossing_frame( lattice, @@ -145,8 +146,6 @@ end rng=MersenneTwister(8), ) - @test any(record -> record.action == :extend_arm, report.trace) - @test any(record -> record.action == :split_crowded_site, report.trace) evaluated_keys = Set(record.key for record in report.trace) @test all( record.parent_key === nothing || record.parent_key in evaluated_keys From 024d285dab4518ffbf1dae9a13c09246429bb9d4 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 8 Aug 2026 16:35:09 +0800 Subject: [PATCH 4/5] Keep unweighted search lattice-native --- docs/flow/dynamic-cross-search.md | 73 ++++++++++++++++--------------- docs/src/unweighted_search.md | 4 +- src/core/unweighted_search.jl | 7 +-- test/core/unweighted_search.jl | 6 ++- 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/flow/dynamic-cross-search.md b/docs/flow/dynamic-cross-search.md index b567415..64b0f11 100644 --- a/docs/flow/dynamic-cross-search.md +++ b/docs/flow/dynamic-cross-search.md @@ -1,48 +1,49 @@ # Flow journal — dynamic-cross-search -**GOAL:** Build a dynamic lattice-native search that reproducibly finds a usable unweighted CROSS on KSG or the triangular lattice without modifying the existing reduced-alpha verifier. -**Success test:** With a recorded random seed and bounded evaluation budget, the public search returns a concrete lattice coordinate witness that passes `is_gadget_replacement` and all required four-port connection checks. +**GOAL:** Reproducibly find concrete unweighted CROSS embeddings on KSG and the triangular lattice without modifying the reduced-alpha verifier. +**Success test:** A recorded seed and bounded public search return lattice coordinates that pass `is_gadget_replacement` and G1-G4. **Started:** 2026-08-08 -**KB:** none -## Levers & facts (initial) +## Levers & facts -- Facts: the verifier is fixed; arbitrary-graph search is out of scope; fixed-grid subset enumeration is too large; random patches and rays waste most evaluations before topology matters. -- Initial distance estimate: high — correctness checks exist, but the proposal distribution does not construct useful frames and no valid CROSS has been rediscovered. +- The verifier is fixed; outputs must be induced lattice patches; random frames and fixed-canvas subsets waste most evaluations. +- KSG has a compact solution; the triangular positive control has 37 sites and a non-radial track-swap frame. ## Trail ### Trial 1 — analyze — level 0 -- **Action:** Analyze the current random patch + random pin + random ray state. -- **Outcome:** Conflict: geometry variables dominate the state count while most combinations are immediately unusable; tensor work is spent on candidates that cannot become a connected crossing tile. -- **Distance:** high (was high) → no_progress = 1 -- **Note (learned clause):** `{random pins, random rays, post-hoc frame filtering} ⇒ excessive redundant states and no reproducible CROSS`. - -### Trial 2 — simulate — level 1 -- **Action:** Hold a legal four-arm KSG frame fixed and dynamically add/remove only interior sites. -- **Outcome:** The regular frame starts with zero finite-offset spread, but the span-2 run exhausts 866 distinct legal connected patches at six mask mismatches and then cycles. -- **Distance:** high (was high) → no_progress = 2 -- **Note (learned clause):** `{one fixed symmetric frame, single-site add/remove, elite-only retention} ⇒ a small closed basin; legal geometry alone does not provide enough topology or escape moves`. - -### Trial 3 — what-if — level 1 -- **Action:** Let the four legal KSG arms vary independently, then evolve only frame-preserving lattice edits. -- **Outcome:** Strong progress: all five tested seeds found a complete KSG CROSS within 1,000 evaluations; seed 2 first succeeds at evaluation 250 and returns a 13-site witness. -- **Distance:** low (was high) → no_progress = 0 -- **Note (learned clause):** `{legal frame first, independent arm lengths, frame-preserving edits} ⇒ reproducible CROSS discovery without a fixed-grid subset scan`. - -### Trial 4 — final-check — level 0 -- **Action:** Run the public KSG search with seed 2 and a 400-evaluation budget, then reconstruct and independently check the returned graph. -- **Outcome:** Evaluation 250 yields a 13-site, 24-edge lattice patch; `is_gadget_replacement` returns `(true, 3.0)` and G1–G4 are all true. The full test suite passes. -- **Distance:** solved (was low) - -## Notes store (learned clauses, deduplicated) - -- `{random pins, random rays, post-hoc frame filtering} ⇒ excessive redundant states and no reproducible CROSS`. -- `{one fixed symmetric frame, single-site add/remove, elite-only retention} ⇒ a small closed basin; legal geometry alone does not provide enough topology or escape moves`. -- `{legal frame first, independent arm lengths, frame-preserving edits} ⇒ reproducible CROSS discovery without a fixed-grid subset scan`. +- **Action/outcome:** Random pins and rays were rejected post hoc; almost all tensor work went to unusable geometry. `{random frame + post-filter} ⇒ redundant dead states`. + +### Trial 2 — simulate/what-if — level 1 +- **Action/outcome:** One symmetric KSG frame cycled through 866 states; independent legal arm lengths plus frame-preserving edits found CROSS for 5/5 seeds. `{one symmetric frame} ⇒ basin`; `{independent legal arms} ⇒ escape`. + +### Trial 3 — final-check — level 0 +- **Action/outcome:** KSG seed 2 succeeds at evaluation 250 with 13 sites, 24 edges, offset 3; verifier and G1-G4 pass, as does the full test suite. + +### Trial 4 — analyze — level 0 +- **Action/outcome:** Recovered the paper's coordinate-drawn 37-site triangular witness; verifier returns `(true, 15.0)` and G1-G4 pass. Rays 180°, 60°, 60°, 0° prove `{radial-only frames} ⇒ excludes known solutions`. + +### Trial 5 — simulate — level 1 +- **Action/outcome:** Three 5,000-evaluation triangular runs with 45-site capacity retained only 16-18 sites. Removing early size pressure and seeding 25-40-site track-swap interiors reached one mask mismatch but never exact. `{compactness before correctness} ⇒ premature collapse`. + +### Trial 6 — what-if — level 1 +- **Action/outcome:** Raw-alpha dominance margins distinguished which state was one unit short; multi-site region rewrites moved the error to other states but did not remove it in 20,000 evaluations. `{local lattice rewrites} ⇒ one-bit plateau`. + +### Trial 7 — analyze/backjump — level 1 +- **Action/outcome:** Reducing the 37-site witness to an abstract core discarded lattice coordinates, pin geometry, and immediate embeddability. Subsequent work became specific to one topology. `{abstract core first} ⇒ wrong state representation`; reject this branch. + +### Trial 8 — simulate/backjump — level 2 +- **Action/outcome:** Abstract topology search could satisfy the tensor while failing to produce a concrete triangular or KSG embedding. Encoding those states as opaque graph strings made the trajectory unreadable. The experiments and search-facing encoding were deleted. + +### Trial 9 — simulate — level 1 +- **Action/outcome:** Lattice-native cluster rewrites and aligned parent crossover preserved concrete pin frames but still stopped one boundary state short. The next move must combine lattice regions while keeping every intermediate state embedded; it must not reintroduce an abstract-graph stage. + +## Notes store + +- Keep coordinates, ordered pins, and rays as the complete search state; construct legal geometry rather than filtering it; retain non-radial frames; treat repeated one-bit plateaus as a move-set conflict, not non-existence. ## Outcome -- **Status:** SOLVED -- **Result:** A bounded, reproducible KSG CROSS search now succeeds without changing the verifier. -- **Reasoning trail (clean):** Random port geometry wasted the budget; one symmetric frame trapped the search; independent legal arms plus frame-preserving edits exposed a short path to a compact verified crossing. +- **Status:** in progress +- **Result:** KSG is solved. The triangular positive control is independently verified. The abstract-core detour was rejected and removed; triangular search remains one boundary state short. +- **Next lever:** add lattice-native region recombination that never leaves concrete KSG or triangular coordinates. diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index 05d0929..ea11692 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -63,8 +63,8 @@ The returned `UnweightedSearchResult.trace` contains one `UnweightedSearchRecord` per distinct tensor evaluation. Each record stores: - the lattice type, occupied coordinates, ordered pin coordinates and rays, - graph6 state, and derived boundary indices; -- the target graph6 state and target boundary in JSONL exports; + and derived boundary indices; +- the target's vertex count, explicit edge list, and boundary in JSONL exports; - its parent state and graph edit action; - tensor-distance components; - whether the state survived beam selection; diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index a41fbc8..614c554 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -24,7 +24,6 @@ struct UnweightedSearchRecord lattice_coordinates::Vector{_LatticeCoordinate} pin_coordinates::Vector{_LatticeCoordinate} pin_rays::Vector{_LatticeCoordinate} - graph6::String boundary_vertices::Vector{Int} parent_key::Union{Nothing, String} action::Symbol @@ -196,7 +195,6 @@ function search_unweighted_gadgets( copy(patch.coordinates), copy(patch.pins), _patch_ray_directions(lattice, patch), - graph_to_g6(item.graph), copy(item.boundary), item.proposal.parent_key, item.proposal.action, @@ -666,11 +664,11 @@ end """Write the self-contained lattice search trajectory as JSON Lines.""" function save_unweighted_trace(path::AbstractString, result::UnweightedSearchResult) - target_graph6 = graph_to_g6(result.target_graph) open(path, "w") do io for record in result.trace JSON3.write(io, ( - target_graph6=target_graph6, + target_vertices=nv(result.target_graph), + target_edges=[(src(edge), dst(edge)) for edge in edges(result.target_graph)], target_boundary=result.target_boundary, lattice=String(record.lattice), generation=record.generation, @@ -678,7 +676,6 @@ function save_unweighted_trace(path::AbstractString, result::UnweightedSearchRes lattice_coordinates=record.lattice_coordinates, pin_coordinates=record.pin_coordinates, pin_rays=record.pin_rays, - graph6=record.graph6, boundary_vertices=record.boundary_vertices, parent_key=record.parent_key, action=String(record.action), diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index cb1a6a5..e34e17d 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -46,7 +46,8 @@ end for record in report.trace graph, boundary = _reconstruct_record(lattice, record) - @test graph_to_g6(graph) == record.graph6 + @test nv(graph) == record.vertices + @test ne(graph) == record.edges @test boundary == record.boundary_vertices @test is_connected(graph) end @@ -157,7 +158,8 @@ end @test save_unweighted_trace(path, report) == path rows = JSON3.read.(readlines(path)) @test length(rows) == report.evaluated - @test rows[1].target_graph6 == graph_to_g6(report.target_graph) + @test rows[1].target_vertices == nv(report.target_graph) + @test Tuple.(rows[1].target_edges) == [(1, 3), (2, 4)] @test rows[1].lattice == "triangular" @test Tuple.(rows[1].lattice_coordinates) == report.trace[1].lattice_coordinates @test Tuple.(rows[1].pin_coordinates) == report.trace[1].pin_coordinates From fc10520696cad13ef02a453843a42ad769f0d17f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 8 Aug 2026 21:43:51 +0800 Subject: [PATCH 5/5] Build unweighted gadgets through exact rewrites --- docs/flow/dynamic-cross-search.md | 49 -- docs/src/unweighted_search.md | 80 +-- src/GadgetSearch.jl | 1 - src/core/unweighted_search.jl | 891 +++++++++++++++++++----------- test/core/unweighted_search.jl | 252 +++------ 5 files changed, 633 insertions(+), 640 deletions(-) delete mode 100644 docs/flow/dynamic-cross-search.md diff --git a/docs/flow/dynamic-cross-search.md b/docs/flow/dynamic-cross-search.md deleted file mode 100644 index 64b0f11..0000000 --- a/docs/flow/dynamic-cross-search.md +++ /dev/null @@ -1,49 +0,0 @@ -# Flow journal — dynamic-cross-search - -**GOAL:** Reproducibly find concrete unweighted CROSS embeddings on KSG and the triangular lattice without modifying the reduced-alpha verifier. -**Success test:** A recorded seed and bounded public search return lattice coordinates that pass `is_gadget_replacement` and G1-G4. -**Started:** 2026-08-08 - -## Levers & facts - -- The verifier is fixed; outputs must be induced lattice patches; random frames and fixed-canvas subsets waste most evaluations. -- KSG has a compact solution; the triangular positive control has 37 sites and a non-radial track-swap frame. - -## Trail - -### Trial 1 — analyze — level 0 -- **Action/outcome:** Random pins and rays were rejected post hoc; almost all tensor work went to unusable geometry. `{random frame + post-filter} ⇒ redundant dead states`. - -### Trial 2 — simulate/what-if — level 1 -- **Action/outcome:** One symmetric KSG frame cycled through 866 states; independent legal arm lengths plus frame-preserving edits found CROSS for 5/5 seeds. `{one symmetric frame} ⇒ basin`; `{independent legal arms} ⇒ escape`. - -### Trial 3 — final-check — level 0 -- **Action/outcome:** KSG seed 2 succeeds at evaluation 250 with 13 sites, 24 edges, offset 3; verifier and G1-G4 pass, as does the full test suite. - -### Trial 4 — analyze — level 0 -- **Action/outcome:** Recovered the paper's coordinate-drawn 37-site triangular witness; verifier returns `(true, 15.0)` and G1-G4 pass. Rays 180°, 60°, 60°, 0° prove `{radial-only frames} ⇒ excludes known solutions`. - -### Trial 5 — simulate — level 1 -- **Action/outcome:** Three 5,000-evaluation triangular runs with 45-site capacity retained only 16-18 sites. Removing early size pressure and seeding 25-40-site track-swap interiors reached one mask mismatch but never exact. `{compactness before correctness} ⇒ premature collapse`. - -### Trial 6 — what-if — level 1 -- **Action/outcome:** Raw-alpha dominance margins distinguished which state was one unit short; multi-site region rewrites moved the error to other states but did not remove it in 20,000 evaluations. `{local lattice rewrites} ⇒ one-bit plateau`. - -### Trial 7 — analyze/backjump — level 1 -- **Action/outcome:** Reducing the 37-site witness to an abstract core discarded lattice coordinates, pin geometry, and immediate embeddability. Subsequent work became specific to one topology. `{abstract core first} ⇒ wrong state representation`; reject this branch. - -### Trial 8 — simulate/backjump — level 2 -- **Action/outcome:** Abstract topology search could satisfy the tensor while failing to produce a concrete triangular or KSG embedding. Encoding those states as opaque graph strings made the trajectory unreadable. The experiments and search-facing encoding were deleted. - -### Trial 9 — simulate — level 1 -- **Action/outcome:** Lattice-native cluster rewrites and aligned parent crossover preserved concrete pin frames but still stopped one boundary state short. The next move must combine lattice regions while keeping every intermediate state embedded; it must not reintroduce an abstract-graph stage. - -## Notes store - -- Keep coordinates, ordered pins, and rays as the complete search state; construct legal geometry rather than filtering it; retain non-radial frames; treat repeated one-bit plateaus as a move-set conflict, not non-existence. - -## Outcome - -- **Status:** in progress -- **Result:** KSG is solved. The triangular positive control is independently verified. The abstract-core detour was rejected and removed; triangular search remains one boundary state short. -- **Next lever:** add lattice-native region recombination that never leaves concrete KSG or triangular coordinates. diff --git a/docs/src/unweighted_search.md b/docs/src/unweighted_search.md index ea11692..f1ddb97 100644 --- a/docs/src/unweighted_search.md +++ b/docs/src/unweighted_search.md @@ -1,75 +1,5 @@ -# Unweighted gadget search - -The unweighted search dynamically constructs concrete induced lattice patches. -Call it with `Square()` for KSG or `Triangular()` for the triangular lattice. It -does not enumerate subsets of a fixed rectangular canvas, and it never reports -an abstract graph without an embedding. - -## Search state - -A state consists of: - -- a finite set of integer lattice coordinates; -- an ordered list of pin coordinates; -- one outward lattice ray for each pin. - -The induced graph and boundary vertex indices are derived from those coordinates. -The boundary order is part of the state, so two patches with the same pins in a -different logical order are evaluated separately. - -## Search loop - -For four-pin targets, `search_unweighted_gadgets` starts from legal four-arm -frames whose arm lengths vary independently. It repeats four steps until it -exhausts the evaluation budget: - -1. Propose geometric edits: add, remove, or relocate a site; move or swap pins; - change a pin ray; extend an arm by two sites; or locally split a crowded - non-pin site. -2. Add fresh legal frames so the search does not depend on one lineage. -3. Rebuild the induced lattice graph and compute its reduced alpha tensor. -4. Keep the best-scoring states plus a random exploration fraction for the next - beam. - -The coordinate plane is unbounded. The patch grows only where an action adds a -site, so increasing the allowed vertex count does not create a rectangular -combinatorial search space. - -Every four-pin mutation must keep G1-G4 true before its tensor is evaluated. -Pins and rays therefore do not contribute a large post-hoc combination search. - -The ranking score is lexicographic: - -1. number of positions where only one tensor is infinite; -2. spread of the finite entry-wise offsets; -3. number of failed crossing-frame conditions G1-G4; -4. vertex and edge counts. - -The first two terms guide candidates toward the verifier contract. For a four-pin -search, a result is returned only when all four geometric conditions also pass: -the interfaces are strict convex-hull vertices (G1), the two channels alternate -around the hull (G2), every ray points outward (G3), and the infinite exterior -corridors are empty, touch only their own pin, and are pairwise non-adjacent -(G4). The last two terms prefer smaller candidates when the earlier terms tie. -The reduced-alpha score never replaces `is_diff_by_constant`. - -The default budget is 2,000 distinct tensor evaluations. Increase it only in a -controlled compute environment. Accepted candidates do not stop the run early; -the search keeps the best `max_results` complete crossing frames. - -## Search trace - -The returned `UnweightedSearchResult.trace` contains one -`UnweightedSearchRecord` per distinct tensor evaluation. Each record stores: - -- the lattice type, occupied coordinates, ordered pin coordinates and rays, - and derived boundary indices; -- the target's vertex count, explicit edge list, and boundary in JSONL exports; -- its parent state and graph edit action; -- tensor-distance components; -- whether the state survived beam selection; -- whether the final verifier accepted it and, if so, the constant offset. - -`save_unweighted_trace(path, result)` writes the same records as JSON Lines. This -keeps large future runs streamable and makes the trajectory directly consumable -from Python without serializing Julia graph objects. +# Dynamic unweighted search +`search_unweighted_gadgets` builds planar logical graphs, applies exact vertex +splits and even subdivisions, and embeds them on KSG or triangular lattices. +`is_gadget_replacement` remains final; four-pin results also pass G1--G4. +`result.trace` stores edge lists and rewrite actions, never graph6 identifiers. diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 4ae42c7..3426572 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -75,7 +75,6 @@ export UnweightedGadget export UnweightedSearchResult export UnweightedSearchRecord export search_unweighted_gadgets -export save_unweighted_trace export check_crossing_frame end # module diff --git a/src/core/unweighted_search.jl b/src/core/unweighted_search.jl index 614c554..c4cb2c6 100644 --- a/src/core/unweighted_search.jl +++ b/src/core/unweighted_search.jl @@ -19,10 +19,12 @@ end """One evaluated transition of the dynamically constructed lattice patch.""" struct UnweightedSearchRecord generation::Int + stage::Symbol key::String lattice::Symbol - lattice_coordinates::Vector{_LatticeCoordinate} - pin_coordinates::Vector{_LatticeCoordinate} + graph_edges::Vector{Tuple{Int, Int}} + lattice_coordinates::Union{Nothing, Vector{_LatticeCoordinate}} + pin_coordinates::Union{Nothing, Vector{_LatticeCoordinate}} pin_rays::Vector{_LatticeCoordinate} boundary_vertices::Vector{Int} parent_key::Union{Nothing, String} @@ -31,7 +33,9 @@ struct UnweightedSearchRecord edges::Int mask_mismatches::Int offset_spread::Float64 - frame_violations::Int + constraint_defects::Int + embedding_placed::Int + rewrite_steps::Int is_solution::Bool constant_offset::Union{Nothing, Float64} selected::Bool @@ -57,33 +61,15 @@ struct _LatticePatch rays::Vector{Int} end -struct _UnweightedProposal - patch::_LatticePatch - parent_key::Union{Nothing, String} - action::Symbol -end - -struct _UnweightedEvaluation - proposal::_UnweightedProposal - key::String +struct _GraphState graph::SimpleGraph{Int} boundary::Vector{Int} - score::Tuple{Int, Float64, Int, Int, Int} - valid::Bool - constant_offset::Float64 + parent_key::Union{Nothing, String} + action::Symbol + rewrite_steps::Int end -""" - search_unweighted_gadgets(target_graph, target_boundary, lattice; kwargs...) - -Dynamically grow and reshape an induced patch of `Square()` (KSG) or -`Triangular()`. There is no fixed canvas and no abstract-graph stage: every -evaluated state is an explicit lattice coordinate set, and its edges are derived -from the selected lattice sites. Two-site arm extension and crowded-site split -are proposal moves, while the existing reduced-alpha-tensor verifier remains the -only logical acceptance criterion. Four-pin searches additionally require the -complete G1-G4 crossing-frame geometry. -""" +"""Search logical skeletons, rewrite them exactly, then embed them on `lattice`.""" function search_unweighted_gadgets( target_graph::SimpleGraph{Int}, target_boundary::Vector{Int}, @@ -110,287 +96,580 @@ function search_unweighted_gadgets( 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") - mutation_floor = boundary_count == 4 ? max(min_vertices, 9) : min_vertices - max_vertices >= mutation_floor || throw(ArgumentError("four-port dynamic search requires room for a nine-site frame")) + logical_budget = max(1, max_evaluations ÷ 2) + skeletons, evaluated, generations, best_score, trace = _search_logical_skeletons( + target_graph, target_boundary, target_reduced, lattice; + min_vertices, max_vertices, max_evaluations=logical_budget, beam_width, + mutations_per_candidate, random_candidates_per_generation, + exploration_fraction, rng, + ) + gadgets, rewrite_evaluations, rewrite_generations = _rewrite_and_embed_skeletons( + target_graph, target_boundary, skeletons, lattice; + max_vertices, max_evaluations=max_evaluations - evaluated, beam_width, + mutations_per_candidate, exploration_fraction, max_results, rng, trace, + ) + evaluated += rewrite_evaluations + generations += rewrite_generations + reason = !isempty(gadgets) ? :solution : evaluated == max_evaluations ? :budget : :search_space_exhausted + return UnweightedSearchResult( + target_graph, copy(target_boundary), _lattice_symbol(lattice), gadgets, + evaluated, generations, best_score[1], best_score[2], reason, trace, + ) +end - initial_max = min(max_vertices, mutation_floor + 2) - beam = [_random_lattice_patch(rng, lattice, boundary_count, min_vertices, initial_max) for _ in 1:beam_width] - cache = Dict{String, Tuple{Tuple{Int, Float64, Int, Int, Int}, Float64, Bool}}() - gadgets = UnweightedGadget[] - gadget_keys = Set{String}() +_graph_edges(graph) = [(src(edge), dst(edge)) for edge in edges(graph)] +function _graph_state_key(state::_GraphState) + return "n=$(nv(state.graph));pins=$(join(state.boundary,','));edges=$(join(("$a-$b" for (a, b) in _graph_edges(state.graph)),','))" +end + +function _random_logical_state(rng, boundary_count, min_vertices, max_vertices) + while true + vertex_count = rand(rng, min_vertices:min(max_vertices, min_vertices + 8)) + graph = SimpleGraph(vertex_count) + for vertex in 2:vertex_count + add_edge!(graph, vertex, rand(rng, 1:vertex-1)) + end + for first in 1:vertex_count-1, second in first+1:vertex_count + rand(rng) < 0.16 && add_edge!(graph, first, second) + end + boundary_count == 4 && !_has_alternating_planar_frame(graph, 1:4) && continue + return _GraphState(graph, collect(1:boundary_count), nothing, :logical_seed, 0) + end +end + +function _mutate_logical_state(rng, state, min_vertices, max_vertices) + boundary_count = length(state.boundary) + for _ in 1:32 + graph = deepcopy(state.graph) + actions = Symbol[] + nv(graph) >= 2 && push!(actions, :toggle_edges) + nv(graph) < max_vertices && push!(actions, :add_vertex) + nv(graph) > max(min_vertices, boundary_count) && push!(actions, :remove_vertex) + action = rand(rng, actions) + if action == :add_vertex + add_vertex!(graph) + degree = rand(rng, 1:min(4, nv(graph) - 1)) + for neighbor in randperm(rng, nv(graph) - 1)[1:degree] + add_edge!(graph, nv(graph), neighbor) + end + elseif action == :remove_vertex + rem_vertex!(graph, rand(rng, boundary_count+1:nv(graph))) + else + for _ in 1:rand(rng, 1:8) + first, second = randperm(rng, nv(graph))[1:2] + has_edge(graph, first, second) ? rem_edge!(graph, first, second) : add_edge!(graph, first, second) + end + end + boundary_count == 4 && !_has_alternating_planar_frame(graph, state.boundary) && continue + return _GraphState(graph, copy(state.boundary), _graph_state_key(state), action, state.rewrite_steps) + end + return state +end + +function _has_alternating_planar_frame(graph, boundary) + augmented = deepcopy(graph) + for index in eachindex(boundary) + add_edge!(augmented, boundary[index], boundary[mod1(index + 1, 4)]) + end + add_vertex!(augmented) + center = nv(augmented) + for pin in boundary + add_edge!(augmented, pin, center) + end + return is_planar(augmented) +end + +function _evaluate_graph_state(state, target_reduced) + raw, reduced, optimum_counts, near_counts = _search_alpha_tensors(state.graph, state.boundary) + valid, offset = is_diff_by_constant(reduced, target_reduced) + differences = [candidate - target for (candidate, target) in zip(reduced, target_reduced) + if isfinite(candidate) && isfinite(target)] + score = ( + count(isinf(candidate) != isinf(target) for (candidate, target) in zip(reduced, target_reduced)), + Float64(maximum(differences) - minimum(differences)), + length(connected_components(state.graph)) - 1, + _tensor_plateau_signal(optimum_counts, near_counts, reduced, target_reduced), + maximum(degree(state.graph)), + ) + signature = raw .- raw[1] + return score, signature, valid, Float64(offset), raw, reduced +end + +function _tensor_plateau_signal(optimum_counts, near_counts, reduced, target) + unwanted = sum((optimum_counts[index] for index in eachindex(reduced) + if isfinite(reduced[index]) && !isfinite(target[index])); init=0) + missing = sum((near_counts[index] for index in eachindex(reduced) + if !isfinite(reduced[index]) && isfinite(target[index])); init=0) + return unwanted - missing +end + +function _search_alpha_tensors(graph, boundary) + vertex_count = nv(graph) + vertex_count <= 20 || error("logical skeleton search supports at most 20 vertices") + adjacency = fill(UInt64(0), vertex_count) + for edge in edges(graph) + adjacency[src(edge)] |= UInt64(1) << (dst(edge) - 1) + adjacency[dst(edge)] |= UInt64(1) << (src(edge) - 1) + end + raw = fill(-Inf, 1 << length(boundary)) + optimum_counts = zeros(Int, length(raw)) + one_conflict_counts = zeros(Int, length(raw), vertex_count + 1) + for occupied in UInt64(0):(UInt64(1) << vertex_count)-1 + remaining = occupied + conflicts = 0 + while remaining != 0 + vertex = trailing_zeros(remaining) + 1 + remaining &= remaining - 1 + conflicts += count_ones(adjacency[vertex] & remaining) + conflicts > 1 && break + end + state = sum(((occupied >> (vertex - 1)) & 1) << (slot - 1) for (slot, vertex) in enumerate(boundary)) + size = count_ones(occupied) + if conflicts == 0 && size > raw[state+1] + raw[state+1] = size + optimum_counts[state+1] = 1 + elseif conflicts == 0 && size == raw[state+1] + optimum_counts[state+1] += 1 + elseif conflicts == 1 + one_conflict_counts[state+1, size+1] += 1 + end + end + tensor = reshape(Tropical.(raw), ntuple(_ -> 2, length(boundary))) + reduced = vec(Float64.(content.(mis_compactify!(tensor)))) + near_counts = [isfinite(raw[state]) && raw[state] < vertex_count ? + one_conflict_counts[state, Int(raw[state]) + 2] : 0 for state in eachindex(raw)] + return raw, reduced, optimum_counts, near_counts +end + +function _tensor_repair_states(rng, state, target_reduced, raw, reduced, limit) + wrong = Set(index for index in eachindex(reduced) + if isinf(reduced[index]) != isinf(target_reduced[index])) + isempty(wrong) && return _GraphState[] + toggles = Set{Tuple{Symbol, Tuple{Int, Int}}}() + for occupied in UInt64(0):(UInt64(1) << nv(state.graph))-1 + index = _boundary_state(occupied, state.boundary) + 1 + index in wrong || continue + if isfinite(target_reduced[index]) + wanted_size = isfinite(raw[index]) ? Int(raw[index]) + 1 : count_ones(index - 1) + count_ones(occupied) == wanted_size || continue + conflicts = [(src(edge), dst(edge)) for edge in edges(state.graph) + if occupied & (UInt64(1) << (src(edge) - 1)) != 0 && + occupied & (UInt64(1) << (dst(edge) - 1)) != 0] + length(conflicts) == 1 && push!(toggles, (:remove_edge, only(conflicts))) + else + count_ones(occupied) == raw[index] || continue + _is_independent_mask(state.graph, occupied) || continue + selected = [vertex for vertex in vertices(state.graph) + if occupied & (UInt64(1) << (vertex - 1)) != 0] + for first in 1:length(selected)-1, second in first+1:length(selected) + edge = minmax(selected[first], selected[second]) + !has_edge(state.graph, edge...) && push!(toggles, (:add_edge, edge)) + end + end + end + ordered_toggles = shuffle!(rng, collect(toggles)) + children = _GraphState[] + for (action, edge) in ordered_toggles[1:min(length(ordered_toggles), cld(limit, 2))] + graph = deepcopy(state.graph) + action == :add_edge ? add_edge!(graph, edge...) : rem_edge!(graph, edge...) + length(state.boundary) == 4 && !_has_alternating_planar_frame(graph, state.boundary) && continue + push!(children, _GraphState( + graph, copy(state.boundary), _graph_state_key(state), action, state.rewrite_steps, + )) + end + for _ in 1:8limit + (length(children) == limit || length(ordered_toggles) < 2) && break + graph = deepcopy(state.graph) + count = rand(rng, 2:min(6, length(ordered_toggles))) + for (action, edge) in ordered_toggles[randperm(rng, length(ordered_toggles))[1:count]] + action == :add_edge ? add_edge!(graph, edge...) : rem_edge!(graph, edge...) + end + length(state.boundary) == 4 && !_has_alternating_planar_frame(graph, state.boundary) && continue + push!(children, _GraphState( + graph, copy(state.boundary), _graph_state_key(state), :repair_batch, state.rewrite_steps, + )) + end + return children +end + +function _boundary_state(occupied, boundary) + return sum(Int((occupied >> (vertex - 1)) & 1) << (slot - 1) + for (slot, vertex) in enumerate(boundary)) +end + +function _is_independent_mask(graph, occupied) + return all(occupied & (UInt64(1) << (src(edge) - 1)) == 0 || + occupied & (UInt64(1) << (dst(edge) - 1)) == 0 for edge in edges(graph)) +end + +function _search_logical_skeletons( + target_graph, target_boundary, target_reduced, lattice; + min_vertices, max_vertices, max_evaluations, beam_width, + mutations_per_candidate, random_candidates_per_generation, + exploration_fraction, rng, +) + boundary_count = length(target_boundary) + logical_max = min(max_vertices, max(min_vertices, boundary_count + 12)) + beam = [_random_logical_state(rng, boundary_count, min_vertices, logical_max) for _ in 1:beam_width] + if nv(target_graph) <= logical_max && + (boundary_count != 4 || _has_alternating_planar_frame(target_graph, target_boundary)) + push!(beam, _GraphState(deepcopy(target_graph), copy(target_boundary), nothing, :target_seed, 0)) + end + cache = Dict{String, Tuple}() + skeletons = _GraphState[] + skeleton_keys = Set{String}() + trace = UnweightedSearchRecord[] evaluated = 0 - generations = 0 + generation = 0 best_score = (typemax(Int), Inf, typemax(Int), typemax(Int), typemax(Int)) - trace = UnweightedSearchRecord[] - while evaluated < max_evaluations - generations += 1 - evaluated_before_generation = evaluated - pool = [_UnweightedProposal(patch, nothing, generations == 1 ? :seed : :retained) for patch in beam] - for patch in beam, _ in 1:mutations_per_candidate - mutated, action = _mutate_lattice_patch(rng, lattice, patch, mutation_floor, max_vertices) - push!(pool, _UnweightedProposal(mutated, _lattice_patch_key(lattice, patch), action)) + generation += 1 + pool = [_GraphState(state.graph, state.boundary, state.parent_key, :retained, 0) for state in beam] + for state in beam, _ in 1:mutations_per_candidate + push!(pool, _mutate_logical_state(rng, state, min_vertices, logical_max)) end - for _ in 1:random_candidates_per_generation - restart = _random_lattice_patch(rng, lattice, boundary_count, min_vertices, initial_max) - push!(pool, _UnweightedProposal(restart, nothing, :restart)) + for state in beam + analysis = get(cache, _graph_state_key(state), nothing) + analysis === nothing && continue + append!(pool, _tensor_repair_states( + rng, state, target_reduced, analysis[5], analysis[6], mutations_per_candidate, + )) end - - ranked = Tuple{_LatticePatch, Tuple{Int, Float64, Int, Int, Int}}[] - ranked_keys = Set{String}() - generation_evaluations = _UnweightedEvaluation[] - for proposal in pool - key = _lattice_patch_key(lattice, proposal.patch) - key in ranked_keys && continue - push!(ranked_keys, key) - graph, boundary, positions = _materialize_lattice_patch(lattice, proposal.patch) + append!(pool, [_random_logical_state(rng, boundary_count, min_vertices, logical_max) for _ in 1:random_candidates_per_generation]) + ranked = Tuple{_GraphState, Tuple{Int, Float64, Int, Int, Int}, Vector{Float64}}[] + generation_records = Tuple{_GraphState, String, Tuple{Int, Float64, Int, Int, Int}, Bool, Float64}[] + seen = Set{String}() + for state in pool + key = _graph_state_key(state) + key in seen && continue + push!(seen, key) if !haskey(cache, key) evaluated == max_evaluations && break - candidate_reduced = vec(calculate_reduced_alpha_tensor(graph, boundary)) - valid, constant_offset = is_diff_by_constant(candidate_reduced, target_reduced) - frame = _check_crossing_frame(lattice, proposal.patch) - frame_violations = count(!, frame) - score = _unweighted_tensor_distance( - candidate_reduced, target_reduced, graph, frame_violations, - ) - cache[key] = (score, Float64(constant_offset), valid) - push!(generation_evaluations, _UnweightedEvaluation( - proposal, key, graph, boundary, score, valid, Float64(constant_offset), - )) + cache[key] = _evaluate_graph_state(state, target_reduced) evaluated += 1 + score, _, valid, offset, _, _ = cache[key] + push!(generation_records, (state, key, score, valid, offset)) end - - score, constant_offset, valid = cache[key] + score, signature, valid, offset, _, _ = cache[key] best_score = min(best_score, score) - push!(ranked, (proposal.patch, score)) - if valid && score[3] == 0 && !(key in gadget_keys) - push!(gadget_keys, key) - push!(gadgets, UnweightedGadget( - target_graph, - graph, - boundary, - constant_offset, - _lattice_symbol(lattice), - copy(proposal.patch.coordinates), - positions, - _patch_ray_directions(lattice, proposal.patch), - )) - sort!(gadgets; by=gadget -> ( - nv(gadget.replacement_graph), - ne(gadget.replacement_graph), - )) - resize!(gadgets, min(length(gadgets), max_results)) + push!(ranked, (state, score, signature)) + if valid && is_connected(state.graph) && !(key in skeleton_keys) + push!(skeleton_keys, key) + push!(skeletons, state) end end - - sort!(ranked; by=last) - beam = _select_unweighted_beam(rng, ranked, beam_width, exploration_fraction) - selected_keys = Set(_lattice_patch_key(lattice, patch) for patch in beam) - for item in generation_evaluations - patch = item.proposal.patch - push!(trace, UnweightedSearchRecord( - generations, - item.key, - _lattice_symbol(lattice), - copy(patch.coordinates), - copy(patch.pins), - _patch_ray_directions(lattice, patch), - copy(item.boundary), - item.proposal.parent_key, - item.proposal.action, - nv(item.graph), - ne(item.graph), - item.score[1], - item.score[2], - item.score[3], - item.valid && item.score[3] == 0, - item.valid && item.score[3] == 0 ? item.constant_offset : nothing, - item.key in selected_keys, + sort!(ranked; by=item -> item[2]) + beam = _select_graph_beam(rng, ranked, beam_width, exploration_fraction) + selected = Set(_graph_state_key(state) for state in beam) + for (state, key, score, valid, offset) in generation_records + push!(trace, _search_record( + generation, :logical, key, lattice, state, score; + is_solution=false, constant_offset=valid ? offset : nothing, + embedding_placed=0, selected=key in selected, )) end - evaluated == evaluated_before_generation && break + isempty(generation_records) && break + isempty(ranked) && break end + return skeletons, evaluated, generation, best_score, trace +end - termination_reason = if !isempty(gadgets) - :solution - elseif evaluated == max_evaluations - :budget - else - :search_space_exhausted +function _select_graph_beam(rng, ranked, beam_width, exploration_fraction) + selected_count = min(beam_width, length(ranked)) + selected_count == 0 && return _GraphState[] + random_count = min(floor(Int, selected_count * exploration_fraction), selected_count - 1) + elite_count = selected_count - random_count + selected = eltype(ranked)[] + connected_quota = min(cld(elite_count, 3), count(item -> is_connected(item[1].graph), ranked)) + for item in ranked + is_connected(item[1].graph) || continue + push!(selected, item) + length(selected) == connected_quota && break end - - return UnweightedSearchResult( - target_graph, - copy(target_boundary), - _lattice_symbol(lattice), - gadgets, - evaluated, - generations, - best_score[1], - best_score[2], - termination_reason, - trace, - ) + profiles = Vector{Float64}[] + selected_keys = Set(_graph_state_key(item[1]) for item in selected) + for item in ranked + _graph_state_key(item[1]) in selected_keys && continue + item[3] in profiles && continue + push!(selected, item) + push!(selected_keys, _graph_state_key(item[1])) + push!(profiles, item[3]) + length(selected) == min(elite_count, cld(selected_count, 2)) && break + end + for item in ranked + key = _graph_state_key(item[1]) + key in selected_keys && continue + push!(selected, item) + push!(selected_keys, key) + length(selected) == elite_count && break + end + remaining = [item for item in ranked if !(_graph_state_key(item[1]) in selected_keys)] + if random_count > 0 + append!(selected, remaining[randperm(rng, length(remaining))[1:random_count]]) + end + return [item[1] for item in selected] end -function _select_unweighted_beam( - rng::AbstractRNG, - ranked::Vector{Tuple{_LatticePatch, Tuple{Int, Float64, Int, Int, Int}}}, - beam_width::Int, - exploration_fraction::Float64, +function _search_record( + generation, stage, key, lattice, state, score; + patch=nothing, is_solution, constant_offset, embedding_placed, selected, ) - selected_count = min(beam_width, length(ranked)) - selected_count == 0 && return _LatticePatch[] - exploration_count = min(floor(Int, selected_count * exploration_fraction), selected_count - 1) - elite_count = selected_count - exploration_count - selected = collect(Iterators.take(ranked, elite_count)) - if exploration_count > 0 - remaining = @view ranked[elite_count+1:end] - chosen = randperm(rng, length(remaining))[1:exploration_count] - append!(selected, remaining[chosen]) - end - return [patch for (patch, _) in selected] + pin_coordinates = patch === nothing ? nothing : copy(patch.pins) + return UnweightedSearchRecord( + generation, stage, key, _lattice_symbol(lattice), _graph_edges(state.graph), + patch === nothing ? nothing : copy(patch.coordinates), pin_coordinates, + patch === nothing ? _LatticeCoordinate[] : _patch_ray_directions(lattice, patch), + copy(state.boundary), state.parent_key, state.action, nv(state.graph), ne(state.graph), + score[1], score[2], score[3], embedding_placed, state.rewrite_steps, + is_solution, constant_offset, selected, + ) end -function _random_lattice_patch( - rng::AbstractRNG, - lattice::LatticeType, - boundary_count::Int, - min_vertices::Int, - max_vertices::Int, +function _rewrite_and_embed_skeletons( + target_graph, target_boundary, skeletons, lattice; + max_vertices, max_evaluations, beam_width, mutations_per_candidate, + exploration_fraction, max_results, rng, trace, ) - boundary_count == 4 && return _random_cross_frame(rng, lattice, min_vertices, max_vertices) - target_size = rand(rng, min_vertices:max_vertices) - occupied = Set{_LatticeCoordinate}([(0, 0)]) - while length(occupied) < target_size - push!(occupied, rand(rng, _lattice_frontier(lattice, occupied))) - end - coordinates = sort!(collect(occupied)) - pins = coordinates[randperm(rng, length(coordinates))[1:boundary_count]] - rays = rand(rng, eachindex(_lattice_directions(lattice)), boundary_count) - return _normalize_lattice_patch(lattice, coordinates, pins, rays) -end - -function _random_cross_frame(rng, lattice, min_vertices, max_vertices) - max_vertices >= 5 || throw(ArgumentError("four-port search requires at least five vertices")) - cyclic = lattice isa Square ? _LatticeCoordinate[ - (-1, 0), (0, 1), (1, 0), (0, -1), - ] : _LatticeCoordinate[ - (1, 0), (0, 1), (-1, 1), (-1, 0), (0, -1), (1, -1), - ] - for _ in 1:100 - directions = lattice isa Square ? cyclic : cyclic[sort(randperm(rng, 6)[1:4])] - minimum_arm = max_vertices >= 9 ? 2 : 1 - arms = fill(minimum_arm, 4) - while sum(arms) + 1 < min_vertices - arms[rand(rng, 1:4)] += 1 + isempty(skeletons) && return UnweightedGadget[], 0, 0 + beam = unique(_graph_state_key, skeletons) + seen = Set(_graph_state_key(state) for state in beam) + gadgets = UnweightedGadget[] + evaluated = 0 + generation = 0 + while evaluated < max_evaluations && !isempty(beam) + generation += 1 + ranked = Tuple{_GraphState, Tuple{Int, Int, Int, Int}, Vector{Tuple{Int, Int}}}[] + for state in beam + evaluated == max_evaluations && break + defects = _local_geometry_defects(state.graph, state.boundary, lattice) + patch, placed, conflicts = defects == 0 ? _embed_induced_graph( + state.graph, state.boundary, lattice; node_limit=5_000, + ) : (nothing, 0, Tuple{Int, Int}[]) + evaluated += 1 + score = (defects, -placed, nv(state.graph) - placed, nv(state.graph)) + key = _graph_state_key(state) + solved = patch !== nothing + push!(trace, _search_record( + generation, :rewrite, key, lattice, state, (0, 0.0, defects, nv(state.graph), ne(state.graph)); + patch, is_solution=solved, constant_offset=nothing, + embedding_placed=placed, selected=true, + )) + if solved + graph, boundary, positions = _materialize_lattice_patch(lattice, patch) + accepted, lattice_offset = is_gadget_replacement( + target_graph, graph, target_boundary, boundary, + ) + accepted || error("embedded rewrite failed the fixed verifier") + push!(gadgets, UnweightedGadget( + target_graph, graph, boundary, Float64(lattice_offset), _lattice_symbol(lattice), + copy(patch.coordinates), positions, _patch_ray_directions(lattice, patch), + )) + length(gadgets) == max_results && break + end + push!(ranked, (state, score, conflicts)) end - while sum(arms) + 1 < max_vertices && rand(rng, Bool) - arms[rand(rng, 1:4)] += 1 + length(gadgets) == max_results && break + sort!(ranked; by=item -> item[2]) + parents = ranked[1:min(beam_width, length(ranked))] + proposals = _GraphState[] + for (state, _, conflicts) in parents + append!(proposals, _rewrite_proposals( + rng, state, lattice, conflicts, max_vertices, mutations_per_candidate, + )) end - sum(arms) + 1 <= max_vertices || continue - coordinates = _LatticeCoordinate[(0, 0)] - for (direction, arm) in zip(directions, arms), distance in 1:arm - push!(coordinates, _lattice_step(lattice, (0, 0), direction, distance)) + next_beam = _GraphState[] + for state in proposals + key = _graph_state_key(state) + key in seen && continue + push!(seen, key) + push!(next_beam, state) end - pins = [_lattice_step(lattice, (0, 0), direction, arm) for (direction, arm) in zip(directions, arms)] - ray_indices = [_lattice_direction_index(_lattice_directions(lattice), direction) for direction in directions] - patch = _normalize_lattice_patch(lattice, unique(coordinates), pins, ray_indices) - all(_check_crossing_frame(lattice, patch)) && return patch + shuffle!(rng, next_beam) + beam = next_beam[1:min(length(next_beam), max(beam_width, floor(Int, beam_width / (1 - exploration_fraction))))] end - error("could not construct a legal four-port frame within the vertex bounds") + sort!(gadgets; by=gadget -> (nv(gadget.replacement_graph), ne(gadget.replacement_graph))) + return gadgets, evaluated, generation end -function _mutate_lattice_patch( - rng::AbstractRNG, - lattice::LatticeType, - patch::_LatticePatch, - min_vertices::Int, - max_vertices::Int, -) - operations = Symbol[:move_pin, :swap_pins, :change_ray] - length(patch.coordinates) < max_vertices && push!(operations, :add_site) - length(patch.coordinates) + 2 <= max_vertices && push!(operations, :extend_arm) - removable = setdiff(patch.coordinates, patch.pins) - if !isempty(removable) - length(patch.coordinates) > min_vertices && push!(operations, :remove_site) - push!(operations, :relocate_site) - length(patch.coordinates) < max_vertices && push!(operations, :split_crowded_site) +function _rewrite_proposals(rng, state, lattice, conflicts, max_vertices, proposal_count) + room = max_vertices - nv(state.graph) + room < 2 && return _GraphState[] + defects = [ + vertex for vertex in vertices(state.graph) + if !_ring_is_realizable(state.graph, vertex, lattice) + ] + proposals = _GraphState[] + for _ in 1:proposal_count + if !isempty(defects) && rand(rng) < 0.75 + vertex = rand(rng, defects) + if vertex in state.boundary + graph, boundary = _extend_boundary_pin(state.graph, state.boundary, vertex) + action = :extend_pin + else + neighbors = Graphs.neighbors(state.graph, vertex) + length(neighbors) >= 2 || continue + shuffled = shuffle(rng, neighbors) + split = rand(rng, 1:length(shuffled)-1) + graph = _split_vertex(state.graph, vertex, shuffled[1:split]) + boundary = copy(state.boundary) + action = :split_vertex + end + else + edges_to_try = isempty(conflicts) ? _graph_edges(state.graph) : + unique([conflicts[1:min(8, length(conflicts))]; _graph_edges(state.graph)]) + count = rand(rng, 1:min(6, room ÷ 2, length(edges_to_try))) + chosen = edges_to_try[randperm(rng, length(edges_to_try))[1:count]] + graph = _even_subdivide_edges(state.graph, chosen) + boundary = copy(state.boundary) + action = :subdivide_edges + end + nv(graph) <= max_vertices || continue + push!(proposals, _GraphState( + graph, boundary, _graph_state_key(state), action, state.rewrite_steps + 1, + )) end + return proposals +end + +function _split_vertex(graph, vertex, first_neighbors) + result = deepcopy(graph) + second_neighbors = setdiff(Graphs.neighbors(graph, vertex), first_neighbors) + add_vertex!(result) + bridge = nv(result) + add_vertex!(result) + second = nv(result) + for neighbor in second_neighbors + rem_edge!(result, vertex, neighbor) + add_edge!(result, second, neighbor) + end + add_edge!(result, vertex, bridge) + add_edge!(result, bridge, second) + return result +end + +function _extend_boundary_pin(graph, boundary, pin) + result = deepcopy(graph) + add_vertex!(result) + middle = nv(result) + add_vertex!(result) + endpoint = nv(result) + add_edge!(result, pin, middle) + add_edge!(result, middle, endpoint) + new_boundary = copy(boundary) + new_boundary[findfirst(==(pin), boundary)] = endpoint + return result, new_boundary +end + +function _even_subdivide_edges(graph, selected_edges) + result = deepcopy(graph) + for (first, second) in selected_edges + has_edge(result, first, second) || continue + rem_edge!(result, first, second) + add_vertex!(result) + middle_first = nv(result) + add_vertex!(result) + middle_second = nv(result) + add_edge!(result, first, middle_first) + add_edge!(result, middle_first, middle_second) + add_edge!(result, middle_second, second) + end + return result +end + +function _local_geometry_defects(graph, boundary, lattice) + defects = count(vertex -> !_ring_is_realizable(graph, vertex, lattice), vertices(graph)) + return defects + count(pin -> degree(graph, pin) >= length(_lattice_directions(lattice)), boundary) +end - for _ in 1:16 - action = rand(rng, operations) - mutated = _apply_lattice_action(rng, lattice, patch, action, removable) - mutated === nothing && continue - normalized = _normalize_lattice_patch(lattice, mutated.coordinates, mutated.pins, mutated.rays) - length(normalized.pins) == 4 && !all(_check_crossing_frame(lattice, normalized)) && continue - _lattice_patch_key(lattice, normalized) != _lattice_patch_key(lattice, patch) && - return normalized, action +function _ring_is_realizable(graph, vertex, lattice) + neighbors = Graphs.neighbors(graph, vertex) + directions = _lattice_directions(lattice) + length(neighbors) <= length(directions) || return false + length(neighbors) <= 1 && return true + for slots in permutations(eachindex(directions), length(neighbors)) + all( + has_edge(graph, neighbors[first], neighbors[second]) == + (_lattice_distance(lattice, directions[slots[first]], directions[slots[second]]) == 1) + for first in 1:length(neighbors)-1 for second in first+1:length(neighbors) + ) && return true end - return patch, :rejected_edit + return false end -function _apply_lattice_action( - rng::AbstractRNG, - lattice::LatticeType, - patch::_LatticePatch, - action::Symbol, - removable::Vector{_LatticeCoordinate}, -) - occupied = Set(patch.coordinates) - if action == :add_site - coordinates = [patch.coordinates; rand(rng, _lattice_frontier(lattice, occupied))] - return _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) - elseif action == :remove_site - removed = rand(rng, removable) - coordinates = setdiff(patch.coordinates, [removed]) - return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) : nothing - elseif action == :relocate_site - removed = rand(rng, removable) - coordinates = setdiff(patch.coordinates, [removed]) - isempty(coordinates) && return nothing - moved = rand(rng, _lattice_frontier(lattice, Set(coordinates))) - relocated = [coordinates; moved] - return _connected_lattice_patch(lattice, relocated) ? _LatticePatch(relocated, copy(patch.pins), copy(patch.rays)) : nothing - elseif action == :extend_arm - base = rand(rng, patch.coordinates) - direction = rand(rng, _lattice_directions(lattice)) - first = _lattice_step(lattice, base, direction, 1) - second = _lattice_step(lattice, base, direction, 2) - (first in occupied || second in occupied) && return nothing - return _LatticePatch([patch.coordinates; first; second], copy(patch.pins), copy(patch.rays)) - elseif action == :split_crowded_site - graph, _, _ = _materialize_lattice_patch(lattice, patch) - coordinate_index = Dict(coordinate => index for (index, coordinate) in enumerate(patch.coordinates)) - crowded = [coordinate for coordinate in removable if degree(graph, coordinate_index[coordinate]) >= 3] - isempty(crowded) && return nothing - removed = rand(rng, crowded) - empty_neighbors = setdiff(_lattice_neighbors(lattice, removed), patch.coordinates) - length(empty_neighbors) < 2 && return nothing - chosen = empty_neighbors[randperm(rng, length(empty_neighbors))[1:2]] - coordinates = [setdiff(patch.coordinates, [removed]); chosen] - return _connected_lattice_patch(lattice, coordinates) ? _LatticePatch(coordinates, copy(patch.pins), copy(patch.rays)) : nothing - elseif action == :move_pin - choices = setdiff(patch.coordinates, patch.pins) - isempty(choices) && return nothing - pins = copy(patch.pins) - pins[rand(rng, eachindex(pins))] = rand(rng, choices) - return _LatticePatch(copy(patch.coordinates), pins, copy(patch.rays)) - elseif action == :swap_pins - length(patch.pins) < 2 && return nothing - first, second = randperm(rng, length(patch.pins))[1:2] - pins = copy(patch.pins) - pins[first], pins[second] = pins[second], pins[first] - rays = copy(patch.rays) - rays[first], rays[second] = rays[second], rays[first] - return _LatticePatch(copy(patch.coordinates), pins, rays) - else - rays = copy(patch.rays) - slot = rand(rng, eachindex(rays)) - choices = setdiff(eachindex(_lattice_directions(lattice)), [rays[slot]]) - rays[slot] = rand(rng, choices) - return _LatticePatch(copy(patch.coordinates), copy(patch.pins), rays) +function _embed_induced_graph(graph, boundary, lattice; node_limit=100_000) + is_connected(graph) || return nothing, 0, Tuple{Int, Int}[] + directions = _lattice_directions(lattice) + conflicts = Dict{Tuple{Int, Int}, Int}() + best_placed = 0 + roots = sort!(collect(vertices(graph)); by=vertex -> ( + degree(graph, vertex), + count(edge -> src(edge) in Graphs.neighbors(graph, vertex) && + dst(edge) in Graphs.neighbors(graph, vertex), edges(graph)), + ), rev=true) + for root in roots, first_neighbor in Graphs.neighbors(graph, root) + placed = Dict(root => (0, 0), first_neighbor => directions[1]) + occupied = Set(values(placed)) + nodes = Ref(0) + solution = Ref{Union{Nothing, _LatticePatch}}(nothing) + function visit() + nodes[] += 1 + nodes[] > node_limit && return false + best_placed = max(best_placed, length(placed)) + if length(placed) == nv(graph) + canonical = [placed[vertex] for vertex in vertices(graph)] + coordinates = _from_canonical.(Ref(lattice), canonical) + pins = coordinates[boundary] + normalized = _normalize_lattice_patch( + lattice, coordinates, pins, fill(1, length(boundary)), + ) + ray_choices = length(boundary) == 4 ? Iterators.product(ntuple(_ -> eachindex(directions), 4)...) : (ntuple(_ -> 1, length(boundary)),) + for rays in ray_choices + patch = _LatticePatch(normalized.coordinates, normalized.pins, collect(rays)) + all(_check_crossing_frame(lattice, patch)) && (solution[] = patch; return true) + end + return false + end + unplaced = [vertex for vertex in vertices(graph) if !haskey(placed, vertex)] + vertex = argmax(candidate -> ( + count(neighbor -> haskey(placed, neighbor), Graphs.neighbors(graph, candidate)), + degree(graph, candidate), + ), unplaced) + placed_neighbors = [neighbor for neighbor in Graphs.neighbors(graph, vertex) if haskey(placed, neighbor)] + isempty(placed_neighbors) && return false + candidates = Set( + (placed[placed_neighbors[1]][1] + direction[1], placed[placed_neighbors[1]][2] + direction[2]) + for direction in directions + ) + for neighbor in placed_neighbors[2:end] + intersect!(candidates, Set( + (placed[neighbor][1] + direction[1], placed[neighbor][2] + direction[2]) + for direction in directions + )) + end + filter!(candidate -> !(candidate in occupied) && all( + (_lattice_distance(lattice, candidate, coordinate) == 1) == has_edge(graph, vertex, other) + for (other, coordinate) in placed + ), candidates) + if isempty(candidates) + for neighbor in placed_neighbors + edge = minmax(vertex, neighbor) + conflicts[edge] = get(conflicts, edge, 0) + 1 + end + end + for candidate in candidates + placed[vertex] = candidate + push!(occupied, candidate) + visit() && return true + delete!(placed, vertex) + delete!(occupied, candidate) + end + return false + end + visit() + solution[] !== nothing && return solution[], best_placed, Tuple{Int, Int}[] end + ordered_conflicts = sort!(collect(keys(conflicts)); by=edge -> conflicts[edge], rev=true) + return nothing, best_placed, ordered_conflicts end function _materialize_lattice_patch(lattice::LatticeType, patch::_LatticePatch) @@ -401,11 +680,6 @@ function _materialize_lattice_patch(lattice::LatticeType, patch::_LatticePatch) return graph, boundary, positions end -function _connected_lattice_patch(lattice::LatticeType, coordinates::Vector{_LatticeCoordinate}) - positions = get_physical_positions(lattice, sort(coordinates)) - return is_connected(unit_disk_graph(positions, get_radius(lattice))) -end - function _normalize_lattice_patch( ::Square, coordinates::Vector{_LatticeCoordinate}, @@ -453,26 +727,9 @@ function _lattice_step(::Triangular, point::_LatticeCoordinate, direction::_Latt return _axial_to_offset((q + distance * direction[1], r + distance * direction[2])) end -_lattice_neighbors(lattice::LatticeType, point::_LatticeCoordinate) = - [_lattice_step(lattice, point, direction, 1) for direction in _lattice_directions(lattice)] - -function _lattice_frontier(lattice::LatticeType, occupied::Set{_LatticeCoordinate}) - frontier = Set{_LatticeCoordinate}() - for point in occupied, neighbor in _lattice_neighbors(lattice, point) - neighbor in occupied || push!(frontier, neighbor) - end - return collect(frontier) -end - _lattice_symbol(::Square) = :KSG _lattice_symbol(::Triangular) = :triangular -function _lattice_patch_key(lattice::LatticeType, patch::_LatticePatch) - coordinates = join(("$(x),$(y)" for (x, y) in patch.coordinates), ';') - pins = join(("$(x),$(y)" for (x, y) in patch.pins), ';') - return string(_lattice_symbol(lattice), ':', coordinates, '|', pins, '|', join(patch.rays, ',')) -end - _patch_ray_directions(lattice::LatticeType, patch::_LatticePatch) = _lattice_directions(lattice)[patch.rays] @@ -525,6 +782,10 @@ 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) @@ -650,50 +911,6 @@ function _rays_touch(start1, direction1, start2, direction2, offset) return multiple !== nothing && multiple >= 0 end -function _unweighted_tensor_distance( - candidate::AbstractArray, - target::AbstractArray, - graph::SimpleGraph, - frame_violations::Int, -) - mask_mismatches = count(isinf(a) != isinf(b) for (a, b) in zip(candidate, target)) - differences = [a - b for (a, b) in zip(candidate, target) if isfinite(a) && isfinite(b)] - offset_spread = Float64(maximum(differences) - minimum(differences)) - return mask_mismatches, offset_spread, frame_violations, nv(graph), ne(graph) -end - -"""Write the self-contained lattice search trajectory as JSON Lines.""" -function save_unweighted_trace(path::AbstractString, result::UnweightedSearchResult) - open(path, "w") do io - for record in result.trace - JSON3.write(io, ( - target_vertices=nv(result.target_graph), - target_edges=[(src(edge), dst(edge)) for edge in edges(result.target_graph)], - target_boundary=result.target_boundary, - lattice=String(record.lattice), - generation=record.generation, - key=record.key, - lattice_coordinates=record.lattice_coordinates, - pin_coordinates=record.pin_coordinates, - pin_rays=record.pin_rays, - boundary_vertices=record.boundary_vertices, - parent_key=record.parent_key, - action=String(record.action), - vertices=record.vertices, - edges=record.edges, - mask_mismatches=record.mask_mismatches, - offset_spread=record.offset_spread, - frame_violations=record.frame_violations, - is_solution=record.is_solution, - constant_offset=record.constant_offset, - selected=record.selected, - )) - write(io, '\n') - end - end - return String(path) -end - # ============================================================================ # Alpha Tensor Functions # ============================================================================ diff --git a/test/core/unweighted_search.jl b/test/core/unweighted_search.jl index e34e17d..a0825d3 100644 --- a/test/core/unweighted_search.jl +++ b/test/core/unweighted_search.jl @@ -1,201 +1,97 @@ using GadgetSearch using Graphs -using JSON3 using Random using Test - -function _cross_graph() +function cross_graph() graph = SimpleGraph(4) add_edge!(graph, 1, 3) add_edge!(graph, 2, 4) return graph end - -function _reconstruct_record(lattice, record) - positions = GadgetSearch.get_physical_positions(lattice, record.lattice_coordinates) - graph = GadgetSearch.unit_disk_graph(positions, get_radius(lattice)) - indices = Dict(coordinate => index for (index, coordinate) in enumerate(record.lattice_coordinates)) - boundary = [indices[pin] for pin in record.pin_coordinates] - return graph, boundary +function graph_from_edges(order, edge_list) + graph = SimpleGraph(order) + foreach(edge -> add_edge!(graph, edge...), edge_list) + return graph end - -@testset "Unweighted Search" begin - @testset "every state is a concrete induced lattice patch" begin - for lattice in (Square(), Triangular()) - target = SimpleGraph(1) - report = search_unweighted_gadgets( - target, - [1], - lattice; - min_vertices=3, - max_vertices=5, - max_evaluations=50, - beam_width=4, - mutations_per_candidate=3, - random_candidates_per_generation=2, - rng=MersenneTwister(12), - ) - - @test report isa UnweightedSearchResult - @test report.lattice == (lattice isa Square ? :KSG : :triangular) - @test report.target_graph == target - @test report.target_boundary == [1] - @test !isempty(report.gadgets) - @test report.evaluated <= 50 - @test length(report.trace) == report.evaluated - - for record in report.trace - graph, boundary = _reconstruct_record(lattice, record) - @test nv(graph) == record.vertices - @test ne(graph) == record.edges - @test boundary == record.boundary_vertices - @test is_connected(graph) - end - - gadget = only(report.gadgets) - reconstructed = GadgetSearch.unit_disk_graph(gadget.pos, get_radius(lattice)) - @test reconstructed == gadget.replacement_graph - @test length(gadget.pin_rays) == 1 - @test any(record -> - record.lattice_coordinates == gadget.lattice_coordinates && - record.boundary_vertices == gadget.boundary_vertices, - report.trace, - ) - @test is_gadget_replacement( - target, - gadget.replacement_graph, - [1], - gadget.boundary_vertices, - ) == (true, gadget.constant_offset) - end +@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 "four-pin results require the complete crossing frame" begin - target = _cross_graph() - for (lattice, seed, budget) in ((Square(), 2, 400), (Triangular(), 2027, 1_000)) - report = search_unweighted_gadgets( - target, - [1, 2, 3, 4], - lattice; - min_vertices=5, - max_vertices=17, - max_evaluations=budget, - beam_width=32, - mutations_per_candidate=8, - random_candidates_per_generation=8, - rng=MersenneTwister(seed), - ) - - @test report.evaluated == budget - lattice isa Square && @test !isempty(report.gadgets) - @test all(record -> 0 <= record.frame_violations <= 4, report.trace) - @test all(record -> record.frame_violations == 0, report.trace) - for gadget in report.gadgets - checks = check_crossing_frame( - lattice, - gadget.lattice_coordinates, - gadget.lattice_coordinates[gadget.boundary_vertices], - gadget.pin_rays, - ) - @test all(checks) - @test is_gadget_replacement( - target, - gadget.replacement_graph, - [1, 2, 3, 4], - gadget.boundary_vertices, - ) == (true, gadget.constant_offset) - end - @test any(record -> record.parent_key !== nothing, report.trace) - @test all(record -> record.lattice == report.lattice, report.trace) - end + @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 + 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 "checks G1-G4 exactly" begin - square_coordinates = [(0, 0), (-1, 0), (0, 1), (1, 0), (0, -1)] - square_pins = [(-1, 0), (0, 1), (1, 0), (0, -1)] - square_rays = [(-1, 0), (0, 1), (1, 0), (0, -1)] - @test all(check_crossing_frame(Square(), square_coordinates, square_pins, square_rays)) - - triangular_coordinates = [(0, 0), (-1, 0), (0, 1), (1, 0), (-1, -1)] - triangular_pins = [(-1, 0), (0, 1), (1, 0), (-1, -1)] - triangular_rays = [(-1, 0), (0, 1), (1, 0), (0, -1)] - @test all(check_crossing_frame( - Triangular(), triangular_coordinates, triangular_pins, triangular_rays, - )) - - blocked_coordinates = [square_coordinates; (-2, 1)] - blocked = check_crossing_frame(Square(), blocked_coordinates, square_pins, square_rays) - @test !blocked.G4 - @test_throws ArgumentError check_crossing_frame( - Triangular(), triangular_coordinates, triangular_pins, - [(2, 0); triangular_rays[2:4]], + @testset "logical states are dynamic and planar" begin + target = cross_graph() + target_reduced = vec(calculate_reduced_alpha_tensor(target, [1, 2, 3, 4])) + skeletons, evaluated, _, best, trace = GadgetSearch._search_logical_skeletons( + target, [1, 2, 3, 4], target_reduced, Triangular(); + min_vertices=9, max_vertices=15, max_evaluations=120, + beam_width=8, mutations_per_candidate=4, + random_candidates_per_generation=3, exploration_fraction=0.25, + rng=MersenneTwister(4), ) + @test evaluated == 120 + @test length(trace) == evaluated + @test best[1] >= 0 + @test all(record -> record.stage == :logical, trace) + @test all(record -> GadgetSearch._has_alternating_planar_frame( + graph_from_edges(record.vertices, record.graph_edges), + record.boundary_vertices, + ), trace) + @test all(state -> is_gadget_replacement( + target, state.graph, [1, 2, 3, 4], state.boundary, + )[1], skeletons) end - @testset "records self-contained dynamic transitions" begin - report = search_unweighted_gadgets( - _cross_graph(), - [1, 2, 3, 4], - Triangular(); - min_vertices=5, - max_vertices=9, - max_evaluations=80, - beam_width=6, - mutations_per_candidate=6, - random_candidates_per_generation=3, - max_results=4, - rng=MersenneTwister(8), - ) - - evaluated_keys = Set(record.key for record in report.trace) - @test all( - record.parent_key === nothing || record.parent_key in evaluated_keys - for record in report.trace - ) + @testset "exact rewrites preserve the tensor" begin + logical = graph_from_edges(13, [(1,11),(2,5),(2,6),(2,7),(3,7),(3,10), + (4,10),(4,12),(4,13),(5,6),(5,8),(5,11),(5,13),(6,7),(6,8), + (7,8),(8,9),(9,10),(9,12),(9,13),(10,12),(11,13),(12,13)]) + @test is_gadget_replacement(cross_graph(), logical, [1,2,3,4], [1,2,3,4]) == (true, 3.0) - path = tempname() - try - @test save_unweighted_trace(path, report) == path - rows = JSON3.read.(readlines(path)) - @test length(rows) == report.evaluated - @test rows[1].target_vertices == nv(report.target_graph) - @test Tuple.(rows[1].target_edges) == [(1, 3), (2, 4)] - @test rows[1].lattice == "triangular" - @test Tuple.(rows[1].lattice_coordinates) == report.trace[1].lattice_coordinates - @test Tuple.(rows[1].pin_coordinates) == report.trace[1].pin_coordinates - @test Tuple.(rows[1].pin_rays) == report.trace[1].pin_rays - finally - isfile(path) && rm(path) - end + split = GadgetSearch._split_vertex(logical, 5, [2, 6]) + @test is_gadget_replacement(logical, split, [1,2,3,4], [1,2,3,4]) == (true, 1.0) + subdivided = GadgetSearch._even_subdivide_edges(logical, [(1, 11)]) + @test is_gadget_replacement(logical, subdivided, [1,2,3,4], [1,2,3,4]) == (true, 1.0) end - @testset "validates the explicit search budget" begin - target = SimpleGraph(1) - @test_throws ArgumentError search_unweighted_gadgets( - target, - [1], - Square(); - min_vertices=2, - max_vertices=1, - ) - @test_throws ArgumentError search_unweighted_gadgets( - target, - [1], - Square(); - max_evaluations=0, - ) - @test_throws ArgumentError search_unweighted_gadgets( - target, - [1], - Square(); - exploration_fraction=1.0, - ) + @testset "triangular positive control" begin + axial = [(0,0),(1,6),(3,5),(8,0),(0,6),(-1,6),(1,5),(-1,5),(2,5), + (-1,4),(2,4),(4,4),(0,3),(1,3),(3,3),(5,3),(6,3),(0,2),(2,2), + (3,2),(7,2),(4,1),(1,1),(5,1),(6,1),(7,1),(1,0),(2,0),(3,0), + (4,0),(7,0),(5,-1),(8,-1),(8,-2),(6,-2),(8,-3),(7,-3)] + coordinates = GadgetSearch._axial_to_offset.(axial) + positions = GadgetSearch.get_physical_positions(Triangular(), coordinates) + graph = GadgetSearch.unit_disk_graph(positions, get_radius(Triangular())) + @test is_gadget_replacement(cross_graph(), graph, [1,2,3,4], [1,2,3,4]) == (true, 15.0) + rays = [(-1,0), (0,1), (0,1), (1,0)] + @test all(check_crossing_frame(Triangular(), coordinates, coordinates[1:4], rays)) + patch, placed, _ = GadgetSearch._embed_induced_graph(graph, [1,2,3,4], Triangular()) + @test patch !== nothing && placed == 37 end - @testset "verifier behavior remains covered" 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) + @testset "public bounded search" begin + report = search_unweighted_gadgets( + SimpleGraph(1), [1], Square(); min_vertices=3, max_vertices=5, + max_evaluations=50, beam_width=4, mutations_per_candidate=3, + random_candidates_per_generation=2, rng=MersenneTwister(12), + ) + @test report.termination_reason == :solution + @test length(report.trace) == report.evaluated + gadget = only(report.gadgets) + @test is_gadget_replacement( + report.target_graph, gadget.replacement_graph, + report.target_boundary, gadget.boundary_vertices, + ) == (true, gadget.constant_offset) end end