cdclkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdclkit/cli.py ADDED
@@ -0,0 +1,513 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """Command line interface: ``python -m cdclkit <command> ...``.
4
+
5
+ Follows SAT competition conventions where they exist, because that is what
6
+ scripts around a solver expect:
7
+
8
+ * solution lines ``s SATISFIABLE`` / ``s UNSATISFIABLE`` / ``s UNKNOWN``;
9
+ * the model on ``v`` lines terminated by ``0``;
10
+ * comments on ``c`` lines;
11
+ * exit status **10** for SAT, **20** for UNSAT, **0** for unknown, **1** for
12
+ an error, and **30** when a proof check fails (a solver that emits a bad
13
+ proof must be loudly distinguishable from one that merely times out).
14
+
15
+ Commands::
16
+
17
+ solve FILE solve a DIMACS CNF, optionally emitting and self-checking a proof
18
+ (--jobs N runs a parallel portfolio)
19
+ check FILE PROOF verify a DRAT proof against a formula
20
+ prep FILE preprocess only, write the reduced formula
21
+ count FILE enumerate models (with optional projection)
22
+ opt FILE minimise the number of true literals among given variables
23
+ gen KIND ... generate benchmark families
24
+ mus FILE extract a minimal unsatisfiable subset (why is it UNSAT?)
25
+ stats FILE report formula statistics
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import sys
32
+ import time
33
+ from typing import Sequence
34
+
35
+ from dratify.cnf import CNF, parse_dimacs_file, write_dimacs
36
+ from .encodings import Encoder, optimise
37
+ from dratify.lits import from_dimacs, mk_lit, to_dimacs
38
+ from .mus import MUSExtractor
39
+ from .portfolio import performance_cores, solve_portfolio
40
+ from .preprocess import Preprocessor
41
+ from dratify.proof import MemoryProof, ProofWriter, check_proof, parse_proof
42
+ from .solver import Config, Solver
43
+
44
+ def _native_ok() -> bool:
45
+ from . import native
46
+
47
+ return native.available()
48
+
49
+
50
+ EXIT_SAT = 10
51
+ EXIT_UNSAT = 20
52
+ EXIT_UNKNOWN = 0
53
+ EXIT_ERROR = 1
54
+ EXIT_BAD_PROOF = 30
55
+
56
+
57
+ def _emit_model(model: Sequence[bool], out=None, per_line: int = 20) -> None:
58
+ # resolved at call time, not at import time: a default of `sys.stdout` binds
59
+ # the stream object once and then ignores any later redirection
60
+ out = sys.stdout if out is None else out
61
+ lits = [(i + 1) if b else -(i + 1) for i, b in enumerate(model)]
62
+ for i in range(0, len(lits), per_line):
63
+ out.write("v " + " ".join(str(x) for x in lits[i : i + per_line]) + "\n")
64
+ out.write("v 0\n")
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # commands
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ def cmd_solve(args) -> int:
73
+ f = parse_dimacs_file(args.file)
74
+ print(f"c cdclkit :: {args.file}")
75
+ print(f"c formula: {f.nvars} vars, {f.nclauses} clauses")
76
+
77
+ proof_sink = None
78
+ mem_proof = None
79
+ if args.self_check:
80
+ mem_proof = MemoryProof()
81
+ proof_sink = mem_proof
82
+ elif args.proof:
83
+ proof_sink = ProofWriter(args.proof)
84
+
85
+ original = f.copy() if (args.self_check or args.check_model) else None
86
+
87
+ pre = None
88
+ if args.preprocess:
89
+ t0 = time.perf_counter()
90
+ pre = Preprocessor(f, proof=proof_sink)
91
+ f = pre.run(rounds=args.prep_rounds)
92
+ print(f"c preprocessing took {time.perf_counter()-t0:.3f}s")
93
+ for line in pre.stats.report().splitlines():
94
+ print(line)
95
+ print(f"c reduced: {f.nvars} vars, {f.nclauses} clauses")
96
+
97
+ cfg = Config(
98
+ restart=args.restart,
99
+ var_decay=args.var_decay,
100
+ ccmin=args.ccmin,
101
+ phase_saving=not args.no_phase_saving,
102
+ rnd_freq=args.rnd_freq,
103
+ rnd_seed=args.seed,
104
+ )
105
+ if getattr(args, "adaptive", False):
106
+ from .pipeline import solve_adaptive
107
+
108
+ pr = solve_adaptive(f, engine="native" if _native_ok() else "python")
109
+ print(pr.report())
110
+ print(f"c {pr.conflicts} conflicts in {pr.seconds:.3f}s")
111
+ if pr.sat:
112
+ if args.check_model:
113
+ base = original if original is not None else f
114
+ if base.falsified_clauses(pr.model):
115
+ print("c MODEL CHECK FAILED")
116
+ return EXIT_ERROR
117
+ print("c model verified against the input formula")
118
+ print("s SATISFIABLE")
119
+ if not args.no_model:
120
+ _emit_model(pr.model)
121
+ return EXIT_SAT
122
+ print("s UNSATISFIABLE")
123
+ return EXIT_UNSAT
124
+
125
+ jobs = args.jobs
126
+ if jobs is not None and jobs > 1:
127
+ # Parallel portfolio. Only the winning worker's proof exists, and
128
+ # because workers share no clauses it is a complete refutation on its
129
+ # own -- see cdclkit/portfolio.py.
130
+ pr = solve_portfolio(f, jobs=jobs, want_proof=proof_sink is not None)
131
+ if not pr.finished:
132
+ print("s UNKNOWN")
133
+ return EXIT_UNKNOWN
134
+ print(pr.report())
135
+ result = pr.sat
136
+ s = Solver(0) # placeholder so the reporting below has a stats object
137
+ s.stats.conflicts = pr.stats.get("conflicts", 0)
138
+ s.stats.propagations = pr.stats.get("propagations", 0)
139
+ s.stats.decisions = pr.stats.get("decisions", 0)
140
+ s.model = pr.model or []
141
+ if proof_sink is not None and pr.proof_steps:
142
+ for kind, lits in pr.proof_steps:
143
+ (proof_sink.delete if kind == "d" else proof_sink.add)(lits)
144
+ else:
145
+ s = Solver(f.nvars, proof=proof_sink, config=cfg)
146
+ ok = s.add_cnf(f)
147
+ result = s.solve(max_conflicts=args.conflicts) if ok else False
148
+ print(s.stats.report())
149
+
150
+ if result is None:
151
+ print("s UNKNOWN")
152
+ return EXIT_UNKNOWN
153
+ if result:
154
+ model = s.model
155
+ if pre is not None:
156
+ model = pre.reconstruct(model)
157
+ if args.check_model:
158
+ base = original if original is not None else f
159
+ bad = base.falsified_clauses(model)
160
+ if bad:
161
+ print(f"c MODEL CHECK FAILED: {len(bad)} clauses unsatisfied")
162
+ return EXIT_ERROR
163
+ print("c model verified against the input formula")
164
+ print("s SATISFIABLE")
165
+ if not args.no_model:
166
+ _emit_model(model)
167
+ return EXIT_SAT
168
+
169
+ print("s UNSATISFIABLE")
170
+ if mem_proof is not None:
171
+ t0 = time.perf_counter()
172
+ res = check_proof(original, mem_proof)
173
+ print(f"c proof self-check took {time.perf_counter()-t0:.3f}s "
174
+ f"({len(mem_proof.steps)} steps)")
175
+ for line in res.report().splitlines():
176
+ print(line)
177
+ if not res.ok:
178
+ return EXIT_BAD_PROOF
179
+ elif isinstance(proof_sink, ProofWriter):
180
+ proof_sink.close()
181
+ print(f"c proof written to {args.proof} "
182
+ f"({proof_sink.n_add} additions, {proof_sink.n_del} deletions)")
183
+ return EXIT_UNSAT
184
+
185
+
186
+ def cmd_check(args) -> int:
187
+ f = parse_dimacs_file(args.file)
188
+ with open(args.proof, "r", encoding="ascii") as fh:
189
+ steps = parse_proof(fh.read())
190
+ print(f"c checking {len(steps)} proof steps against {args.file}")
191
+ t0 = time.perf_counter()
192
+ res = check_proof(f, steps, check_rat=not args.no_rat, apply_deletions=not args.keep_deleted)
193
+ print(f"c took {time.perf_counter()-t0:.3f}s")
194
+ print(res.report())
195
+ return EXIT_UNSAT if res.ok else EXIT_BAD_PROOF
196
+
197
+
198
+ def cmd_prep(args) -> int:
199
+ f = parse_dimacs_file(args.file)
200
+ pre = Preprocessor(f, do_bve=not args.no_bve, do_bce=not args.no_bce)
201
+ red = pre.run(rounds=args.prep_rounds)
202
+ print(pre.summary(red))
203
+ if args.out:
204
+ red.save(args.out)
205
+ print(f"c reduced formula written to {args.out}")
206
+ else:
207
+ write_dimacs(red, sys.stdout)
208
+ return EXIT_UNKNOWN
209
+
210
+
211
+ def cmd_count(args) -> int:
212
+ f = parse_dimacs_file(args.file)
213
+ s = Solver(f.nvars)
214
+ if not s.add_cnf(f):
215
+ print("c 0 models")
216
+ print("s UNSATISFIABLE")
217
+ return EXIT_UNSAT
218
+ proj = None
219
+ if args.project:
220
+ proj = [abs(int(x)) - 1 for x in args.project.split(",")]
221
+ n = 0
222
+ for model in s.enumerate_models(projection=proj, limit=args.limit):
223
+ n += 1
224
+ if args.show:
225
+ print("v " + " ".join(str(to_dimacs(mk_lit(v, not b))) for v, b in enumerate(model)))
226
+ print(f"c models found: {n}" + (" (limit reached)" if args.limit and n >= args.limit else ""))
227
+ return EXIT_SAT if n else EXIT_UNSAT
228
+
229
+
230
+ def cmd_opt(args) -> int:
231
+ f = parse_dimacs_file(args.file)
232
+ s = Solver(f.nvars)
233
+ if not s.add_cnf(f):
234
+ print("s UNSATISFIABLE")
235
+ return EXIT_UNSAT
236
+ if args.soft:
237
+ soft = [from_dimacs(int(x)) for x in args.soft.split(",")]
238
+ else:
239
+ soft = [mk_lit(v) for v in range(f.nvars)]
240
+ res = optimise(s, soft, minimise=not args.maximise,
241
+ on_improve=lambda c, m: print(f"c improved to {c}"))
242
+ if res is None:
243
+ print("s UNSATISFIABLE")
244
+ return EXIT_UNSAT
245
+ count, model = res
246
+ print(f"c optimum: {count}")
247
+ print("s SATISFIABLE")
248
+ _emit_model(model[: f.nvars])
249
+ return EXIT_SAT
250
+
251
+
252
+ def cmd_mus(args) -> int:
253
+ """Explain an UNSAT answer: which clauses are actually to blame."""
254
+ f = parse_dimacs_file(args.file)
255
+ ex = MUSExtractor(f)
256
+ t0 = time.perf_counter()
257
+ if args.method == "core":
258
+ result = ex.core()
259
+ elif args.method == "quickxplain":
260
+ result = ex.quickxplain()
261
+ else:
262
+ result = ex.deletion()
263
+ dt = time.perf_counter() - t0
264
+ if not result:
265
+ print("c the formula is satisfiable: there is nothing to explain")
266
+ print("s SATISFIABLE")
267
+ return EXIT_SAT
268
+ print(f"c {args.method}: {len(result)} of {f.nclauses} clauses, "
269
+ f"{ex.calls} solver calls, {dt:.3f}s")
270
+ if args.verify:
271
+ ok, msg = ex.verify(result)
272
+ print(f"c {msg}")
273
+ if not ok:
274
+ return EXIT_ERROR
275
+ for i in result:
276
+ body = " ".join(str(to_dimacs(l)) for l in f.clauses[i])
277
+ print(f"c clause {i}: {body} 0")
278
+ print("s UNSATISFIABLE")
279
+ return EXIT_UNSAT
280
+
281
+
282
+ def cmd_stats(args) -> int:
283
+ f = parse_dimacs_file(args.file)
284
+ st = f.stats()
285
+ for k, v in st.items():
286
+ print(f"c {k:<10}: {v}")
287
+ return EXIT_UNKNOWN
288
+
289
+
290
+ # --------------------------------------------------------------------------
291
+ # benchmark generators
292
+ # --------------------------------------------------------------------------
293
+
294
+
295
+ def gen_php(pigeons: int, holes: int) -> CNF:
296
+ """Pigeonhole: `pigeons` items into `holes` slots, at most one per slot."""
297
+ f = CNF()
298
+ f.comments.append(f"pigeonhole {pigeons} pigeons into {holes} holes")
299
+ x = [[f.new_var(f"p{i}h{j}") for j in range(holes)] for i in range(pigeons)]
300
+ for i in range(pigeons):
301
+ f.add([mk_lit(x[i][j]) for j in range(holes)])
302
+ for j in range(holes):
303
+ for i in range(pigeons):
304
+ for k in range(i + 1, pigeons):
305
+ f.add([mk_lit(x[i][j], True), mk_lit(x[k][j], True)])
306
+ return f
307
+
308
+
309
+ def gen_random_ksat(n: int, m: int, k: int, seed: int) -> CNF:
310
+ import random
311
+
312
+ rng = random.Random(seed)
313
+ f = CNF(n)
314
+ f.comments.append(f"uniform random {k}-SAT n={n} m={m} seed={seed}")
315
+ for _ in range(m):
316
+ vs = rng.sample(range(n), k)
317
+ f.add([mk_lit(v, rng.random() < 0.5) for v in vs])
318
+ return f
319
+
320
+
321
+ def gen_queens(n: int) -> CNF:
322
+ f = CNF()
323
+ f.comments.append(f"{n}-queens")
324
+ enc = Encoder(f)
325
+ q = [[f.new_var(f"q{r}c{c}") for c in range(n)] for r in range(n)]
326
+ for r in range(n):
327
+ enc.exactly_one([mk_lit(q[r][c]) for c in range(n)])
328
+ for c in range(n):
329
+ enc.exactly_one([mk_lit(q[r][c]) for r in range(n)])
330
+ for d in range(-n + 1, n):
331
+ diag = [mk_lit(q[r][r - d]) for r in range(n) if 0 <= r - d < n]
332
+ if len(diag) > 1:
333
+ enc.at_most_one(diag)
334
+ anti = [mk_lit(q[r][d - r]) for r in range(n) if 0 <= d - r < n]
335
+ if len(anti) > 1:
336
+ enc.at_most_one(anti)
337
+ return f
338
+
339
+
340
+ def gen_parity(n: int, seed: int) -> CNF:
341
+ """A random XOR (parity) system -- exponentially hard for pure resolution."""
342
+ import random
343
+
344
+ rng = random.Random(seed)
345
+ f = CNF(n)
346
+ enc = Encoder(f)
347
+ f.comments.append(f"random parity system n={n} seed={seed}")
348
+ for _ in range(n):
349
+ vs = rng.sample(range(n), 3)
350
+ enc.xor_chain([mk_lit(v) for v in vs], value=rng.random() < 0.5)
351
+ return f
352
+
353
+
354
+ def cmd_gen(args) -> int:
355
+ if args.kind == "php":
356
+ f = gen_php(args.n + 1, args.n)
357
+ elif args.kind == "random":
358
+ f = gen_random_ksat(args.n, args.m or int(4.26 * args.n), args.k, args.seed)
359
+ elif args.kind == "queens":
360
+ f = gen_queens(args.n)
361
+ elif args.kind == "parity":
362
+ f = gen_parity(args.n, args.seed)
363
+ else:
364
+ print(f"unknown family {args.kind}", file=sys.stderr)
365
+ return EXIT_ERROR
366
+ if args.out:
367
+ f.save(args.out)
368
+ print(f"c wrote {args.out}: {f.nvars} vars, {f.nclauses} clauses")
369
+ else:
370
+ write_dimacs(f, sys.stdout)
371
+ return EXIT_UNKNOWN
372
+
373
+
374
+ # --------------------------------------------------------------------------
375
+ # argument parsing
376
+ # --------------------------------------------------------------------------
377
+
378
+
379
+ def build_parser() -> argparse.ArgumentParser:
380
+ p = argparse.ArgumentParser(
381
+ prog="cdclkit", description="CDCL SAT solving with checkable proofs"
382
+ )
383
+ # Reports the engine too. "Which cdclkit is this" and "was the accelerator
384
+ # actually loaded" are the same question when a benchmark number looks
385
+ # wrong, and the second is the one people forget to ask.
386
+ from . import __version__, native
387
+
388
+ p.add_argument(
389
+ "--version", action="version",
390
+ version=(f"cdclkit {__version__} "
391
+ f"(native engine: {'yes' if native.available() else 'no'})"),
392
+ )
393
+ sub = p.add_subparsers(dest="cmd", required=True)
394
+
395
+ s = sub.add_parser("solve", help="solve a DIMACS CNF file")
396
+ s.add_argument("file")
397
+ s.add_argument("--proof", help="write a DRAT proof here (UNSAT only)")
398
+ s.add_argument("--self-check", action="store_true",
399
+ help="keep the proof in memory and verify it before reporting UNSAT")
400
+ s.add_argument("--check-model", action="store_true",
401
+ help="verify the model against the input before reporting SAT")
402
+ s.add_argument("--preprocess", action="store_true")
403
+ s.add_argument("--adaptive", action="store_true",
404
+ help="probe briefly, then preprocess only if the instance "
405
+ "turns out to be hard enough to repay it")
406
+ s.add_argument("--prep-rounds", type=int, default=3)
407
+ s.add_argument("--conflicts", type=int, default=None, help="conflict budget")
408
+ s.add_argument("--restart", default="glucose", choices=["glucose", "luby", "none"])
409
+ s.add_argument("--var-decay", type=float, default=0.8)
410
+ s.add_argument("--ccmin", default="deep", choices=["deep", "basic", "none"])
411
+ s.add_argument("--no-phase-saving", action="store_true")
412
+ s.add_argument("--rnd-freq", type=float, default=0.0)
413
+ s.add_argument("--seed", type=int, default=91648253)
414
+ s.add_argument("--no-model", action="store_true", help="suppress the v lines")
415
+ s.add_argument("--jobs", "-j", type=int, default=None,
416
+ metavar="N",
417
+ help=f"run a parallel portfolio of N differently-configured "
418
+ f"solvers and take the first answer (this machine has "
419
+ f"{performance_cores()} performance cores; more workers "
420
+ f"than that is usually slower)")
421
+ s.set_defaults(func=cmd_solve)
422
+
423
+ c = sub.add_parser("check", help="verify a DRAT proof")
424
+ c.add_argument("file")
425
+ c.add_argument("proof")
426
+ c.add_argument("--no-rat", action="store_true", help="RUP only")
427
+ c.add_argument("--keep-deleted", action="store_true", help="ignore deletion lines")
428
+ c.set_defaults(func=cmd_check)
429
+
430
+ pr = sub.add_parser("prep", help="preprocess a formula")
431
+ pr.add_argument("file")
432
+ pr.add_argument("--out")
433
+ pr.add_argument("--prep-rounds", type=int, default=3)
434
+ pr.add_argument("--no-bve", action="store_true")
435
+ pr.add_argument("--no-bce", action="store_true")
436
+ pr.set_defaults(func=cmd_prep)
437
+
438
+ ct = sub.add_parser("count", help="enumerate models")
439
+ ct.add_argument("file")
440
+ ct.add_argument("--limit", type=int, default=0)
441
+ ct.add_argument("--project", help="comma-separated 1-based variables")
442
+ ct.add_argument("--show", action="store_true")
443
+ ct.set_defaults(func=cmd_count)
444
+
445
+ op = sub.add_parser("opt", help="minimise true literals among a soft set")
446
+ op.add_argument("file")
447
+ op.add_argument("--soft", help="comma-separated DIMACS literals")
448
+ op.add_argument("--maximise", action="store_true")
449
+ op.set_defaults(func=cmd_opt)
450
+
451
+ mu = sub.add_parser("mus", help="extract a minimal unsatisfiable subset")
452
+ mu.add_argument("file")
453
+ mu.add_argument("--method", default="deletion",
454
+ choices=["deletion", "quickxplain", "core"])
455
+ mu.add_argument("--verify", action="store_true",
456
+ help="check that the result is unsatisfiable and minimal")
457
+ mu.set_defaults(func=cmd_mus)
458
+
459
+ st = sub.add_parser("stats", help="formula statistics")
460
+ st.add_argument("file")
461
+ st.set_defaults(func=cmd_stats)
462
+
463
+ g = sub.add_parser("gen", help="generate benchmark instances")
464
+ g.add_argument("kind", choices=["php", "random", "queens", "parity"])
465
+ g.add_argument("-n", type=int, default=8)
466
+ g.add_argument("-m", type=int, default=0)
467
+ g.add_argument("-k", type=int, default=3)
468
+ g.add_argument("--seed", type=int, default=1)
469
+ g.add_argument("--out")
470
+ g.set_defaults(func=cmd_gen)
471
+
472
+ return p
473
+
474
+
475
+ def main(argv: Sequence[str] | None = None) -> int:
476
+ args = build_parser().parse_args(argv)
477
+ try:
478
+ return args.func(args)
479
+ except FileNotFoundError as e:
480
+ print(f"c error: {e}", file=sys.stderr)
481
+ return EXIT_ERROR
482
+ except IsADirectoryError as e:
483
+ print(f"c error: {e}", file=sys.stderr)
484
+ return EXIT_ERROR
485
+ except PermissionError as e:
486
+ print(f"c error: {e}", file=sys.stderr)
487
+ return EXIT_ERROR
488
+ except ValueError as e:
489
+ # Malformed DIMACS. The parser raises with a line number and the
490
+ # offending token, which is the useful half of a traceback; the other
491
+ # half is our call stack, which tells the user nothing about their
492
+ # file and reads like a crash.
493
+ print(f"c error: {e}", file=sys.stderr)
494
+ return EXIT_ERROR
495
+ except UnicodeDecodeError as e:
496
+ print(f"c error: not a text file ({e})", file=sys.stderr)
497
+ return EXIT_ERROR
498
+ except BrokenPipeError: # pragma: no cover - `cdclkit ... | head`
499
+ # Python prints its own noisy warning at shutdown unless stdout is
500
+ # closed first, and `| head` is a normal thing to do to a solver that
501
+ # prints a model with 100k literals in it.
502
+ try:
503
+ sys.stdout.close()
504
+ except Exception:
505
+ pass
506
+ return EXIT_ERROR
507
+ except KeyboardInterrupt: # pragma: no cover - interactive
508
+ print("c interrupted", file=sys.stderr)
509
+ return EXIT_UNKNOWN
510
+
511
+
512
+ if __name__ == "__main__": # pragma: no cover
513
+ sys.exit(main())