Skip to content

Commit 5249086

Browse files
authored
Merge pull request #10 from Jij-Inc/07-independentset
Introduce 07-independentset
2 parents 7b62aa1 + cde5a8f commit 5249086

File tree

10 files changed

+1569
-0
lines changed

10 files changed

+1569
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
def read_dimacs_gph(path: str):
2+
"""
3+
Read an undirected graph in DIMACS format (.gph).
4+
The function converts vertices to 0-based indexing and removes self-loops
5+
or duplicate edges. Edge endpoints are normalized such that the smaller
6+
index comes first.
7+
8+
Args:
9+
path (str): Path to the `.gph` DIMACS graph file.
10+
11+
Returns:
12+
tuple[int, list[list[int]]]:
13+
- N: The number of vertices.
14+
- E: A list of edges, where each edge is represented as a
15+
2-element list `[u, v]` with 0-based vertex indices.
16+
"""
17+
N = None
18+
E = []
19+
with open(path, "r") as f:
20+
for raw in f:
21+
line = raw.strip()
22+
if not line or line.startswith("c"):
23+
# Skip empty and comment lines
24+
continue
25+
if line.startswith("p"):
26+
# Example: "p edge 17 39"
27+
parts = line.split()
28+
if len(parts) < 4 or parts[1] != "edge":
29+
raise ValueError(f"Invalid p-line: {line}")
30+
N = int(parts[2])
31+
# M = int(parts[3]) # Optional: use this for edge count validation
32+
elif line.startswith("e"):
33+
# Example: "e 7 17"
34+
_, u, v = line.split()
35+
u0 = int(u) - 1 # Convert to 0-based
36+
v0 = int(v) - 1
37+
if u0 == v0:
38+
# Ignore self-loops if present
39+
continue
40+
# Normalize to (smaller, larger) to avoid duplicates
41+
if v0 < u0:
42+
u0, v0 = v0, u0
43+
E.append([u0, v0])
44+
45+
if N is None:
46+
raise ValueError("Missing 'p edge N M' header.")
47+
48+
# Deduplicate edges just in case
49+
E = sorted(set(tuple(e) for e in E))
50+
E = [list(e) for e in E]
51+
52+
# Basic validation: ensure all endpoints are in range
53+
if any(u < 0 or v < 0 or u > N - 1 or v > N - 1 for u, v in E):
54+
raise ValueError("Edge endpoint out of range for declared N.")
55+
56+
return N, E
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import jijmodeling as jm
2+
3+
4+
def build_mis_problem() -> jm.Problem:
5+
"""Create Maximum Independent Set (MIS) optimization model.
6+
7+
Formulates the maximum independent set problem using binary decision
8+
variables, where the objective is to maximize the number of selected
9+
vertices subject to adjacency constraints.
10+
11+
Returns:
12+
jm.Problem: JijModeling problem instance with all constraints and
13+
variables defined for the maximum independent set problem.
14+
"""
15+
# Placeholders
16+
N = jm.Placeholder("N", description="number of nodes")
17+
E = jm.Placeholder("E", ndim=2, description="edge list as pairs (u,v), 0-based")
18+
19+
# Decision variable: x[i] ∈ {0,1}
20+
x = jm.BinaryVar("x", shape=(N,), description="1 if vertex i is selected")
21+
22+
# Objective: maximize sum_v x[v]
23+
v = jm.Element("v", belong_to=(0, N))
24+
obj = jm.sum(v, x[v])
25+
26+
problem = jm.Problem("maximum_independent_set", sense=jm.ProblemSense.MAXIMIZE)
27+
problem += obj
28+
29+
# Constraints: for all (u,v) in E, x[u] + x[v] <= 1
30+
e = jm.Element("e", belong_to=E)
31+
problem += jm.Constraint("no_adjacent", x[e[0]] + x[e[1]] <= 1, forall=e)
32+
33+
return problem
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import os
2+
import glob
3+
import jijmodeling as jm
4+
from ommx.artifact import ArtifactBuilder
5+
from model import build_mis_problem
6+
from dat_reader import read_dimacs_gph
7+
from sol_reader import parse_sol_file
8+
9+
10+
def _pick_solution_file(sol_dir: str, base: str) -> str | None:
11+
"""Pick first existing solution file for a basename."""
12+
candidates = [
13+
os.path.join(sol_dir, f"{base}.opt.sol"),
14+
os.path.join(sol_dir, f"{base}.best.sol"),
15+
os.path.join(sol_dir, f"{base}.bst.sol"),
16+
os.path.join(sol_dir, f"{base}.sol"),
17+
]
18+
for p in candidates:
19+
if os.path.exists(p):
20+
return p
21+
return None
22+
23+
24+
def batch_process(
25+
inst_dir: str = "../../instances",
26+
sol_root: str = "../../solutions",
27+
output_directory: str = "./ommx_output",
28+
):
29+
"""
30+
Process instances from a QBench JSON file and corresponding solution files,
31+
convert them into OMMX artifacts, and save them to the output directory.
32+
33+
Parameters:
34+
inst_dir (str): Path to the gph..
35+
sol_root (str): Path to the root solutions directory.
36+
output_directory (str): Path to save the generated .ommx files.
37+
"""
38+
os.makedirs(output_directory, exist_ok=True)
39+
40+
problem = build_mis_problem()
41+
42+
gph_paths = sorted(glob.glob(os.path.join(inst_dir, "*.gph")))
43+
if not gph_paths:
44+
print(f"[MIS] No .gph files found under: {inst_dir}")
45+
return
46+
47+
processed_count = 0
48+
error_count = 0
49+
50+
for gph_path in gph_paths:
51+
base = os.path.splitext(os.path.basename(gph_path))[0]
52+
try:
53+
print(f"[{base}] Reading: {gph_path}")
54+
N, E = read_dimacs_gph(gph_path)
55+
instance_data = {"N": N, "E": E}
56+
57+
interpreter = jm.Interpreter(instance_data)
58+
ommx_instance = interpreter.eval_problem(problem)
59+
60+
sol_path = _pick_solution_file(sol_root, base)
61+
solution = None
62+
if sol_path:
63+
try:
64+
print(f" → Evaluating solution: {sol_path}")
65+
obj_from_file, solution_dict = parse_sol_file(sol_path, N)
66+
solution = ommx_instance.evaluate(solution_dict)
67+
if (
68+
solution.feasible
69+
and abs(solution.objective - obj_from_file["Energy"]) < 1e-6
70+
):
71+
print(
72+
f" objective={solution.objective}, feasible={solution.feasible}"
73+
)
74+
else:
75+
print(
76+
" ! Objective mismatch or infeasible; will save instance only."
77+
)
78+
solution = None
79+
except Exception as sol_err:
80+
print(f" ! Solution evaluation failed: {sol_err}")
81+
solution = None
82+
83+
out_path = os.path.join(output_directory, f"{base}.ommx")
84+
if os.path.exists(out_path):
85+
os.remove(out_path)
86+
87+
builder = ArtifactBuilder.new_archive_unnamed(out_path)
88+
builder.add_instance(ommx_instance)
89+
if solution is not None:
90+
builder.add_solution(solution)
91+
builder.build()
92+
93+
print(f" ✓ Created: {out_path}")
94+
print("-" * 50)
95+
processed_count += 1
96+
97+
except Exception as e:
98+
print(f"[{base}] Error: {e}")
99+
print("-" * 50)
100+
error_count += 1
101+
102+
print(f"Batch complete — processed: {processed_count}, errors: {error_count}")
103+
104+
105+
if __name__ == "__main__":
106+
batch_process(
107+
inst_dir="../../instances",
108+
sol_root="../../solutions",
109+
output_directory="./ommx_output",
110+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import re
2+
3+
4+
def parse_sol_file(file_path: str, n: int) -> tuple[dict[str, int], dict[int, int]]:
5+
"""Parse MIS solution file with automatic format detection.
6+
7+
Supports two common formats found in `.sol` files:
8+
9+
**A) Key–Value format (e.g., `opt.sol`, `sol`)**
10+
Lines may include:
11+
- `# Objective value = <int>`
12+
- `x#<idx> <int>` (typically 1-based indexing)
13+
14+
**B) Index-list format (e.g., `bst.sol`, sometimes `opt.sol`)**
15+
Each line contains a single integer representing a selected vertex.
16+
Values are interpreted as 0-based or 1-based automatically.
17+
18+
Args:
19+
file_path (str): Path to the solution file.
20+
n (int): Number of vertices in the MIS instance.
21+
22+
Returns:
23+
tuple:
24+
- dict[str, int]: `{"Energy": <int or None>}`.
25+
The objective value if present; otherwise, the count of selected vertices.
26+
- dict[int, int]: Mapping of vertex indices (0..n−1) to 0.0/1.0 (or int in KV format).
27+
"""
28+
# read full file
29+
with open(file_path, "r") as f:
30+
raw_lines = [ln.strip() for ln in f]
31+
32+
lines = [ln for ln in raw_lines if ln]
33+
34+
# detect KV format
35+
kv_pattern = re.compile(r"^x#(\d+)\s+([-+]?\d*\.?\d+)$", re.IGNORECASE)
36+
has_kv = any(kv_pattern.match(ln) for ln in lines)
37+
38+
# check objective value
39+
obj_pattern = re.compile(
40+
r"#\s*Objective\s*value\s*=\s*([-+]?\d*\.?\d+)", re.IGNORECASE
41+
)
42+
obj_value = None
43+
for ln in lines:
44+
m = obj_pattern.match(ln)
45+
if m:
46+
obj_value = int(m.group(1))
47+
break
48+
49+
if has_kv:
50+
# === KV format ===
51+
x_vars = {}
52+
for ln in lines:
53+
m = kv_pattern.match(ln)
54+
if m:
55+
idx = int(m.group(1)) # assume 1-based: x#1, x#2, ...
56+
val = int(m.group(2))
57+
x_vars[idx] = val
58+
59+
solution_dict = {i - 1: x_vars.get(i, 0.0) for i in range(1, n + 1)}
60+
return {"Energy": obj_value}, solution_dict
61+
62+
# === Index-list format ===
63+
selected = []
64+
only_ints = True
65+
for ln in lines:
66+
try:
67+
val = int(ln)
68+
selected.append(val)
69+
except ValueError:
70+
only_ints = False
71+
break
72+
73+
if only_ints and selected:
74+
all_in_1_based = all(1 <= v <= n for v in selected)
75+
all_in_0_based = all(0 <= v <= n - 1 for v in selected)
76+
77+
if not (all_in_0_based or all_in_1_based):
78+
raise ValueError(
79+
f"Index list not within [0, {n-1}] or [1, {n}] in {file_path}: {selected[:10]}..."
80+
)
81+
82+
use_one_based = all_in_1_based
83+
solution_dict = {i: 0.0 for i in range(n)}
84+
if use_one_based:
85+
for v in selected:
86+
solution_dict[v - 1] = 1.0
87+
else:
88+
for v in selected:
89+
solution_dict[v] = 1.0
90+
91+
if obj_value is None:
92+
obj_value = int(sum(solution_dict.values()))
93+
94+
return {"Energy": obj_value}, solution_dict
95+
96+
raise ValueError(
97+
f"Unrecognized solution format in {file_path}. "
98+
"Expect either 'x#i val' lines or pure index-per-line."
99+
)

0 commit comments

Comments
 (0)