Skip to content

Commit 9e9b46a

Browse files
committed
feat(bench): add Hen 2026 planted-solution factorization Ising/QUBO benchmark
Implements the integer-factorization construction of Hen, "Planted-solution SAT and Ising benchmarks from integer factorization", arXiv:2604.09837 (2026) so QQA4CO can be exercised against problems whose ground state is known *by construction*. The semi-prime N = p * q is encoded by long multiplication into a circuit of AND/XOR clauses, each compiled to the three- or four-spin energy gadgets of Eqs. 10-11 (verified directly by the new spectrum tests). The bits of N and the leading bits of p, q are pinned and folded into a constant offset, so the resulting QUBO is strictly free-variable and the planted assignment evaluates to ``loss = 0``. * `src/qqa/problems/factorization.py` - `IntegerFactorizationIsing(p, q)` — full QUBOProblem with `loss_fn`, `score_summary`, decoded-factor reporting (`p_hat`, `q_hat`, `N_hat`, `is_ground_state`, `decoded_N_correct`). - Half-adder reduction fuses the carry and XOR auxiliary spins, cutting variable count by ~30% relative to the naive encoding. - Ising -> QUBO conversion is documented in-source with the algebra that produced the `4 * J_sym` off-diagonal coefficient. - Helpers: `_is_probable_prime` (Miller-Rabin), `random_prime`, `random_semiprime`, `random_factorization_problems`. * `tests/test_factorization.py` (39 tests): - Number-theory helpers, planted-solution invariants (`loss == 0`, decoder round-trip, gap >= 2), - **`test_and_gadget_full_spectrum`** and **`test_xor_gadget_full_spectrum`** check the per-clause Ising energies against Eqs. 10/11 byte-for-byte, - End-to-end `qqa.anneal` smoke test that recovers (p, q) for small N. * `scripts/bench_factorization.py` reports both `ground_state_count` (E = 0) and `decoded_n_count` (factors multiply to N) so partial successes are visible. * `data/factorization/README.md` documents the construction, the spin-count scaling table, and the BibTeX citation. * README.md / docs/problems.md add a top-level section + the `@article{hen2026planted, ...}` entry under the `## Cite` block.
1 parent c839c51 commit 9e9b46a

8 files changed

Lines changed: 1223 additions & 0 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,31 @@ See [`data/discs/README.md`](data/discs/README.md) for the full layout,
435435
optional Hugging Face source, the `qqa.datasets.discs_*` Python loaders,
436436
and the batched-instance (`parallel=True`) API.
437437

438+
### Planted-solution factorization Ising/QUBO benchmark (Hen 2026)
439+
440+
A second, complementary benchmark is the **planted-solution
441+
factorization Ising** of Hen, *Planted-solution SAT and Ising
442+
benchmarks from integer factorization* (arXiv:2604.09837, 2026). The
443+
construction encodes ``N = p · q`` as a circuit of AND/XOR Ising
444+
gadgets whose unique ground state is the bit-string of ``(p, q)``, so
445+
**every solver claim is verifiable** against the known optimum.
446+
447+
```python
448+
import qqa
449+
prob = qqa.IntegerFactorizationIsing(p=11, q=13) # N = 143
450+
result = qqa.anneal(prob, sol_size=500, num_epochs=3000,
451+
learning_rate=0.5,
452+
schedule=qqa.LinearBGSchedule(min_bg=-2, max_bg=0.5))
453+
print(result.score["extra"]["p_hat"], result.score["extra"]["q_hat"])
454+
```
455+
456+
```bash
457+
uv run python scripts/bench_factorization.py --bits 4 --instances 5
458+
```
459+
460+
See [`data/factorization/README.md`](data/factorization/README.md) for
461+
the construction details, scaling table, and the citation.
462+
438463
## Streamlit dashboard
439464

440465
```bash
@@ -716,3 +741,21 @@ Reference implementation: <https://github.com/google-research/discs>.
716741
The unified ``data/discs/`` layout (one ``.gpickle`` per instance plus a
717742
``manifest.jsonl`` sidecar) is QQA4CO-specific and described in
718743
[`data/discs/README.md`](data/discs/README.md).
744+
745+
If you use the **planted-solution factorization Ising benchmark**
746+
(`qqa.IntegerFactorizationIsing`, `scripts/bench_factorization.py`,
747+
`data/factorization/`), please cite the paper that introduced the
748+
construction and the gadget formulas this implementation follows:
749+
750+
```bibtex
751+
@article{hen2026planted,
752+
title = {Planted-solution {SAT} and Ising benchmarks from integer factorization},
753+
author = {Hen, Itay},
754+
journal = {arXiv preprint arXiv:2604.09837},
755+
year = {2026}
756+
}
757+
```
758+
759+
Reference SAT/Ising compiler: <https://github.com/itay-hen/pq-SAT-benchmark>.
760+
The QQA4CO implementation in `src/qqa/problems/factorization.py` is
761+
independent and follows Eqs. 10–12 of the paper directly.

data/factorization/README.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Planted-solution factorization Ising/QUBO benchmark
2+
3+
Implementation of the **planted-solution Ising / QUBO benchmark from
4+
integer factorization** introduced by:
5+
6+
> Itay Hen, *Planted-solution SAT and Ising benchmarks from integer
7+
> factorization*, **arXiv:2604.09837** (2026).
8+
9+
The construction encodes the long-multiplication identity ``N = p · q``
10+
as a Boolean circuit of AND and XOR gates, then lowers each gate to one
11+
of the two Ising **energy gadgets** of the paper (Eqs. 10 and 11). The
12+
resulting QUBO has a **provably optimal, planted ground state** equal to
13+
the bits of ``(p, q)`` — making it an unusually clean benchmark for any
14+
discrete optimiser (SAT solvers, simulated / quantum annealers,
15+
gradient-based samplers, …) because every reported solution can be
16+
*verified* against the known minimum.
17+
18+
Unlike random ``k``-SAT (where the ground state energy is unknown and
19+
solver "successes" cannot be cross-checked), every instance here has
20+
21+
* a known optimum ``H(s*) = 0`` (after offset normalisation),
22+
* a single tunable difficulty knob: the bit-length ``d = max(n_p, n_q)``,
23+
* a strictly positive spectral gap ``≥ 2`` between the planted optimum
24+
and any other configuration.
25+
26+
## Quick start
27+
28+
The data is generated at runtime; no download needed.
29+
30+
```bash
31+
# Tiny smoke (4-bit primes, ~50 free spins; CPU, < 30 s)
32+
uv run python scripts/bench_factorization.py --bits 4 --instances 5
33+
34+
# Larger sweep on GPU
35+
uv run python scripts/bench_factorization.py --bits 6 --instances 10 \
36+
--device cuda --sol-size 1000 --num-epochs 5000 \
37+
--output bench_factorization_d6.json
38+
```
39+
40+
Or programmatically:
41+
42+
```python
43+
import qqa
44+
45+
# Single instance with known factors
46+
prob = qqa.IntegerFactorizationIsing(p=11, q=13) # N = 143
47+
print(prob.num_nodes, prob.num_and, prob.num_xor) # ~150 spins
48+
49+
# Random benchmark suite
50+
suite = qqa.random_factorization_problems(
51+
bit_length=5, num_instances=10, seed=0,
52+
)
53+
54+
# Solve with QQA
55+
result = qqa.anneal(
56+
prob,
57+
sol_size=500, num_epochs=3000, learning_rate=0.5,
58+
schedule=qqa.LinearBGSchedule(min_bg=-2, max_bg=0.5),
59+
curve_rate=4, div_param=0.2,
60+
)
61+
print(result.score["extra"]["p_hat"], result.score["extra"]["q_hat"])
62+
```
63+
64+
`prob.score_summary(x)` reports the residual energy above the planted
65+
optimum, the **decoded** factor pair ``(p̂, q̂)``, ``N̂ = p̂ · q̂``, and
66+
the bit-Hamming distance to ``s*``. ``score['feasible']`` is `True` iff
67+
the optimiser reached the planted ground state and ``p̂ · q̂ = N``.
68+
69+
## What the construction looks like inside
70+
71+
| Pipeline step | Where in the code |
72+
|-------------------------------------------------|--------------------------------------------|
73+
| Random equal-bit-length prime sampling | `random_prime`, `random_semiprime` |
74+
| Long-multiplication → AND/XOR clause graph | `_compile` |
75+
| AND gadget (Eq. 10), XOR gadget (Eq. 11) | `_add_and_gadget`, `_add_xor_gadget` |
76+
| Pin substitution + Ising → QUBO | `_build_qubo_from_compilation` |
77+
| Public ``qqa.IntegerFactorizationIsing`` | top of `src/qqa/problems/factorization.py` |
78+
| End-to-end smoke + planted-invariant tests | `tests/test_factorization.py` |
79+
80+
The implementation is **gadget-faithful** to the paper but does *not*
81+
ship the preprocessing pipeline of Sec. III. Problem sizes therefore
82+
scale as ``O(d^4)`` in the symmetric case ``n_p = n_q = d``:
83+
84+
| ``d`` | free spins (typical) | suitable for |
85+
|-------|----------------------|--------------|
86+
| 2–3 | 16 – 40 | CPU smoke |
87+
| 4–5 | 100 – 250 | CPU bench |
88+
| 6–7 | 500 – 1 500 | single-GPU |
89+
| ≥ 8 | ≥ 3 000 | multi-GPU / preprocessing |
90+
91+
Concrete sizes (``carry`` and XOR-aux fused per Eq. 12 footnote — see
92+
``_compile`` in `src/qqa/problems/factorization.py`):
93+
94+
| ``N`` | ``(p, q)`` | free spins | AND clauses | XOR clauses |
95+
|--------|-----------|------------|-------------|-------------|
96+
| 15 | (3, 5) | 16 | 12 | 6 |
97+
| 35 | (5, 7) | 37 | 24 | 15 |
98+
| 143 | (11, 13) | 102 | 60 | 44 |
99+
| 221 | (13, 17) | 148 | 85 | 65 |
100+
| 667 | (23, 29) | 213 | 120 | 95 |
101+
102+
For larger ``d`` the upstream paper's preprocessing is essential; an
103+
optional pin-propagation / AND-XOR simplification pass is a natural
104+
follow-up that would slot directly into ``_build_qubo_from_compilation``.
105+
106+
## What QQA can do today
107+
108+
The construction is **correct for any ``(p, q)``**: ``planted_x``
109+
evaluates to exactly ``loss = 0``, any single bit-flip raises the
110+
loss by at least 2 (the gadget gap; see
111+
`tests/test_factorization.py::test_planted_is_strict_minimum`), and the
112+
gadget energy tables match Eqs. (10)–(11) of the paper element-wise
113+
(`test_and_gadget_full_spectrum`, `test_xor_gadget_full_spectrum`).
114+
115+
The bench script reports **two** orthogonal success metrics:
116+
117+
* `gs` (ground state) — `loss == 0`, i.e. the entire spin string sits
118+
on the planted optimum. This is the *strong* contract.
119+
* `decoded_N` — the bits on the input wires `p_0..p_{n_p-1}` and
120+
`q_0..q_{n_q-1}` decode to a pair `(p̂, q̂)` with `p̂ · q̂ = N`,
121+
even if internal pp/sum/carry spins are still inconsistent (so
122+
`loss > 0`). This is the *weak* contract: the optimiser found the
123+
factorisation but did not finish the auxiliary bookkeeping.
124+
125+
Empirically, the default ``qqa.anneal`` recipe consistently reaches
126+
`gs` for ``d ≤ 3`` (``N ≤ 7·7``) and gets `decoded_N` for ``d = 4``
127+
(``N ≈ 11·13``) within a few seconds on CPU; ``d ≥ 5`` benefits from
128+
larger ``sol_size`` and ``num_epochs`` and may still leave a non-zero
129+
gap, exactly as the paper predicts (``T_med`` grows as ``2^{β d}``
130+
with ``β ≈ 1``). This is the *whole point* of the benchmark: it is
131+
genuinely difficult for general-purpose CO solvers even at moderate
132+
``d``.
133+
134+
## Citation
135+
136+
Please cite the original paper:
137+
138+
```bibtex
139+
@article{hen2026planted,
140+
title = {Planted-solution {SAT} and Ising benchmarks from integer factorization},
141+
author = {Hen, Itay},
142+
journal = {arXiv preprint arXiv:2604.09837},
143+
year = {2026},
144+
}
145+
```
146+
147+
The reference SAT/Ising compiler is open source at
148+
<https://github.com/itay-hen/pq-SAT-benchmark>; the QQA4CO
149+
implementation here is independent and follows the published gadget
150+
formulas (Eqs. 10–12) directly.

docs/problems.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,28 @@ The batched path is supported by ``qqa.anneal`` only — SA / PA backends
3838
require a single dense ``Q_mat`` and intentionally reject
3939
batched-instance problems.
4040

41+
## Planted-solution factorization Ising/QUBO (Hen 2026)
42+
43+
| Class | Inputs | Loss / property |
44+
| --------------------------------------- | ----------------- | ------------------------------------------------ |
45+
| `IntegerFactorizationIsing(p, q)` | two primes | $x^T Q x + E_0$, **min = 0** on the planted bits |
46+
| `random_factorization_problems(d, k)` | bit-length, count | list of $k$ random instances of width $d$ |
47+
48+
Implements arXiv:2604.09837. The semi-prime $N = p \cdot q$ is encoded
49+
via long multiplication into a circuit of AND and XOR clauses; each
50+
clause becomes the three- or four-spin energy gadget of Eqs. 10–11 of
51+
the paper. Pinned spins (the bits of $N$ and the leading bits of
52+
$p$, $q$) are folded into the constant offset, so the resulting QUBO is
53+
strictly free-variable.
54+
55+
`score_summary(x)` returns the residual energy above the planted optimum,
56+
the **decoded** factor pair `(p̂, q̂)`, the product ``, and the
57+
bit-Hamming distance to the planted assignment. `feasible == True`
58+
exactly when the optimiser found the planted solution and `p̂ · q̂ = N`.
59+
60+
See `data/factorization/README.md` for the construction details, problem
61+
sizes vs. bit-length, and the citation.
62+
4163
## Classic CO (binary, problem-specific losses)
4264

4365
| Class | Inputs | Loss / feasibility |

scripts/bench_factorization.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Benchmark runner for the planted-solution factorization Ising/QUBO suite.
2+
3+
Usage examples
4+
--------------
5+
$ uv run python scripts/bench_factorization.py --bits 4 --instances 5
6+
$ uv run python scripts/bench_factorization.py --bits 5 --instances 3 \
7+
--device cuda --sol-size 500 --num-epochs 5000
8+
9+
For each instance the script reports:
10+
11+
* the planted ``(p, q)`` and ``N``;
12+
* the number of free Ising spins (problem size after pin folding);
13+
* QQA's residual energy ``H(x̂) − E_0`` (zero if the planted is found);
14+
* the decoded ``(p̂, q̂)``, ``N̂`` and the bit-Hamming distance to ``x*``;
15+
* wall-clock time.
16+
17+
The benchmark is intentionally **CPU-friendly** for ``bits ≤ 5`` so that
18+
contributors can iterate without a GPU; for ``bits ≥ 6`` the recommended
19+
defaults are ``--device cuda --sol-size 1000 --num-epochs 5000``.
20+
21+
Citation
22+
--------
23+
Hen, *Planted-solution SAT and Ising benchmarks from integer
24+
factorization*, arXiv:2604.09837 (2026).
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import argparse
30+
import sys
31+
import time
32+
from pathlib import Path
33+
from typing import Any
34+
35+
import qqa
36+
37+
# `scripts/` is on sys.path when this file is invoked as
38+
# ``python scripts/bench_factorization.py``; importing as a sibling module
39+
# works without packaging.
40+
sys.path.insert(0, str(Path(__file__).resolve().parent))
41+
import _bench_common as bench # noqa: E402
42+
43+
44+
def main(argv: list[str] | None = None) -> int:
45+
p = argparse.ArgumentParser(description=__doc__)
46+
p.add_argument("--bits", type=int, default=4,
47+
help="bit-length d of each prime factor (≥ 2). Problem size "
48+
"scales as O(d^4) without preprocessing.")
49+
p.add_argument("--instances", type=int, default=5,
50+
help="number of random semiprimes to benchmark.")
51+
p.add_argument("--seed", type=int, default=0)
52+
p.add_argument("--device", default="auto", help="cpu / cuda / auto")
53+
p.add_argument("--sol-size", type=int, default=200)
54+
p.add_argument("--num-epochs", type=int, default=2000)
55+
bench.add_qqa_hp_args(p)
56+
p.add_argument("--output", type=Path, default=None,
57+
help="optional JSON file to dump per-instance results.")
58+
p.add_argument("-v", "--verbose", action="store_true")
59+
args = p.parse_args(argv)
60+
61+
log = bench.setup_logging(args.verbose, name="bench_factorization")
62+
bench.setup_device(args)
63+
log.info("bits=%d instances=%d device=%s", args.bits, args.instances, args.device)
64+
hp = bench.qqa_hp_kwargs(args)
65+
log.info(
66+
"qqa hp: lr=%g temp=%g curve_rate=%d gamma=[%g,%g] div=%g sol_size=%d num_epochs=%d",
67+
hp["learning_rate"], hp["temp"], hp["curve_rate"],
68+
hp["gamma_min"], hp["gamma_max"], hp["div_param"],
69+
args.sol_size, args.num_epochs,
70+
)
71+
72+
suite = qqa.random_factorization_problems(
73+
bit_length=args.bits, num_instances=args.instances, seed=args.seed,
74+
device=args.device,
75+
)
76+
77+
rows: list[dict[str, Any]] = []
78+
n_ground_state = 0 # E_min = 0 AND decoded N̂ = N
79+
n_decoded = 0 # decoded N̂ = N (input bits right, internal bits maybe not)
80+
for k, prob in enumerate(suite):
81+
t0 = time.time()
82+
result = bench.run_qqa_anneal(
83+
prob, device=args.device, sol_size=args.sol_size,
84+
num_epochs=args.num_epochs, **hp,
85+
)
86+
wall = time.time() - t0
87+
s = result.score
88+
ex = s["extra"]
89+
# Two distinct success criteria:
90+
# - `decoded`: the optimiser placed the right bits on the input wires
91+
# p_0..p_{n_p-1} and q_0..q_{n_q-1} (cheap to satisfy locally).
92+
# - `gs`: the entire spin string sits at the planted ground state
93+
# (energy 0), which additionally requires every internal pp/sum/
94+
# carry spin to be consistent with the multiplication circuit.
95+
decoded_ok = bool(ex["matches_planted"])
96+
gs_ok = bool(s["feasible"])
97+
n_decoded += int(decoded_ok)
98+
n_ground_state += int(gs_ok)
99+
log.info(
100+
" [%d/%d] N=%d=%d*%d free=%d E_min=%.3f decoded=%d*%d=%d "
101+
"decoded_N=%s gs=%s t=%.2fs",
102+
k + 1, args.instances, ex["N"], ex["p"], ex["q"], ex["num_free_spins"],
103+
float(s["value"]), ex["p_hat"], ex["q_hat"], ex["N_hat"],
104+
"Y" if decoded_ok else "N", "Y" if gs_ok else "N", wall,
105+
)
106+
rows.append({
107+
"k": k, "N": ex["N"], "p": ex["p"], "q": ex["q"],
108+
"num_free_spins": ex["num_free_spins"],
109+
"energy_above_planted": float(s["value"]),
110+
"p_hat": ex["p_hat"], "q_hat": ex["q_hat"], "N_hat": ex["N_hat"],
111+
"decoded_N_correct": decoded_ok,
112+
"is_ground_state": gs_ok,
113+
"hamming_to_planted": ex["hamming_to_planted"],
114+
"wall_s": wall,
115+
})
116+
117+
log.info(
118+
"== SUMMARY == ground-state %d/%d (%.1f%%) decoded-N %d/%d (%.1f%%)",
119+
n_ground_state, len(suite), 100.0 * n_ground_state / max(1, len(suite)),
120+
n_decoded, len(suite), 100.0 * n_decoded / max(1, len(suite)),
121+
)
122+
if args.output:
123+
payload = {
124+
"bits": args.bits, "instances": args.instances, "device": args.device,
125+
"qqa_hp": {**hp, "sol_size": args.sol_size, "num_epochs": args.num_epochs},
126+
"ground_state_count": n_ground_state,
127+
"decoded_n_count": n_decoded,
128+
"total": len(suite),
129+
"results": rows,
130+
}
131+
bench.dump_results_json(args.output, payload)
132+
log.info("wrote %s", args.output)
133+
return 0
134+
135+
136+
if __name__ == "__main__":
137+
raise SystemExit(main())

0 commit comments

Comments
 (0)