Skip to content

Commit 776e855

Browse files
GiggleLiuclaude
andauthored
Close Julia parity test gaps: BicliqueCover, BMF, SAT→CircuitSAT, reduction paths (#75)
* update makefile * feat: implement SAT -> CircuitSAT reduction with tests Converts CNF formulas into boolean circuits by creating an OR gate per clause and a final AND gate constrained to true. Part of Julia parity gaps (issue #67). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add BicliqueCover and BMF Julia parity test fixtures Add export functions for BicliqueCover and BMF in the Julia test data generator, with custom flat-config evaluation logic matching the Rust implementation (since the Julia ProblemReductions.jl package uses incompatible vector-of-vectors / BitMatrix interfaces for these types). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add BicliqueCover and BMF Julia parity tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add reduction-path parity tests matching Julia's test/reduction_path.jl Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Factoring → SpinGlass chained reduction example Mirrors Julia's examples/Ising.jl — reduces Factoring to SpinGlass via the reduction graph, solves, and extracts factors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: regenerate reduction graph with SAT→CircuitSAT edge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add reduction path Julia fixtures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: pin random seed in Julia fixture generation Add Random.seed!(42) so re-running generate_testdata.jl produces identical evaluation configs. Reduction path fixtures (deterministic) are skipped if they already exist to avoid slow BruteForce re-solves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix example * fix: adapt reduction path tests to new graph constructor API Update MaxCut::unweighted calls to pass SimpleGraph instead of (num_vertices, edges) following the constructor refactor in #74. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: revert unrelated Makefile change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert "chore: revert unrelated Makefile change" This reverts commit 1957aeb. * refactor: replace name-level path finding with variant-level API Remove find_paths<S,T>, find_paths_by_name, find_shortest_path<S,T>, and find_shortest_path_by_name. Replace with variant-level equivalents: - find_all_paths(src, src_variant, dst, dst_variant) - find_shortest_path(src, src_variant, dst, dst_variant) All path-finding now operates on exact variant nodes, consistent with find_cheapest_path. Update design.md and all tests accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove redundant cost functions and find_shortest_path Remove find_shortest_path (duplicate of find_cheapest_path with MinimizeSteps). Remove MinimizeWeighted, MinimizeMax, and MinimizeLexicographic cost functions. Keep Minimize, MinimizeSteps, and CustomCost. Update design.md and all tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5320302 commit 776e855

40 files changed

Lines changed: 978 additions & 390 deletions

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,9 @@ run-plan:
176176
@NL=$$'\n'; \
177177
BRANCH=$$(git branch --show-current); \
178178
if [ "$(AGENT_TYPE)" = "claude" ]; then \
179-
PROCESS="1. Read the plan file$${NL}2. Use /subagent-driven-development to execute tasks$${NL}3. Push: git push origin $$BRANCH$${NL}4. Post summary"; \
179+
PROCESS="1. Read the plan file$${NL}2. Use /subagent-driven-development to execute tasks$${NL}3. Push: git push origin $$BRANCH$${NL}4. Create a pull request"; \
180180
else \
181-
PROCESS="1. Read the plan file$${NL}2. Execute the tasks step by step. For each task, implement and test before moving on.$${NL}3. Push: git push origin $$BRANCH$${NL}4. Post summary"; \
181+
PROCESS="1. Read the plan file$${NL}2. Execute the tasks step by step. For each task, implement and test before moving on.$${NL}3. Push: git push origin $$BRANCH$${NL}4. Create a pull request"; \
182182
fi; \
183183
PROMPT="Execute the plan in '$${PLAN_FILE}'."; \
184184
if [ -n "$(INSTRUCTIONS)" ]; then \

docs/src/design.md

Lines changed: 55 additions & 149 deletions
Large diffs are not rendered by default.

docs/src/reductions/reduction_graph.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,17 @@
757757
],
758758
"doc_path": "rules/spinglass_qubo/index.html"
759759
},
760+
{
761+
"source": 21,
762+
"target": 0,
763+
"overhead": [
764+
{
765+
"field": "num_variables",
766+
"formula": "num_variables + num_clauses + 1"
767+
}
768+
],
769+
"doc_path": "rules/sat_circuitsat/index.html"
770+
},
760771
{
761772
"source": 21,
762773
"target": 3,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// # Chained Reduction: Factoring -> SpinGlass
2+
//
3+
// Mirrors Julia's examples/Ising.jl — reduces a Factoring problem
4+
// to SpinGlass via the reduction graph, then solves and extracts the factors.
5+
// Uses ILPSolver for the solve step (Julia uses GenericTensorNetworks).
6+
7+
// ANCHOR: imports
8+
use problemreductions::prelude::*;
9+
use problemreductions::rules::{MinimizeSteps, ReductionGraph};
10+
use problemreductions::solvers::ILPSolver;
11+
use problemreductions::topology::SimpleGraph;
12+
use problemreductions::types::ProblemSize;
13+
// ANCHOR_END: imports
14+
15+
pub fn run() {
16+
// ANCHOR: example
17+
let graph = ReductionGraph::new();
18+
19+
// Find reduction path: Factoring -> ... -> SpinGlass
20+
let src_var = ReductionGraph::variant_to_map(&Factoring::variant());
21+
let dst_var =
22+
ReductionGraph::variant_to_map(&SpinGlass::<SimpleGraph, f64>::variant());
23+
let rpath = graph
24+
.find_cheapest_path(
25+
"Factoring",
26+
&src_var,
27+
"SpinGlass",
28+
&dst_var,
29+
&ProblemSize::new(vec![]),
30+
&MinimizeSteps,
31+
)
32+
.unwrap();
33+
println!("Reduction path: {:?}", rpath.type_names());
34+
35+
// Create: factor 6 = p × q with 2-bit factors (mirrors Julia's Factoring(2, 2, 6))
36+
let factoring = Factoring::new(2, 2, 6);
37+
38+
// Solve Factoring via ILP
39+
let solver = ILPSolver::new();
40+
let solution = solver.solve_reduced(&factoring).unwrap();
41+
42+
// Extract and display the factors
43+
let (p, q) = factoring.read_factors(&solution);
44+
println!("{} = {} × {}", factoring.target(), p, q);
45+
assert_eq!(p * q, 6, "Factors should multiply to 6");
46+
// ANCHOR_END: example
47+
}
48+
49+
fn main() {
50+
run()
51+
}

scripts/jl/generate_testdata.jl

Lines changed: 237 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Generate JSON test fixtures from ProblemReductions.jl for Rust parity testing.
33
# Run: cd scripts/jl && julia --project=. generate_testdata.jl
44

5-
using ProblemReductions, Graphs, JSON
5+
using ProblemReductions, Graphs, JSON, Random
66

77
const OUTDIR = joinpath(@__DIR__, "..", "..", "tests", "data", "jl")
88
mkpath(OUTDIR)
@@ -274,6 +274,165 @@ function export_setcovering(sc, label)
274274
return make_instance(label, inst, sc)
275275
end
276276

277+
"""Evaluate BicliqueCover with flat binary config (matching Rust convention).
278+
279+
Config layout: for each vertex v (0-based) and biclique b (0-based),
280+
config[v*k + b + 1] == 1 means vertex v is in biclique b.
281+
Returns (is_valid, size) where is_valid means all edges covered,
282+
and size is the total number of 1s in the config.
283+
"""
284+
function biclique_cover_evaluate(left_size, right_size, edges_0based, k, config)
285+
n = left_size + right_size
286+
# Check all edges are covered
287+
for (l, r) in edges_0based
288+
covered = false
289+
for b in 0:k-1
290+
l_in = config[l * k + b + 1] == 1
291+
r_in = config[r * k + b + 1] == 1
292+
if l_in && r_in
293+
covered = true
294+
break
295+
end
296+
end
297+
if !covered
298+
return (false, 0)
299+
end
300+
end
301+
return (true, sum(config))
302+
end
303+
304+
function export_biclique_cover(graph, left_part, k, label)
305+
left_size = length(left_part)
306+
right_size = nv(graph) - left_size
307+
edges_0 = graph_to_edges(graph)
308+
n = nv(graph)
309+
num_vars = n * k
310+
311+
inst = Dict(
312+
"num_vertices" => n,
313+
"edges" => edges_0,
314+
"left_size" => left_size,
315+
"right_size" => right_size,
316+
"k" => k,
317+
)
318+
319+
# Sample configs
320+
configs = Set{Vector{Int}}()
321+
push!(configs, zeros(Int, num_vars))
322+
push!(configs, ones(Int, num_vars))
323+
while length(configs) < min(10, 2^num_vars)
324+
push!(configs, [rand(0:1) for _ in 1:num_vars])
325+
end
326+
configs = collect(configs)
327+
328+
# Evaluate configs
329+
evals = []
330+
for cfg in configs
331+
(valid, sz) = biclique_cover_evaluate(left_size, right_size, edges_0, k, cfg)
332+
push!(evals, Dict("config" => cfg, "is_valid" => valid, "size" => valid ? sz : 0))
333+
end
334+
335+
# Brute force: find all best (minimize size among valid covers)
336+
best_size = typemax(Int)
337+
best_configs = Vector{Int}[]
338+
for bits in 0:(2^num_vars - 1)
339+
cfg = [(bits >> i) & 1 for i in 0:num_vars-1]
340+
(valid, sz) = biclique_cover_evaluate(left_size, right_size, edges_0, k, cfg)
341+
if valid
342+
if sz < best_size
343+
best_size = sz
344+
best_configs = [cfg]
345+
elseif sz == best_size
346+
push!(best_configs, cfg)
347+
end
348+
end
349+
end
350+
351+
return Dict(
352+
"label" => label,
353+
"instance" => inst,
354+
"evaluations" => evals,
355+
"best_solutions" => best_configs,
356+
)
357+
end
358+
359+
"""Evaluate BMF with flat binary config (matching Rust convention).
360+
361+
Config layout: first m*k bits are B (row-major), next k*n bits are C (row-major).
362+
Returns hamming distance between A and boolean_product(B, C).
363+
All configs are valid.
364+
"""
365+
function bmf_evaluate(A, m, n, k, config)
366+
# Extract B (m x k)
367+
B = zeros(Bool, m, k)
368+
for i in 1:m, j in 1:k
369+
B[i,j] = config[(i-1)*k + j] == 1
370+
end
371+
b_size = m * k
372+
# Extract C (k x n)
373+
C = zeros(Bool, k, n)
374+
for i in 1:k, j in 1:n
375+
C[i,j] = config[b_size + (i-1)*n + j] == 1
376+
end
377+
# Boolean product
378+
product = zeros(Bool, m, n)
379+
for i in 1:m, j in 1:n
380+
product[i,j] = any(kk -> B[i,kk] && C[kk,j], 1:k)
381+
end
382+
# Hamming distance
383+
return sum(A .!= product)
384+
end
385+
386+
function export_bmf(A, k, label)
387+
m, n = size(A)
388+
num_vars = m * k + k * n
389+
390+
inst = Dict(
391+
"matrix" => [[Int(A[i,j]) for j in 1:n] for i in 1:m],
392+
"m" => m,
393+
"n" => n,
394+
"k" => k,
395+
)
396+
397+
# Sample configs
398+
configs = Set{Vector{Int}}()
399+
push!(configs, zeros(Int, num_vars))
400+
push!(configs, ones(Int, num_vars))
401+
while length(configs) < min(10, 2^num_vars)
402+
push!(configs, [rand(0:1) for _ in 1:num_vars])
403+
end
404+
configs = collect(configs)
405+
406+
# Evaluate configs
407+
evals = []
408+
for cfg in configs
409+
dist = bmf_evaluate(A, m, n, k, cfg)
410+
# All configs are valid for BMF; size = hamming distance
411+
push!(evals, Dict("config" => cfg, "is_valid" => true, "size" => dist))
412+
end
413+
414+
# Brute force: find all best (minimize hamming distance)
415+
best_dist = typemax(Int)
416+
best_configs = Vector{Int}[]
417+
for bits in 0:(2^num_vars - 1)
418+
cfg = [(bits >> i) & 1 for i in 0:num_vars-1]
419+
dist = bmf_evaluate(A, m, n, k, cfg)
420+
if dist < best_dist
421+
best_dist = dist
422+
best_configs = [cfg]
423+
elseif dist == best_dist
424+
push!(best_configs, cfg)
425+
end
426+
end
427+
428+
return Dict(
429+
"label" => label,
430+
"instance" => inst,
431+
"evaluations" => evals,
432+
"best_solutions" => best_configs,
433+
)
434+
end
435+
277436
# ── reduction exports ────────────────────────────────────────────────
278437

279438
function export_reduction(source, target_type, source_label)
@@ -306,6 +465,7 @@ end
306465
# ── main ─────────────────────────────────────────────────────────────
307466

308467
function main()
468+
Random.seed!(42) # pin seed so re-runs produce identical fixtures
309469
println("Generating Julia parity test data...")
310470

311471
# ── Build test instances (matching Julia test/rules/rules.jl) ──
@@ -370,6 +530,16 @@ function main()
370530
# SetCovering docstring
371531
doc_sc = SetCovering([[1, 2, 3], [2, 4], [1, 4]], [1, 2, 3])
372532

533+
# BicliqueCover: 6-vertex bipartite graph, 2 bicliques (from Julia test)
534+
doc_bc_graph = SimpleGraph(6)
535+
for (i,j) in [(1,5), (1,4), (2,5), (2,4), (3,6)]
536+
add_edge!(doc_bc_graph, i, j)
537+
end
538+
doc_bc = BicliqueCover(doc_bc_graph, [1,2,3], 2)
539+
540+
# BMF: 3x3 all-ones matrix, rank 2 (from Julia test)
541+
doc_bmf = BinaryMatrixFactorization(trues(3, 3), 2)
542+
373543
# ── Individual rule test instances (from test/rules/*.jl) ──
374544
rule_graph4 = SimpleGraph(Graphs.SimpleEdge.([(1, 2), (1, 3), (3, 4), (2, 3)]))
375545

@@ -547,6 +717,16 @@ function main()
547717
export_setcovering(doc_sc, "doc_3subsets"),
548718
]))
549719

720+
# BicliqueCover
721+
write_fixture("biclique_cover.json", model_fixture("BicliqueCover", [
722+
export_biclique_cover(doc_bc_graph, [1,2,3], 2, "doc_6vertex"),
723+
]))
724+
725+
# BMF
726+
write_fixture("bmf.json", model_fixture("BMF", [
727+
export_bmf(trues(3, 3), 2, "doc_3x3_ones"),
728+
]))
729+
550730
# ── Export reduction fixtures ──
551731
println("Exporting reduction fixtures...")
552732

@@ -622,6 +802,62 @@ function main()
622802
write_fixture(filename, data)
623803
end
624804

805+
# ── Export reduction path fixtures (deterministic, skip if already exist) ──
806+
println("Exporting reduction path fixtures...")
807+
808+
g = reduction_graph()
809+
810+
if !isfile(joinpath(OUTDIR, "path_maxcut_to_spinglass.json"))
811+
# MaxCut → SpinGlass path
812+
mc_source = MaxCut(smallgraph(:petersen))
813+
mc_paths = reduction_paths(g, MaxCut, SpinGlass)
814+
mc_res = reduceto(mc_paths[1], mc_source)
815+
mc_best_source = findbest(mc_source, BruteForce())
816+
mc_best_target = findbest(target_problem(mc_res), BruteForce())
817+
mc_extracted = sort(unique(extract_solution.(Ref(mc_res), mc_best_target)))
818+
819+
write_fixture("path_maxcut_to_spinglass.json", Dict(
820+
"path" => string.(typeof.(mc_paths[1].nodes)),
821+
"best_source" => mc_best_source,
822+
"best_target" => mc_best_target,
823+
"extracted" => mc_extracted,
824+
))
825+
826+
# MaxCut → QUBO path (uses same mc_source/mc_best_source)
827+
mc_qubo_paths = reduction_paths(g, MaxCut, QUBO)
828+
mc_qubo_res = reduceto(mc_qubo_paths[1], mc_source)
829+
mc_qubo_best_target = findbest(target_problem(mc_qubo_res), BruteForce())
830+
mc_qubo_extracted = sort(unique(extract_solution.(Ref(mc_qubo_res), mc_qubo_best_target)))
831+
832+
write_fixture("path_maxcut_to_qubo.json", Dict(
833+
"path" => string.(typeof.(mc_qubo_paths[1].nodes)),
834+
"best_source" => mc_best_source,
835+
"extracted" => mc_qubo_extracted,
836+
))
837+
else
838+
println(" skipping path_maxcut_to_*.json (already exist)")
839+
end
840+
841+
if !isfile(joinpath(OUTDIR, "path_factoring_to_spinglass.json"))
842+
# Factoring → SpinGlass path (slow BruteForce on SpinGlass target)
843+
fact = Factoring(2, 1, 3)
844+
fact_paths = reduction_paths(g, Factoring, SpinGlass)
845+
fact_res = reduceto(fact_paths[1], fact)
846+
fact_best_target = findbest(target_problem(fact_res), BruteForce())
847+
fact_extracted = sort(unique(filter(
848+
sol -> solution_size(fact, sol) == SolutionSize(0, true),
849+
extract_solution.(Ref(fact_res), fact_best_target)
850+
)))
851+
852+
write_fixture("path_factoring_to_spinglass.json", Dict(
853+
"path" => string.(typeof.(fact_paths[1].nodes)),
854+
"best_source" => findbest(fact, BruteForce()),
855+
"extracted" => fact_extracted,
856+
))
857+
else
858+
println(" skipping path_factoring_to_spinglass.json (already exists)")
859+
end
860+
625861
println("Done! Generated fixtures in $OUTDIR")
626862
end
627863

0 commit comments

Comments
 (0)