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/portfolio.py ADDED
@@ -0,0 +1,683 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """A parallel portfolio: run several differently-configured solvers, take the
4
+ first answer.
5
+
6
+ Why a portfolio rather than a parallel search
7
+ ---------------------------------------------
8
+ Splitting a SAT search across cores is genuinely hard -- the search tree is
9
+ irregular, and dividing it well requires knowing which subtree is expensive,
10
+ which is the thing you are trying to find out. A *portfolio* sidesteps that: run
11
+ the same formula under different heuristic configurations, and stop when any of
12
+ them finishes. No coordination, no shared state, no load balancing.
13
+
14
+ What it can and cannot buy you, stated up front:
15
+
16
+ * **Satisfiable instances: real gains.** CDCL runtimes on satisfiable instances
17
+ are heavy-tailed -- the same solver with a different seed can be an order of
18
+ magnitude faster or slower, because it either wanders into the right region
19
+ early or does not. Running k configurations takes the *minimum* of k draws
20
+ from that distribution, and the minimum of a heavy-tailed sample is much
21
+ better than its mean.
22
+ * **Unsatisfiable instances: close to nothing.** A refutation has to exhaust the
23
+ search space no matter which heuristic walks it. Diversity changes the
24
+ constant, not the requirement. Expect a speedup near 1.0, and be suspicious
25
+ of any claim otherwise that does not come with a measurement.
26
+
27
+ This module deliberately does **no clause sharing**. That keeps each worker's
28
+ DRAT proof self-contained and independently verifiable: the winner's proof is a
29
+ refutation of the original formula, full stop. Sharing clauses between workers
30
+ would invalidate that -- an imported clause is not RUP in the importing worker's
31
+ proof stream -- and buying throughput with an unverifiable answer is a bad trade
32
+ for this project. See `PLAN.md` for how sharing would have to be handled if it
33
+ is ever added.
34
+
35
+ Threads versus processes
36
+ ------------------------
37
+ CPython's GIL is enabled on this interpreter, so threads would serialise on the
38
+ solver's pure-Python inner loop and buy nothing. Processes it is, with the
39
+ `spawn` start method (the default on macOS). Spawn re-imports the module in each
40
+ worker, which is why the worker entry point is a module-level function and the
41
+ formula crosses as plain tuples rather than as a `CNF` object.
42
+
43
+ Asymmetric cores
44
+ ----------------
45
+ On Apple Silicon the core count is not the parallelism you get: an M3 Pro has 5
46
+ performance and 6 efficiency cores, and the E-cores run this workload at a
47
+ fraction of P-core throughput. A worker that lands on an E-core takes much
48
+ longer, which does not hurt a first-to-finish portfolio (the P-core workers win
49
+ the race) but does mean **worker count is not speedup**. The default is the
50
+ performance-core count; `jobs` overrides it, and `bench/compare.py` measures both
51
+ rather than trusting either.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import multiprocessing
57
+ import os
58
+ import queue as _queue
59
+ import subprocess
60
+ import sys
61
+ import time
62
+ import warnings
63
+ from typing import Sequence
64
+
65
+ from dratify.cnf import CNF
66
+ from .solver import Config, Solver
67
+
68
+ __all__ = [
69
+ "solve_portfolio",
70
+ "PortfolioResult",
71
+ "default_configs",
72
+ "performance_cores",
73
+ "usable_start_method",
74
+ ]
75
+
76
+
77
+ # --------------------------------------------------------------------------
78
+ # machine topology
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ def performance_cores() -> int:
83
+ """Number of performance cores, falling back to the logical CPU count.
84
+
85
+ On Apple Silicon, `hw.perflevel0.physicalcpu` is the P-core count; on other
86
+ platforms the sysctl is absent and we use `os.cpu_count()`.
87
+ """
88
+ try:
89
+ out = subprocess.run(
90
+ ["sysctl", "-n", "hw.perflevel0.physicalcpu"],
91
+ capture_output=True, text=True, timeout=5,
92
+ )
93
+ n = int(out.stdout.strip())
94
+ if n > 0:
95
+ return n
96
+ except Exception:
97
+ pass
98
+ return os.cpu_count() or 1
99
+
100
+
101
+ #: Marks a process as a portfolio worker. Set in the environment the children
102
+ #: inherit, so it is visible even before their `__main__` is re-imported.
103
+ _WORKER_ENV = "CDCLKIT_PORTFOLIO_WORKER"
104
+
105
+
106
+ def in_worker() -> bool:
107
+ """True when this process is already a portfolio worker.
108
+
109
+ Guards against recursive process explosion. `multiprocessing` with the
110
+ `spawn` method re-imports the parent's `__main__` in every child, so a
111
+ caller who forgets the `if __name__ == "__main__":` guard has their whole
112
+ script re-executed per child -- and if that script calls
113
+ `solve_portfolio`, each child starts its own portfolio, and so on. The
114
+ standard library's answer is "always write the guard", which is correct and
115
+ also not something a library should rely on: the failure mode is a fork
116
+ bomb, not an error message.
117
+
118
+ So children are marked, and a marked process runs sequentially.
119
+ """
120
+ return os.environ.get(_WORKER_ENV) == "1"
121
+
122
+
123
+ def usable_start_method() -> str | None:
124
+ """Pick a start method that will actually work in this process.
125
+
126
+ `spawn` (the macOS default) re-imports the parent's `__main__` in every
127
+ child. From a script that is fine. From a REPL, a `python -c`, or a
128
+ heredoc, `__main__` has no importable file and **every child dies on
129
+ startup** -- and a `Pool` cheerfully respawns them, so the run hangs
130
+ forever instead of failing. That is worth detecting rather than
131
+ documenting.
132
+
133
+ Returns the method to use, or None when no multiprocessing method is
134
+ viable and the caller should run sequentially.
135
+ """
136
+ available = multiprocessing.get_all_start_methods()
137
+ main = sys.modules.get("__main__")
138
+ main_file = getattr(main, "__file__", None)
139
+ main_importable = bool(main_file) and os.path.exists(main_file)
140
+
141
+ if main_importable and "spawn" in available:
142
+ return "spawn"
143
+ # No importable __main__: spawn cannot work, but fork does not re-import
144
+ # anything, so it still can.
145
+ if "fork" in available:
146
+ return "fork"
147
+ if "forkserver" in available and main_importable:
148
+ return "forkserver"
149
+ return None
150
+
151
+
152
+ # --------------------------------------------------------------------------
153
+ # configuration diversity
154
+ # --------------------------------------------------------------------------
155
+
156
+
157
+ def default_configs(n: int) -> list[Config]:
158
+ """`n` meaningfully different solver configurations.
159
+
160
+ Diversity along the axes that actually change the search trajectory:
161
+ restart policy (when to abandon a region), phase (which half of the space
162
+ to try first), clause minimisation (how strong learnt clauses are), and
163
+ randomisation (tie-breaking). Seeds differ throughout, so even two workers
164
+ with the same policy explore differently.
165
+
166
+ The order matters: index 0 is the plain default, so a 1-worker portfolio
167
+ reproduces sequential behaviour exactly, and each added worker brings the
168
+ next most different configuration rather than a near-duplicate.
169
+ """
170
+ recipes = [
171
+ dict(),
172
+ # Glucose EMA restarts with saved phases -- the default until the
173
+ # measurement in CHECKPOINT_LOG moved it to Luby plus target phases.
174
+ # It stays as a diversity axis rather than disappearing: it is still
175
+ # the better configuration on some instances, and index 1 used to read
176
+ # `restart="luby"`, which the flip silently turned into a duplicate of
177
+ # index 0. A portfolio worker running the same search as another
178
+ # worker is a wasted core.
179
+ dict(restart="glucose", target_phase=False),
180
+ # Local search with the gate lifted. Ungated walking is 34x on large
181
+ # random satisfiable instances and a loss elsewhere, so it is wrong as
182
+ # a default and right as one worker out of several: the cost is one
183
+ # core, and the other workers are untouched.
184
+ dict(walk_min_conflicts=0, walk_flips=50_000),
185
+ dict(phase_saving=False, init_phase=True),
186
+ dict(restart="luby", luby_base=1000, ccmin="basic"),
187
+ dict(rnd_freq=0.02, var_decay=0.75),
188
+ dict(restart="none", var_decay=0.9),
189
+ dict(phase_saving=False, rnd_freq=0.05),
190
+ dict(restart="luby", luby_base=32, glue_keep=3),
191
+ dict(var_decay=0.95, first_reduce=8000),
192
+ dict(ccmin="none", restart="luby", luby_base=256),
193
+ dict(rnd_freq=0.1, init_phase=True, var_decay=0.7),
194
+ ]
195
+ out = []
196
+ for i in range(n):
197
+ kw = dict(recipes[i % len(recipes)])
198
+ # a distinct seed per worker, so duplicated recipes still diverge
199
+ kw["rnd_seed"] = 91648253 + 7919 * i
200
+ out.append(Config(**kw))
201
+ return out
202
+
203
+
204
+ # --------------------------------------------------------------------------
205
+ # worker
206
+ # --------------------------------------------------------------------------
207
+
208
+
209
+ def _worker_native(index, nvars, clauses, cfg_kwargs, want_proof):
210
+ """Run one configuration on the native engine. Returns None if unavailable.
211
+
212
+ Each worker imports the native module independently -- under `spawn` the
213
+ child is a fresh interpreter, so there is nothing to inherit. A worker that
214
+ cannot import it falls back to Python rather than failing the whole
215
+ portfolio, which keeps the dependency-free path working even here.
216
+ """
217
+ try:
218
+ import cdclkit_native
219
+ except ImportError:
220
+ return None
221
+
222
+ from dratify.lits import from_dimacs
223
+
224
+ cfg = Config(**cfg_kwargs)
225
+ t0 = time.perf_counter()
226
+ s = cdclkit_native.Solver(
227
+ nvars,
228
+ restart=cfg.restart, ccmin=cfg.ccmin,
229
+ phase_saving=cfg.phase_saving, init_phase=cfg.init_phase,
230
+ target_phase=cfg.target_phase, target_reset=cfg.target_reset,
231
+ walk_flips=cfg.walk_flips, walk_interval=cfg.walk_interval,
232
+ walk_patience=cfg.walk_patience,
233
+ walk_min_conflicts=cfg.walk_min_conflicts,
234
+ var_decay=cfg.var_decay, var_decay_max=cfg.var_decay_max,
235
+ cla_decay=cfg.cla_decay, luby_base=float(cfg.luby_base),
236
+ first_reduce=cfg.first_reduce, reduce_inc=cfg.reduce_inc,
237
+ glue_keep=cfg.glue_keep, block_restart=cfg.block_restart,
238
+ )
239
+ if want_proof:
240
+ s.enable_proof() # must precede the first clause
241
+ ok = True
242
+ for c in clauses:
243
+ if not s.add_clause(c):
244
+ ok = False
245
+ break
246
+ res = s.solve() if ok else False
247
+ dt = time.perf_counter() - t0
248
+
249
+ stats = {
250
+ "conflicts": s.conflicts,
251
+ "decisions": s.decisions,
252
+ "propagations": s.propagations,
253
+ "restarts": s.restarts,
254
+ "seconds": dt,
255
+ "engine": "native",
256
+ }
257
+ model = list(s.model) if res else None
258
+ steps = None
259
+ if want_proof and not res:
260
+ # normalise to the same internal-literal form MemoryProof uses, so a
261
+ # caller cannot tell which engine produced the proof
262
+ steps = [(k, tuple(from_dimacs(d) for d in lits))
263
+ for k, lits in s.proof_steps()]
264
+ return (index, bool(res), model, steps, stats)
265
+
266
+
267
+ def _worker(payload):
268
+ """Run one configuration. Must be module-level: `spawn` pickles by name."""
269
+ (index, nvars, clauses, cfg_kwargs, want_proof, engine, preprocess) = payload
270
+
271
+ if preprocess:
272
+ # This worker preprocesses first. Measurement says the two strategies
273
+ # win on disjoint instance classes -- a portfolio wins on satisfiable
274
+ # and heavy-tailed instances (rand3(250): 0.08 s against 0.99 s),
275
+ # preprocessing wins on structured unsatisfiable ones (factor, php,
276
+ # colouring). Since workers race in parallel, preprocessing does not
277
+ # have to be chosen *instead of* diversity: it can be one of the
278
+ # diverse strategies, and whichever suits the instance wins.
279
+ from dratify.cnf import CNF
280
+
281
+ f = CNF(nvars)
282
+ for c in clauses:
283
+ f.add(c)
284
+ f.nvars = nvars
285
+ try:
286
+ reduced, unsat, reconstruct, _ = _preprocess_for_worker(f, engine)
287
+ except Exception:
288
+ reduced, unsat, reconstruct = None, False, None
289
+ if unsat:
290
+ return (index, False, None, None,
291
+ {"conflicts": 0, "decisions": 0, "propagations": 0,
292
+ "restarts": 0, "seconds": 0.0,
293
+ "engine": engine, "preprocessed": True})
294
+ if reduced is not None:
295
+ inner = (index, nvars, [list(c) for c in reduced.clauses],
296
+ cfg_kwargs, False, engine, False)
297
+ idx, sat, model, _steps, stats = _worker(inner)
298
+ stats["preprocessed"] = True
299
+ if sat and reconstruct is not None:
300
+ model = list(reconstruct(model))
301
+ # proofs are not returned from a preprocessing worker: the proof
302
+ # would be of the *reduced* formula, and the preprocessing steps
303
+ # that justify the reduction live in this process only
304
+ return (idx, sat, model, None, stats)
305
+
306
+ if engine == "native":
307
+ got = _worker_native(index, nvars, clauses, cfg_kwargs, want_proof)
308
+ if got is not None:
309
+ return got
310
+ # fall through to Python when the native module is missing
311
+
312
+ from dratify.proof import MemoryProof # local import keeps worker startup lean
313
+
314
+ t0 = time.perf_counter()
315
+ proof = MemoryProof() if want_proof else None
316
+ s = Solver(nvars, proof=proof, config=Config(**cfg_kwargs))
317
+ ok = True
318
+ for c in clauses:
319
+ if not s.add_clause(c):
320
+ ok = False
321
+ break
322
+ res = s.solve() if ok else False
323
+ dt = time.perf_counter() - t0
324
+
325
+ stats = {
326
+ "conflicts": s.stats.conflicts,
327
+ "decisions": s.stats.decisions,
328
+ "propagations": s.stats.propagations,
329
+ "restarts": s.stats.restarts,
330
+ "seconds": dt,
331
+ "engine": "python",
332
+ }
333
+ model = list(s.model) if res else None
334
+ steps = proof.steps if (proof is not None and not res) else None
335
+ return (index, bool(res), model, steps, stats)
336
+
337
+
338
+ def _preprocess_for_worker(f, engine):
339
+ """Preprocess inside a worker, preferring the native preprocessor."""
340
+ from .pipeline import _preprocess
341
+
342
+ return _preprocess(f, engine)
343
+
344
+
345
+ def _worker_proc(payload, out_queue):
346
+ """Process entry point: run `_worker` and post the answer."""
347
+ try:
348
+ out_queue.put(_worker(payload))
349
+ except BaseException as e: # never leave the parent waiting on a silent death
350
+ out_queue.put(("error", payload[0], f"{type(e).__name__}: {e}"))
351
+
352
+
353
+ # --------------------------------------------------------------------------
354
+ # result
355
+ # --------------------------------------------------------------------------
356
+
357
+
358
+ class PortfolioResult:
359
+ """Outcome of a portfolio run."""
360
+
361
+ __slots__ = ("sat", "model", "proof_steps", "winner", "winner_config",
362
+ "stats", "elapsed", "jobs", "finished", "engine")
363
+
364
+ def __init__(self) -> None:
365
+ self.sat: bool | None = None
366
+ self.model: list[bool] | None = None
367
+ self.proof_steps = None
368
+ self.winner: int = -1
369
+ self.winner_config: Config | None = None
370
+ self.stats: dict = {}
371
+ self.elapsed: float = 0.0
372
+ self.jobs: int = 0
373
+ self.finished: bool = False
374
+ self.engine: str = "python"
375
+
376
+ def __bool__(self) -> bool:
377
+ return bool(self.sat)
378
+
379
+ def report(self) -> str:
380
+ if not self.finished:
381
+ return f"c portfolio: no answer within the budget ({self.jobs} workers)"
382
+ verdict = "SATISFIABLE" if self.sat else "UNSATISFIABLE"
383
+ cfg = self.winner_config
384
+ desc = (f"restart={cfg.restart} ccmin={cfg.ccmin} "
385
+ f"phase_saving={cfg.phase_saving} seed={cfg.rnd_seed}"
386
+ if cfg else "?")
387
+ return (
388
+ f"c portfolio: {self.jobs} workers on the {self.engine} engine, "
389
+ f"{self.elapsed:.3f}s\n"
390
+ f"c winner : worker {self.winner} ({desc})\n"
391
+ f"c {self.stats.get('conflicts', 0)} conflicts, "
392
+ f"{self.stats.get('propagations', 0)} propagations\n"
393
+ f"c verdict : {verdict}"
394
+ )
395
+
396
+
397
+ # --------------------------------------------------------------------------
398
+ # native threaded path
399
+ # --------------------------------------------------------------------------
400
+
401
+
402
+ def _config_tuples(configs):
403
+ """Flatten Configs into the shape the native binding accepts.
404
+
405
+ Dicts rather than tuples. pyo3 stops extracting tuples past 12 elements,
406
+ and a positional tuple mis-binds silently when a field is inserted in the
407
+ middle -- `phase_saving` and `target_phase` are both booleans and adjacent,
408
+ so a swap would run happily and just search differently.
409
+ """
410
+ return [
411
+ dict(restart=c.restart, ccmin=c.ccmin, phase_saving=c.phase_saving,
412
+ init_phase=c.init_phase, target_phase=c.target_phase,
413
+ target_reset=c.target_reset, walk_flips=c.walk_flips,
414
+ walk_interval=c.walk_interval, walk_patience=c.walk_patience,
415
+ walk_min_conflicts=c.walk_min_conflicts,
416
+ var_decay=c.var_decay, var_decay_max=c.var_decay_max,
417
+ cla_decay=c.cla_decay, luby_base=float(c.luby_base),
418
+ first_reduce=c.first_reduce, reduce_inc=c.reduce_inc,
419
+ glue_keep=c.glue_keep, block_restart=c.block_restart)
420
+ for c in configs
421
+ ]
422
+
423
+
424
+ def _native_threaded(formula, cfgs, result, preprocess_workers=0,
425
+ want_proof=False):
426
+ """Try the native threaded portfolio. Returns True when it answered.
427
+
428
+ Threads instead of processes removes ~60 ms of startup per solve, which on
429
+ short instances was the entire runtime. The native side releases the GIL,
430
+ so the threads genuinely run in parallel.
431
+
432
+ Proofs work here too: each thread carries its own DRAT buffer and the
433
+ winner's is returned. Because threads share no clauses, that buffer is
434
+ already a complete standalone refutation -- there is nothing to merge. It is
435
+ the payoff of the no-sharing decision, and it means the fastest
436
+ configuration is also a certifying one.
437
+ """
438
+ try:
439
+ import cdclkit_native
440
+ except ImportError:
441
+ return False
442
+ if not hasattr(cdclkit_native, "solve_portfolio"):
443
+ return False
444
+
445
+ clauses = [list(c) for c in formula.clauses]
446
+
447
+ # Some threads solve a preprocessed copy instead. Preprocessing is worth
448
+ # 1.3-1.5x on structured instances and a loss on easy ones, and the formula
449
+ # does not say which it is -- so run it as one of the parallel strategies
450
+ # rather than as a decision. When it helps, that thread wins; when it does
451
+ # not, it cost nothing on the critical path.
452
+ alt = None
453
+ reconstruct = None
454
+ if preprocess_workers > 0:
455
+ from .pipeline import _preprocess
456
+
457
+ try:
458
+ red, unsat, reconstruct, _ = _preprocess(formula, "native")
459
+ if unsat:
460
+ result.sat = False
461
+ result.model = None
462
+ result.winner, result.winner_config = 0, cfgs[0]
463
+ result.stats = {"conflicts": 0, "engine": "native-threads"}
464
+ result.engine = "native-threads"
465
+ result.finished = True
466
+ return True
467
+ alt = [list(c) for c in red.clauses]
468
+ except Exception:
469
+ alt, reconstruct = None, None
470
+
471
+ out = cdclkit_native.solve_portfolio(
472
+ formula.nvars, clauses, _config_tuples(cfgs),
473
+ alt, preprocess_workers if alt is not None else 0, want_proof)
474
+ if out is None:
475
+ return False
476
+ (winner, clause_set, sat, model, conflicts, decisions, propagations,
477
+ restarts, proof) = out
478
+ if sat and clause_set == 1 and reconstruct is not None:
479
+ model = list(reconstruct(list(model)))
480
+ from dratify.lits import from_dimacs
481
+
482
+ result.sat = sat
483
+ result.model = list(model) if sat else None
484
+ result.proof_steps = (
485
+ [("d" if is_del else "a", tuple(from_dimacs(d) for d in lits))
486
+ for is_del, lits in proof]
487
+ if proof else None
488
+ )
489
+ result.winner = winner
490
+ result.winner_config = cfgs[winner]
491
+ result.stats = {
492
+ "conflicts": conflicts, "decisions": decisions,
493
+ "propagations": propagations, "restarts": restarts,
494
+ "engine": "native-threads",
495
+ }
496
+ result.engine = "native-threads"
497
+ result.finished = True
498
+ return True
499
+
500
+
501
+ # --------------------------------------------------------------------------
502
+ # driver
503
+ # --------------------------------------------------------------------------
504
+
505
+
506
+ def solve_portfolio(
507
+ formula: CNF,
508
+ jobs: int | None = None,
509
+ configs: Sequence[Config] | None = None,
510
+ want_proof: bool = False,
511
+ timeout: float | None = None,
512
+ engine: str = "native",
513
+ preprocess_workers: int | None = None,
514
+ ) -> PortfolioResult:
515
+ """Solve `formula` with `jobs` differently-configured workers in parallel.
516
+
517
+ Returns as soon as any worker produces a definitive answer; the rest are
518
+ terminated. Every worker solves the same formula, so whichever answers
519
+ first is authoritative.
520
+
521
+ `jobs=1` runs in-process with no multiprocessing at all, which keeps the
522
+ single-worker path byte-for-byte identical to a plain `Solver` run --
523
+ useful as a control, and it means the default configuration of any caller
524
+ that does not ask for parallelism is unchanged.
525
+
526
+ `engine` selects the per-worker solver. "native" uses the Rust engine when
527
+ the module imports in the worker and falls back to Python otherwise, so the
528
+ default is safe on a machine with no Rust toolchain. Pass "python" to force
529
+ the reference engine -- worth doing when comparing against
530
+ `bench/baseline.json`, since the two engines are bit-exact but only the
531
+ Python one is what the baseline was recorded from.
532
+ """
533
+ if engine not in ("native", "python"):
534
+ raise ValueError(f"unknown engine {engine!r} (expected native or python)")
535
+ if preprocess_workers is None:
536
+ # Roughly two fifths of the workers preprocess. Measured on the
537
+ # benchmark set at jobs=5 (total seconds over 17 instances):
538
+ # 0 preprocessing workers 1.243
539
+ # 1 1.002
540
+ # 2 0.927
541
+ # 3 0.914
542
+ # Past 2 the gain is marginal and it is bought with diversity, which
543
+ # this benchmark set under-represents -- only 3 of its 17 instances are
544
+ # satisfiable, and diversity is what wins those.
545
+ preprocess_workers = max(1, jobs * 2 // 5) if jobs > 1 else 0
546
+ if want_proof:
547
+ # A preprocessing worker solves the *reduced* formula, so its proof
548
+ # would refute that rather than the original, and the steps justifying
549
+ # the reduction live in this process. Proof runs therefore use plain
550
+ # configurations only, and every thread's buffer refutes the formula
551
+ # the caller actually passed in.
552
+ preprocess_workers = 0
553
+ if jobs is None:
554
+ jobs = performance_cores()
555
+ jobs = max(1, int(jobs))
556
+ cfgs = list(configs) if configs is not None else default_configs(jobs)
557
+ jobs = min(jobs, len(cfgs))
558
+
559
+ result = PortfolioResult()
560
+ result.jobs = jobs
561
+ clauses = [list(c) for c in formula.clauses]
562
+ t0 = time.perf_counter()
563
+
564
+ if in_worker():
565
+ # Already inside a portfolio worker: never fan out again.
566
+ jobs = 1
567
+ cfgs = cfgs[:1]
568
+ result.jobs = 1
569
+
570
+ # Threads first: same design, a thousandth of the startup cost. Skipped
571
+ # for proof runs and for the preprocessing-worker strategy, both of which
572
+ # need the per-process path.
573
+ if jobs > 1 and engine == "native" and not in_worker():
574
+ if _native_threaded(formula, cfgs, result, preprocess_workers, want_proof):
575
+ result.elapsed = time.perf_counter() - t0
576
+ return result
577
+
578
+ if jobs == 1:
579
+ idx, sat, model, steps, stats = _worker(
580
+ (0, formula.nvars, clauses, cfgs[0].as_dict(), want_proof, engine,
581
+ False))
582
+ result.sat, result.model, result.proof_steps = sat, model, steps
583
+ result.winner, result.winner_config, result.stats = idx, cfgs[0], stats
584
+ result.engine = stats.get("engine", engine)
585
+ result.elapsed = time.perf_counter() - t0
586
+ result.finished = True
587
+ return result
588
+
589
+ method = usable_start_method()
590
+ if method is None:
591
+ warnings.warn(
592
+ "no usable multiprocessing start method; running the portfolio "
593
+ "sequentially with the first configuration",
594
+ RuntimeWarning, stacklevel=2,
595
+ )
596
+ return solve_portfolio(formula, jobs=1, configs=cfgs[:1],
597
+ want_proof=want_proof, timeout=timeout,
598
+ engine=engine, preprocess_workers=0)
599
+
600
+ ctx = multiprocessing.get_context(method)
601
+ # The last `preprocess_workers` slots preprocess first; the rest race with
602
+ # plain configuration diversity.
603
+ payloads = [
604
+ (i, formula.nvars, clauses, cfgs[i].as_dict(), want_proof, engine,
605
+ i >= jobs - preprocess_workers)
606
+ for i in range(jobs)
607
+ ]
608
+
609
+ # Explicit processes rather than a Pool: a Pool silently respawns workers
610
+ # that die at startup, which turns a configuration error into an infinite
611
+ # hang. With plain processes, "everyone died" is observable.
612
+ out: multiprocessing.Queue = ctx.Queue()
613
+ procs = [ctx.Process(target=_worker_proc, args=(p, out), daemon=True)
614
+ for p in payloads]
615
+ # Mark the environment the children inherit, then restore ours. This has
616
+ # to happen around `start()` rather than inside the worker: under `spawn`
617
+ # the child re-imports `__main__` *before* the worker function runs, and
618
+ # that re-import is exactly what has to be stopped from fanning out again.
619
+ previous = os.environ.get(_WORKER_ENV)
620
+ os.environ[_WORKER_ENV] = "1"
621
+ try:
622
+ for p in procs:
623
+ p.start()
624
+ finally:
625
+ if previous is None:
626
+ os.environ.pop(_WORKER_ENV, None)
627
+ else:
628
+ os.environ[_WORKER_ENV] = previous
629
+
630
+ deadline = (time.monotonic() + timeout) if timeout else None
631
+ winner = None
632
+ errors: list[str] = []
633
+ try:
634
+ while True:
635
+ if deadline is not None and time.monotonic() > deadline:
636
+ break
637
+ try:
638
+ item = out.get(timeout=0.05)
639
+ except _queue.Empty:
640
+ if not any(p.is_alive() for p in procs):
641
+ try:
642
+ item = out.get_nowait()
643
+ except _queue.Empty:
644
+ break
645
+ else:
646
+ continue
647
+ if item and item[0] == "error":
648
+ errors.append(f"worker {item[1]}: {item[2]}")
649
+ continue
650
+ winner = item
651
+ break
652
+ finally:
653
+ for p in procs:
654
+ if p.is_alive():
655
+ p.terminate()
656
+ for p in procs:
657
+ p.join(timeout=5)
658
+ out.close()
659
+ out.join_thread()
660
+
661
+ result.elapsed = time.perf_counter() - t0
662
+ if winner is None:
663
+ if errors:
664
+ raise RuntimeError(
665
+ "every portfolio worker failed:\n " + "\n ".join(errors))
666
+ if deadline is None:
667
+ # Workers died without reporting -- almost always a start-method
668
+ # problem the detector missed. Degrade rather than return nothing.
669
+ warnings.warn(
670
+ "portfolio workers exited without an answer; falling back to "
671
+ "a sequential solve", RuntimeWarning, stacklevel=2,
672
+ )
673
+ return solve_portfolio(formula, jobs=1, configs=cfgs[:1],
674
+ want_proof=want_proof, timeout=timeout,
675
+ engine=engine, preprocess_workers=0)
676
+ return result # genuine timeout
677
+
678
+ idx, sat, model, steps, stats = winner
679
+ result.sat, result.model, result.proof_steps = sat, model, steps
680
+ result.winner, result.winner_config, result.stats = idx, cfgs[idx], stats
681
+ result.engine = stats.get("engine", engine)
682
+ result.finished = True
683
+ return result