diff --git a/examples/plot_triangular_lattices.jl b/examples/plot_triangular_lattices.jl new file mode 100644 index 0000000..5383bdc --- /dev/null +++ b/examples/plot_triangular_lattices.jl @@ -0,0 +1,67 @@ +using GadgetSearch + +const EXAMPLES_DIR = pkgdir(GadgetSearch, "examples") + +triangular_positions(nx::Int, ny::Int) = + GadgetSearch.get_physical_positions(Triangular(), vec(Tuple{Int, Int}[(i, j) for i in 1:nx, j in 1:ny])) + +function plot_lattice_examples(outdir::AbstractString=EXAMPLES_DIR) + mkpath(outdir) + full_graph = triangular_lattice_graph(4, 4) + full_positions = triangular_positions(4, 4) + full_path = joinpath(outdir, "triangular_lattice_4x4.svg") + GadgetSearch.plot_graph(full_graph, full_path; pos=full_positions, plot_size=700, vertex_size=8, vertex_label_size=12) + + small_graph = triangular_lattice_graph(3, 3) + small_positions = triangular_positions(3, 3) + small_path = joinpath(outdir, "triangular_lattice_3x3.svg") + GadgetSearch.plot_graph(small_graph, small_path; pos=small_positions, plot_size=600, vertex_size=10, vertex_label_size=14) + + return (full_path, small_path) +end + +function ensure_subset_dataset(outdir::AbstractString=EXAMPLES_DIR) + mkpath(outdir) + dataset_path = joinpath(outdir, "triangular_subset_dataset.g6") + if !isfile(dataset_path) + generate_triangular_udg_subsets(3, 3; subset_sizes=2:3, deduplicate=true, path=dataset_path) + end + return dataset_path +end + +function plot_subset_examples(dataset_path::String; outdir::AbstractString=EXAMPLES_DIR, nplots::Int=3) + mkpath(outdir) + loader = GraphLoader(dataset_path) + plot_count = min(nplots, length(loader)) + saved_paths = String[] + + for idx in 1:plot_count + graph = loader[idx] + positions = loader.layout[idx] + positions === nothing && continue + + outpath = joinpath(outdir, "triangular_subset_$(idx).svg") + GadgetSearch.plot_graph(graph, outpath; pos=positions, plot_size=500, vertex_size=12, vertex_label_size=16) + push!(saved_paths, outpath) + end + + return saved_paths +end + +function main(; outdir::AbstractString=EXAMPLES_DIR, nplots::Int=3) + lattice_paths = plot_lattice_examples(outdir) + dataset_path = ensure_subset_dataset(outdir) + subset_paths = plot_subset_examples(dataset_path; outdir=outdir, nplots=nplots) + + println("Triangular lattice plots:") + foreach(println, lattice_paths) + println("Subset dataset:") + println(dataset_path) + println("Subset plots:") + foreach(println, subset_paths) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main() +end + diff --git a/src/GadgetSearch.jl b/src/GadgetSearch.jl index 6d32014..6915d42 100644 --- a/src/GadgetSearch.jl +++ b/src/GadgetSearch.jl @@ -38,8 +38,13 @@ export save_cache export save_graph export Square, Triangular +export triangular_adjacency +export triangular_lattice_graph +export canonical_crossing_pins +export dedup_inner_subsets export generate_full_grid_udg export generate_full_grid_graph +export generate_triangular_udg_subsets # Core types export Gadget diff --git a/src/graphio/udg.jl b/src/graphio/udg.jl index 03c81f3..de8b987 100644 --- a/src/graphio/udg.jl +++ b/src/graphio/udg.jl @@ -26,9 +26,151 @@ abstract type LatticeType end struct Square <: LatticeType end struct Triangular <: LatticeType end +""" + triangular_adjacency(i1::Int, j1::Int, i2::Int, j2::Int) -> Bool + +Return whether two sites of the triangular lattice are nearest neighbors using +only integer lattice coordinates. + +# Arguments +- `i1::Int`: Column index of the first site. +- `j1::Int`: Row index of the first site. +- `i2::Int`: Column index of the second site. +- `j2::Int`: Row index of the second site. + +# Returns +- `Bool`: `true` if the two lattice sites share an edge in the triangular lattice. +""" +function triangular_adjacency(i1::Int, j1::Int, i2::Int, j2::Int) + j1 == j2 && return abs(i1 - i2) == 1 + abs(j1 - j2) == 1 || return false + + if j1 > j2 + return triangular_adjacency(i2, j2, i1, j1) + end + + return isodd(j1) ? (i2 == i1 || i2 == i1 + 1) : (i2 == i1 || i2 == i1 - 1) +end + +""" + triangular_lattice_graph(nx::Int, ny::Int) -> SimpleGraph{Int} + +Build the full nearest-neighbor triangular lattice graph on an `nx × ny` grid +of lattice sites. + +# Arguments +- `nx::Int`: Number of lattice columns. +- `ny::Int`: Number of lattice rows. + +# Returns +- `SimpleGraph{Int}`: The graph whose vertices are lattice sites and whose + edges connect nearest neighbors. +""" +function triangular_lattice_graph(nx::Int, ny::Int) + if nx <= 0 || ny <= 0 + return SimpleGraph(0) + end + + vertex_index(i, j) = i + (j - 1) * nx + g = SimpleGraph(nx * ny) + + for j = 1:ny, i = 1:nx + v = vertex_index(i, j) + + if i < nx + add_edge!(g, v, vertex_index(i + 1, j)) + end + + if j < ny + add_edge!(g, v, vertex_index(i, j + 1)) + + if isodd(j) && i < nx + add_edge!(g, v, vertex_index(i + 1, j + 1)) + elseif iseven(j) && i > 1 + add_edge!(g, v, vertex_index(i - 1, j + 1)) + end + end + end + + return g +end + +""" + get_radius(lattice::LatticeType) -> Float64 + +Return the unit-disk radius used for the chosen lattice geometry. + +# Arguments +- `lattice::LatticeType`: Lattice family whose physical spacing determines the + unit-disk threshold. + +# Returns +- `Float64`: The interaction radius used when constructing unit-disk graphs. +""" get_radius(::Square) = 1.5 get_radius(::Triangular) = 1.1 +_triangular_grid_coordinates(nx::Int, ny::Int) = vec(Tuple{Int, Int}[(i, j) for i in 1:nx, j in 1:ny]) + +function _all_inner_subsets(inner_grid::SimpleGraph{Int}, k::Integer) + return [collect(subset) for subset in Combinatorics.combinations(vertices(inner_grid), k)] +end + +function _dedup_subsets_by_g6(inner_grid::SimpleGraph{Int}, subsets::Vector{Vector{Int}}) + seen = Set{String}() + deduped = Vector{Vector{Int}}() + for subset in subsets + subgraph, _ = Graphs.induced_subgraph(inner_grid, subset) + g6 = GraphIO.Graph6._graphToG6String(subgraph)[11:end] + if !(g6 in seen) + push!(seen, g6) + push!(deduped, subset) + end + end + return deduped +end + +""" + dedup_inner_subsets(inner_grid::SimpleGraph{Int}, k::Integer; use_shortg::Bool=true) -> Vector{Vector{Int}} + +Enumerate all `k`-vertex subsets of `inner_grid` and retain one representative +per class. + +When `use_shortg=true`, deduplication is by graph isomorphism via `shortg`. +When `use_shortg=false`, deduplication uses g6-string equality (same labeled +graph encoding). +""" +function dedup_inner_subsets(inner_grid::SimpleGraph{Int}, k::Integer; use_shortg::Bool=true) + 0 <= k <= nv(inner_grid) || throw(ArgumentError("subset size k must satisfy 0 <= k <= nv(inner_grid)")) + + subsets = _all_inner_subsets(inner_grid, k) + isempty(subsets) && return subsets + if !use_shortg + return _dedup_subsets_by_g6(inner_grid, subsets) + end + Sys.which("shortg") === nothing && throw(ArgumentError("use_shortg=true requires `shortg` in PATH")) + + subgraphs = SimpleGraph{Int}[] + for subset in subsets + subgraph, _ = Graphs.induced_subgraph(inner_grid, subset) + push!(subgraphs, subgraph) + end + + mapping_file = tempname() + temp_path = tempname() + + try + save_graph(subgraphs, temp_path) + _call_shortg(temp_path, mapping_file) + + canonical_to_original, _ = _parse_shortg_mapping(mapping_file) + return [subsets[canonical_to_original[idx][1]] for idx in sort!(collect(keys(canonical_to_original)))] + finally + isfile(mapping_file) && rm(mapping_file) + isfile(temp_path) && rm(temp_path) + end +end + get_physical_positions(::Square, pos::Vector{Tuple{Int, Int}}) = Vector{Tuple{Float64, Float64}}(pos) function get_physical_positions(::Triangular, pos::Vector{Tuple{Int, Int}}) h = sqrt(3) / 2 @@ -48,6 +190,21 @@ function get_inner_points(lattice::LatticeType, nx::Int, ny::Int) return get_physical_positions(lattice, vec(original_points)) end +""" + canonical_crossing_pins(top, bottom, left, right) + +Return the four boundary pin positions in the canonical crossing order used by +the unweighted crossing search: + +- pin `1`: left +- pin `2`: top +- pin `3`: right +- pin `4`: bottom +""" +function canonical_crossing_pins(top, bottom, left, right) + return [left, top, right, bottom] +end + """ complete_graph(n::Int) -> SimpleGraph @@ -133,26 +290,84 @@ function generate_full_grid_udg(lattice::LatticeType, nx::Int, ny::Int; path::St for top in top_candidates, bottom in bottom_candidates, left in left_candidates, right in right_candidates - selected = vcat([top, right, bottom, left], inner_points) + selected = vcat(canonical_crossing_pins(top, bottom, left, right), inner_points) g = unit_disk_graph(selected, radius) push!(results, (g, selected)) end - @info "pinset in generated graphs: [1,2,3,4]" + @info "pinset in generated graphs follows canonical crossing roles: [left, top, right, bottom]" return _process_and_save_graphs(results, path) end -function _call_shortg(temp_path::String, mapping_file::String) - if Sys.which("shortg") === nothing - # Make shortg optional: log and signal caller to fallback - @warn "Optional tool `shortg` not found; skipping canonicalization/dedup." - return false - else - @info "shortg found in PATH; running canonicalization" +""" + generate_triangular_udg_subsets(nx::Int, ny::Int; + subset_sizes=0:(nx * ny), + deduplicate::Bool=true, + use_shortg::Bool=true, + strict_dedup::Bool=false, + path::String="triangular_udg_subsets.g6") -> String + +Generate all triangular-lattice unit-disk graphs obtained by selecting subsets +of the `nx × ny` inner lattice sites and save them to disk. + +# Arguments +- `nx::Int`: Number of lattice columns in the inner triangular grid. +- `ny::Int`: Number of lattice rows in the inner triangular grid. +- `subset_sizes=0:(nx * ny)`: Iterable of subset sizes to enumerate. +- `deduplicate::Bool=true`: Whether to collapse isomorphic inner subsets before saving. +- `use_shortg::Bool=true`: Whether `shortg` may be used for deduplication when available. +- `strict_dedup::Bool=false`: When `true`, require `shortg` and throw if unavailable. +- `path::String="triangular_udg_subsets.g6"`: Output file path. + +# Returns +- `String`: The path where the generated dataset was written. +""" +function generate_triangular_udg_subsets( + nx::Int, + ny::Int; + subset_sizes=0:(nx * ny), + deduplicate::Bool=true, + use_shortg::Bool=true, + strict_dedup::Bool=false, + path::String="triangular_udg_subsets.g6", +) + inner_grid = triangular_lattice_graph(nx, ny) + grid_coords = _triangular_grid_coordinates(nx, ny) + physical_positions = get_physical_positions(Triangular(), grid_coords) + results = Tuple{SimpleGraph{Int}, Vector{Tuple{Float64, Float64}}}[] + shortg_available = Sys.which("shortg") !== nothing + do_shortg_dedup = deduplicate && use_shortg && shortg_available + + if strict_dedup && deduplicate && !do_shortg_dedup + throw(ArgumentError("strict_dedup=true requires use_shortg=true and `shortg` available in PATH")) + end + + if deduplicate && use_shortg && !shortg_available + @warn "`shortg` not found; falling back to g6-string deduplication." + end + + for k in subset_sizes + 0 <= k <= nv(inner_grid) || throw(ArgumentError("subset sizes must satisfy 0 <= k <= nx * ny")) + + subsets = deduplicate ? + dedup_inner_subsets(inner_grid, k; use_shortg=do_shortg_dedup) : + _all_inner_subsets(inner_grid, k) + + for subset in subsets + subset_positions = physical_positions[subset] + subset_graph = unit_disk_graph(subset_positions, get_radius(Triangular())) + push!(results, (subset_graph, subset_positions)) + end end + + save_graph(results, path) + return path +end + +function _call_shortg(temp_path::String, mapping_file::String) + @info "shortg found in PATH; running canonicalization" run(pipeline(`shortg -v -u $(temp_path)`, stderr=mapping_file)) - return true end function _process_and_save_graphs(results::Vector{Tuple{SimpleGraph{T}, Vector{Tuple{Float64, Float64}}}}, path::String) where T diff --git a/test/graphio/udg.jl b/test/graphio/udg.jl index 8bf623b..dc61cd4 100644 --- a/test/graphio/udg.jl +++ b/test/graphio/udg.jl @@ -2,6 +2,8 @@ using Test using GadgetSearch using Graphs +include(joinpath(pkgdir(GadgetSearch), "examples", "plot_triangular_lattices.jl")) + @testset "UDG Basic Types and Radius" begin @testset "Lattice Type Definitions" begin # Test that lattice types are concrete types @@ -109,6 +111,220 @@ end end end +@testset "Triangular Lattice Topology" begin + @testset "triangular_adjacency Function" begin + adjacency = GadgetSearch.triangular_adjacency + + known_neighbors = [ + ((1, 1), (2, 1)), + ((2, 2), (1, 2)), + ((2, 2), (2, 3)), + ((2, 2), (1, 3)), + ((2, 3), (2, 2)), + ((2, 3), (3, 2)), + ] + non_neighbors = [ + ((1, 1), (1, 1)), + ((1, 1), (3, 1)), + ((2, 2), (3, 3)), + ((2, 2), (2, 4)), + ] + + for ((i1, j1), (i2, j2)) in known_neighbors + @test adjacency(i1, j1, i2, j2) + @test adjacency(i2, j2, i1, j1) + end + + for ((i1, j1), (i2, j2)) in non_neighbors + @test !adjacency(i1, j1, i2, j2) + @test !adjacency(i2, j2, i1, j1) + end + end + + @testset "triangular_lattice_graph Function" begin + g11 = GadgetSearch.triangular_lattice_graph(1, 1) + @test nv(g11) == 1 + @test ne(g11) == 0 + + g22 = GadgetSearch.triangular_lattice_graph(2, 2) + @test nv(g22) == 4 + @test ne(g22) == 5 + @test has_edge(g22, 1, 2) + @test has_edge(g22, 1, 3) + @test has_edge(g22, 1, 4) + @test has_edge(g22, 2, 4) + @test has_edge(g22, 3, 4) + @test !has_edge(g22, 2, 3) + + coords = vec(Tuple{Int, Int}[(x, y) for x in 1:3, y in 1:2]) + physical = GadgetSearch.get_physical_positions(Triangular(), coords) + udg = GadgetSearch.unit_disk_graph(physical, get_radius(Triangular())) + g32 = GadgetSearch.triangular_lattice_graph(3, 2) + @test nv(g32) == nv(udg) + @test ne(g32) == ne(udg) + @test all(has_edge(udg, src(edge), dst(edge)) for edge in edges(g32)) + @test all(has_edge(g32, src(edge), dst(edge)) for edge in edges(udg)) + + gempty = GadgetSearch.triangular_lattice_graph(0, 3) + @test nv(gempty) == 0 + @test ne(gempty) == 0 + end +end + +@testset "Triangular UDG Subsets" begin + @testset "dedup_inner_subsets Function" begin + inner_grid = GadgetSearch.triangular_lattice_graph(2, 2) + + subsets_without_shortg = GadgetSearch.dedup_inner_subsets(inner_grid, 2; use_shortg=false) + @test length(subsets_without_shortg) == 2 + @test all(length(subset) == 2 for subset in subsets_without_shortg) + induced_edges_no_shortg = sort(unique(ne(Graphs.induced_subgraph(inner_grid, subset)[1]) for subset in subsets_without_shortg)) + @test induced_edges_no_shortg == [0, 1] + + if Sys.which("shortg") === nothing + @test_throws ArgumentError GadgetSearch.dedup_inner_subsets(inner_grid, 2; use_shortg=true) + else + subsets_with_shortg = GadgetSearch.dedup_inner_subsets(inner_grid, 2; use_shortg=true) + @test length(subsets_with_shortg) == 2 + induced_edges = sort(unique(ne(Graphs.induced_subgraph(inner_grid, subset)[1]) for subset in subsets_with_shortg)) + @test induced_edges == [0, 1] + end + end + + @testset "dedup_inner_subsets validation" begin + inner_grid = GadgetSearch.triangular_lattice_graph(2, 2) + @test_throws ArgumentError GadgetSearch.dedup_inner_subsets(inner_grid, -1; use_shortg=false) + @test_throws ArgumentError GadgetSearch.dedup_inner_subsets(inner_grid, 5; use_shortg=false) + if Sys.which("shortg") === nothing + @test_throws ArgumentError GadgetSearch.dedup_inner_subsets(inner_grid, 2; use_shortg=true) + end + end + + @testset "generate_triangular_udg_subsets Integration" begin + temp_file = tempname() * ".g6" + result = generate_triangular_udg_subsets(2, 1; subset_sizes=1:2, deduplicate=false, path=temp_file) + + @test result == temp_file + dataset = GraphDataset(temp_file) + @test dataset.n == 3 + @test map(length, dataset.layouts) == [1, 1, 2] + + loader = GraphLoader(temp_file) + edge_counts = sort([ne(loader[idx]) for idx in 1:length(loader)]) + @test edge_counts == [0, 0, 1] + + rm(temp_file) + end + + @testset "generate_triangular_udg_subsets zero subset and validation" begin + temp_file = tempname() * ".g6" + try + result = generate_triangular_udg_subsets( + 2, + 1; + subset_sizes=0:1, + deduplicate=true, + use_shortg=false, + path=temp_file, + ) + + @test result == temp_file + dataset = GraphDataset(temp_file) + @test dataset.n == 2 + @test dataset.layouts[1] === nothing + @test map(length, dataset.layouts[2:2]) == [1] + finally + isfile(temp_file) && rm(temp_file) + end + + @test_throws ArgumentError generate_triangular_udg_subsets( + 2, + 1; + subset_sizes=[-1], + deduplicate=false, + path=tempname() * ".g6", + ) + + if Sys.which("shortg") === nothing + @test_throws ArgumentError generate_triangular_udg_subsets( + 2, + 1; + subset_sizes=1:2, + deduplicate=true, + use_shortg=true, + strict_dedup=true, + path=tempname() * ".g6", + ) + end + + @test_throws ArgumentError generate_triangular_udg_subsets( + 2, + 1; + subset_sizes=1:2, + deduplicate=true, + use_shortg=false, + strict_dedup=true, + path=tempname() * ".g6", + ) + end + + @testset "generate_triangular_udg_subsets dedup strategy behavior" begin + raw_file = tempname() * ".g6" + dedup_file = tempname() * ".g6" + try + generate_triangular_udg_subsets( + 2, + 2; + subset_sizes=[2], + deduplicate=false, + use_shortg=false, + path=raw_file, + ) + generate_triangular_udg_subsets( + 2, + 2; + subset_sizes=[2], + deduplicate=true, + use_shortg=false, + path=dedup_file, + ) + + raw_dataset = GraphDataset(raw_file) + dedup_dataset = GraphDataset(dedup_file) + + @test raw_dataset.n == 6 + @test dedup_dataset.n == 2 + @test dedup_dataset.n <= raw_dataset.n + @test all(layout -> layout !== nothing && length(layout) == 2, dedup_dataset.layouts) + + dedup_loader = GraphLoader(dedup_file) + dedup_edge_counts = sort([ne(dedup_loader[idx]) for idx in 1:length(dedup_loader)]) + @test dedup_edge_counts == [0, 1] + finally + isfile(raw_file) && rm(raw_file) + isfile(dedup_file) && rm(dedup_file) + end + end +end + +@testset "Triangular lattice example smoke test" begin + mktempdir() do tmpdir + lattice_paths = plot_lattice_examples(tmpdir) + @test all(isfile, lattice_paths) + @test all(path -> filesize(path) > 0, lattice_paths) + + dataset_path = ensure_subset_dataset(tmpdir) + @test isfile(dataset_path) + @test filesize(dataset_path) > 0 + + subset_paths = plot_subset_examples(dataset_path; outdir=tmpdir, nplots=2) + @test length(subset_paths) == 2 + @test all(isfile, subset_paths) + + @test_nowarn main(; outdir=tmpdir, nplots=2) + end +end + @testset "get_pin_positions Function" begin @testset "Basic Pin Positioning" begin square = Square() @@ -153,6 +369,16 @@ end end end +@testset "canonical_crossing_pins Function" begin + top = (10.0, 1.0) + bottom = (10.0, 9.0) + left = (1.0, 5.0) + right = (19.0, 5.0) + + pins = GadgetSearch.canonical_crossing_pins(top, bottom, left, right) + @test pins == [left, top, right, bottom] +end + @testset "get_inner_points Function" begin @testset "Basic Inner Points" begin square = Square() @@ -212,6 +438,17 @@ end rm(temp_file) end end + + @testset "Canonical crossing pin order" begin + square = Square() + top, bottom, left, right = GadgetSearch.get_pin_positions(square, 1, 1) + pins = GadgetSearch.canonical_crossing_pins(top[1], bottom[1], left[1], right[1]) + + @test pins[1] == left[1] + @test pins[2] == top[1] + @test pins[3] == right[1] + @test pins[4] == bottom[1] + end @testset "Parameter Validation" begin square = Square()