unicode-logic-kit 0.31.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.
Files changed (237) hide show
  1. unicode_logic_kit/__init__.py +385 -0
  2. unicode_logic_kit/__main__.py +520 -0
  3. unicode_logic_kit/_deadline.py +219 -0
  4. unicode_logic_kit/ace/__init__.py +126 -0
  5. unicode_logic_kit/ace/_align.py +135 -0
  6. unicode_logic_kit/ace/chem_lexicon.py +128 -0
  7. unicode_logic_kit/ace/drs_reader.py +570 -0
  8. unicode_logic_kit/ace/mapping.py +666 -0
  9. unicode_logic_kit/ace/reverse_modal.py +138 -0
  10. unicode_logic_kit/ace/runner.py +551 -0
  11. unicode_logic_kit/ace/translate.py +452 -0
  12. unicode_logic_kit/ace/verbalize.py +1070 -0
  13. unicode_logic_kit/api.py +1284 -0
  14. unicode_logic_kit/atp/__init__.py +177 -0
  15. unicode_logic_kit/atp/_ascii_names.py +113 -0
  16. unicode_logic_kit/atp/_html.py +72 -0
  17. unicode_logic_kit/atp/_substructural_input.py +228 -0
  18. unicode_logic_kit/atp/_tff_problem.py +715 -0
  19. unicode_logic_kit/atp/_tptp_problem.py +1111 -0
  20. unicode_logic_kit/atp/_writer_support.py +289 -0
  21. unicode_logic_kit/atp/clingo_backend.py +1180 -0
  22. unicode_logic_kit/atp/cvc5_backend.py +1385 -0
  23. unicode_logic_kit/atp/eprover_backend.py +732 -0
  24. unicode_logic_kit/atp/finite_domain.py +1055 -0
  25. unicode_logic_kit/atp/fitch.py +1547 -0
  26. unicode_logic_kit/atp/fitch_search.py +551 -0
  27. unicode_logic_kit/atp/hets_backend.py +339 -0
  28. unicode_logic_kit/atp/hybrid_down.py +120 -0
  29. unicode_logic_kit/atp/incremental.py +250 -0
  30. unicode_logic_kit/atp/kripke_enum.py +741 -0
  31. unicode_logic_kit/atp/lambek.py +436 -0
  32. unicode_logic_kit/atp/leo3_backend.py +332 -0
  33. unicode_logic_kit/atp/linear.py +738 -0
  34. unicode_logic_kit/atp/lj.py +705 -0
  35. unicode_logic_kit/atp/logic_backends.py +566 -0
  36. unicode_logic_kit/atp/ltl_tableau.py +1084 -0
  37. unicode_logic_kit/atp/minizinc_backend.py +1402 -0
  38. unicode_logic_kit/atp/modal_tableau.py +1382 -0
  39. unicode_logic_kit/atp/nanocop_backend.py +410 -0
  40. unicode_logic_kit/atp/portfolio.py +489 -0
  41. unicode_logic_kit/atp/protocol.py +1803 -0
  42. unicode_logic_kit/atp/prover9_entailment.py +1153 -0
  43. unicode_logic_kit/atp/resolution.py +1376 -0
  44. unicode_logic_kit/atp/resolution_check.py +1114 -0
  45. unicode_logic_kit/atp/sequent.py +1050 -0
  46. unicode_logic_kit/atp/tableau.py +921 -0
  47. unicode_logic_kit/atp/tableau_check.py +543 -0
  48. unicode_logic_kit/atp/tptp_ncl.py +811 -0
  49. unicode_logic_kit/atp/tptp_tff.py +1546 -0
  50. unicode_logic_kit/atp/tstp.py +1333 -0
  51. unicode_logic_kit/atp/tstp_check.py +1096 -0
  52. unicode_logic_kit/atp/twee_backend.py +236 -0
  53. unicode_logic_kit/atp/twee_check.py +711 -0
  54. unicode_logic_kit/atp/twee_entailment.py +953 -0
  55. unicode_logic_kit/atp/vampire_entailment.py +540 -0
  56. unicode_logic_kit/atp/z3_arith.py +470 -0
  57. unicode_logic_kit/atp/z3_equivalence.py +36 -0
  58. unicode_logic_kit/atp/z3_fuzzy.py +362 -0
  59. unicode_logic_kit/atp/z3_input.py +500 -0
  60. unicode_logic_kit/atp/z3_models.py +208 -0
  61. unicode_logic_kit/chem/__init__.py +88 -0
  62. unicode_logic_kit/chem/_naming.py +284 -0
  63. unicode_logic_kit/chem/cache.py +185 -0
  64. unicode_logic_kit/chem/interop.py +244 -0
  65. unicode_logic_kit/chem/mol.py +525 -0
  66. unicode_logic_kit/chem/signature.py +112 -0
  67. unicode_logic_kit/comorphism.py +497 -0
  68. unicode_logic_kit/dl/__init__.py +384 -0
  69. unicode_logic_kit/dl/classification.py +227 -0
  70. unicode_logic_kit/dl/concepts.py +632 -0
  71. unicode_logic_kit/dl/datatypes.py +818 -0
  72. unicode_logic_kit/dl/owl_functional.py +2433 -0
  73. unicode_logic_kit/dl/owl_manchester.py +1637 -0
  74. unicode_logic_kit/dl/owl_reasoner.py +790 -0
  75. unicode_logic_kit/dl/parser.py +391 -0
  76. unicode_logic_kit/dl/tableau.py +4048 -0
  77. unicode_logic_kit/dl/translate.py +2704 -0
  78. unicode_logic_kit/drt/__init__.py +94 -0
  79. unicode_logic_kit/drt/export.py +179 -0
  80. unicode_logic_kit/drt/nodes.py +506 -0
  81. unicode_logic_kit/drt/parser.py +965 -0
  82. unicode_logic_kit/drt/resolve.py +195 -0
  83. unicode_logic_kit/drt/reverse.py +175 -0
  84. unicode_logic_kit/eval/__init__.py +106 -0
  85. unicode_logic_kit/eval/batch.py +382 -0
  86. unicode_logic_kit/eval/canonical.py +663 -0
  87. unicode_logic_kit/eval/chem_batch.py +606 -0
  88. unicode_logic_kit/eval/converses.py +200 -0
  89. unicode_logic_kit/eval/datasets/__init__.py +136 -0
  90. unicode_logic_kit/eval/datasets/_base.py +263 -0
  91. unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
  92. unicode_logic_kit/eval/datasets/c3po.py +678 -0
  93. unicode_logic_kit/eval/datasets/folio.py +158 -0
  94. unicode_logic_kit/eval/datasets/fracas.py +418 -0
  95. unicode_logic_kit/eval/datasets/groves.py +191 -0
  96. unicode_logic_kit/eval/datasets/logicbench.py +467 -0
  97. unicode_logic_kit/eval/datasets/logicnli.py +303 -0
  98. unicode_logic_kit/eval/datasets/malls.py +133 -0
  99. unicode_logic_kit/eval/datasets/pfolio.py +594 -0
  100. unicode_logic_kit/eval/datasets/pmb.py +242 -0
  101. unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
  102. unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
  103. unicode_logic_kit/eval/datasets/proverqa.py +674 -0
  104. unicode_logic_kit/eval/datasets/willow.py +478 -0
  105. unicode_logic_kit/eval/equivalence.py +466 -0
  106. unicode_logic_kit/eval/exercise_gen.py +533 -0
  107. unicode_logic_kit/eval/explain.py +791 -0
  108. unicode_logic_kit/eval/generality.py +750 -0
  109. unicode_logic_kit/eval/metric_hf.py +458 -0
  110. unicode_logic_kit/eval/predicate_match.py +343 -0
  111. unicode_logic_kit/eval/theory_check.py +1170 -0
  112. unicode_logic_kit/eval/validate.py +306 -0
  113. unicode_logic_kit/fol/__init__.py +177 -0
  114. unicode_logic_kit/fol/_atom_keys.py +510 -0
  115. unicode_logic_kit/fol/_fol_nodes.py +3586 -0
  116. unicode_logic_kit/fol/_free_parameters.py +105 -0
  117. unicode_logic_kit/fol/_ho_nodes.py +448 -0
  118. unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
  119. unicode_logic_kit/fol/_identifiers.py +1091 -0
  120. unicode_logic_kit/fol/_lambek_nodes.py +112 -0
  121. unicode_logic_kit/fol/_linear_nodes.py +352 -0
  122. unicode_logic_kit/fol/_modal_nodes.py +1467 -0
  123. unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
  124. unicode_logic_kit/fol/_numeral_symbols.py +231 -0
  125. unicode_logic_kit/fol/_so_nodes.py +200 -0
  126. unicode_logic_kit/fol/_symbol_names.py +81 -0
  127. unicode_logic_kit/fol/_team_nodes.py +181 -0
  128. unicode_logic_kit/fol/_tptp_symbols.py +551 -0
  129. unicode_logic_kit/fol/_truth_constants.py +117 -0
  130. unicode_logic_kit/fol/casl_export.py +1135 -0
  131. unicode_logic_kit/fol/casl_import.py +929 -0
  132. unicode_logic_kit/fol/derivation.py +367 -0
  133. unicode_logic_kit/fol/dialect_detect.py +70 -0
  134. unicode_logic_kit/fol/dialect_repair.py +537 -0
  135. unicode_logic_kit/fol/frames.py +637 -0
  136. unicode_logic_kit/fol/grammars/terminals.lark +31 -0
  137. unicode_logic_kit/fol/lambda_tools.py +297 -0
  138. unicode_logic_kit/fol/latex_input.py +429 -0
  139. unicode_logic_kit/fol/modal_translation.py +944 -0
  140. unicode_logic_kit/fol/msflparser.py +1033 -0
  141. unicode_logic_kit/fol/naming.py +422 -0
  142. unicode_logic_kit/fol/nodes.py +241 -0
  143. unicode_logic_kit/fol/normalforms.py +492 -0
  144. unicode_logic_kit/fol/pal.py +287 -0
  145. unicode_logic_kit/fol/prolog_export.py +566 -0
  146. unicode_logic_kit/fol/prolog_input.py +505 -0
  147. unicode_logic_kit/fol/prover9_input.py +1325 -0
  148. unicode_logic_kit/fol/qml.py +1760 -0
  149. unicode_logic_kit/fol/qmltp_input.py +525 -0
  150. unicode_logic_kit/fol/sanitize.py +221 -0
  151. unicode_logic_kit/fol/serialize.py +79 -0
  152. unicode_logic_kit/fol/signature.py +1290 -0
  153. unicode_logic_kit/fol/simplify_check.py +544 -0
  154. unicode_logic_kit/fol/spans.py +594 -0
  155. unicode_logic_kit/fol/tptp_input.py +1503 -0
  156. unicode_logic_kit/fol/tptp_repair.py +941 -0
  157. unicode_logic_kit/fol/unification.py +157 -0
  158. unicode_logic_kit/fol/verbalize.py +263 -0
  159. unicode_logic_kit/hets/__init__.py +163 -0
  160. unicode_logic_kit/hets/bridge.py +142 -0
  161. unicode_logic_kit/hets/client.py +748 -0
  162. unicode_logic_kit/hets/docker.py +420 -0
  163. unicode_logic_kit/hets/dol.py +712 -0
  164. unicode_logic_kit/hets/haskell_json.py +355 -0
  165. unicode_logic_kit/hets/owl_backend.py +794 -0
  166. unicode_logic_kit/hets/owl_cli.py +598 -0
  167. unicode_logic_kit/hets/symbols.py +512 -0
  168. unicode_logic_kit/hol/__init__.py +140 -0
  169. unicode_logic_kit/hol/_ho_common.py +323 -0
  170. unicode_logic_kit/hol/_isabelle_binders.py +125 -0
  171. unicode_logic_kit/hol/classical.py +812 -0
  172. unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
  173. unicode_logic_kit/hol/deepshallow/_common.py +177 -0
  174. unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
  175. unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
  176. unicode_logic_kit/hol/deepshallow/modal.py +217 -0
  177. unicode_logic_kit/hol/deepshallow/qml.py +406 -0
  178. unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
  179. unicode_logic_kit/hol/free.py +753 -0
  180. unicode_logic_kit/hol/goedel.py +336 -0
  181. unicode_logic_kit/hol/ho_modal.py +1743 -0
  182. unicode_logic_kit/hol/intuitionistic.py +403 -0
  183. unicode_logic_kit/hol/isabelle_conditional.py +593 -0
  184. unicode_logic_kit/hol/isabelle_modal.py +1908 -0
  185. unicode_logic_kit/hol/isabelle_relevant.py +412 -0
  186. unicode_logic_kit/hol/isabelle_runner.py +1147 -0
  187. unicode_logic_kit/hol/isabelle_substructural.py +884 -0
  188. unicode_logic_kit/hol/lean.py +1018 -0
  189. unicode_logic_kit/hol/manyvalued.py +921 -0
  190. unicode_logic_kit/hol/secondorder.py +687 -0
  191. unicode_logic_kit/hol/thf_modal.py +941 -0
  192. unicode_logic_kit/hol/thirdorder.py +397 -0
  193. unicode_logic_kit/ilp/__init__.py +89 -0
  194. unicode_logic_kit/ilp/readback.py +389 -0
  195. unicode_logic_kit/ilp/separation.py +153 -0
  196. unicode_logic_kit/ilp/task.py +730 -0
  197. unicode_logic_kit/logic.py +163 -0
  198. unicode_logic_kit/mcp/__init__.py +28 -0
  199. unicode_logic_kit/mcp/__main__.py +5 -0
  200. unicode_logic_kit/mcp/chem_tools.py +1031 -0
  201. unicode_logic_kit/mcp/server.py +2453 -0
  202. unicode_logic_kit/mcp/syntax_spec.py +681 -0
  203. unicode_logic_kit/prob/__init__.py +53 -0
  204. unicode_logic_kit/prob/_bdd.py +225 -0
  205. unicode_logic_kit/prob/_column_gen.py +668 -0
  206. unicode_logic_kit/prob/distribution.py +686 -0
  207. unicode_logic_kit/prob/nilsson.py +470 -0
  208. unicode_logic_kit/py.typed +0 -0
  209. unicode_logic_kit/semantics/__init__.py +137 -0
  210. unicode_logic_kit/semantics/_modal_reject.py +156 -0
  211. unicode_logic_kit/semantics/action_models.py +466 -0
  212. unicode_logic_kit/semantics/asp_models.py +1200 -0
  213. unicode_logic_kit/semantics/conditional.py +580 -0
  214. unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
  215. unicode_logic_kit/semantics/free_logic.py +913 -0
  216. unicode_logic_kit/semantics/fuzzy.py +384 -0
  217. unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
  218. unicode_logic_kit/semantics/intuitionistic.py +581 -0
  219. unicode_logic_kit/semantics/kripke.py +1139 -0
  220. unicode_logic_kit/semantics/manyvalued.py +580 -0
  221. unicode_logic_kit/semantics/matrix.py +342 -0
  222. unicode_logic_kit/semantics/model_eval.py +1135 -0
  223. unicode_logic_kit/semantics/modelfinder.py +1036 -0
  224. unicode_logic_kit/semantics/nonmonotonic.py +372 -0
  225. unicode_logic_kit/semantics/relevant.py +331 -0
  226. unicode_logic_kit/semantics/secondorder.py +657 -0
  227. unicode_logic_kit/semantics/structures.py +352 -0
  228. unicode_logic_kit/semantics/tarski.py +975 -0
  229. unicode_logic_kit/semantics/team.py +315 -0
  230. unicode_logic_kit/semantics/team_translation.py +416 -0
  231. unicode_logic_kit/semantics/thirdorder.py +358 -0
  232. unicode_logic_kit/semantics/tnorm.py +85 -0
  233. unicode_logic_kit/semantics/truthtable.py +201 -0
  234. unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
  235. unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
  236. unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
  237. unicode_logic_kit-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,489 @@
1
+ """Portfolio scheduling: race several backends, take the first agreed answer.
2
+
3
+ :mod:`unicode_logic_kit.api`'s ``prove()`` runs a fixed backend chain IN ORDER,
4
+ one call at a time — cheap, deterministic, and the right default. This module
5
+ is the opposite tool: given an EXPLICIT list of backends, it runs them
6
+ CONCURRENTLY and returns as soon as enough of them agree. It is deliberately
7
+ opt-in (:func:`portfolio_prove` has no default backend list, unlike
8
+ ``default_chain``) — racing backends burns more CPU than a sequential chain
9
+ for the same query, so a caller must name the routes it wants raced.
10
+
11
+ Why processes, not threads
12
+ ---------------------------
13
+ Every backend that goes through Z3 shares one process-global Z3 context;
14
+ :class:`z3.Solver` is documented as not thread-safe, so two ``decide()``
15
+ calls racing in threads of the SAME process can corrupt each other's state
16
+ invisibly. Equally important: a native solver call in flight (Z3's C core,
17
+ a Prover9/Vampire subprocess wait) cannot be cancelled from a Python thread
18
+ — there is no safe way to interrupt it once started. A separate OS process
19
+ sidesteps both problems: no shared solver state, and "the loser is still
20
+ running" is handled by simply not waiting for it, never by trying to kill
21
+ its native call out from under it. Hence ``concurrent.futures.
22
+ ProcessPoolExecutor``, exactly like :func:`unicode_logic_kit.eval.batch.
23
+ batch_decide` — and the same project-wide cap applies (see memory: "cap all
24
+ parallelism at 8"): ``jobs`` is clamped to ``min(jobs, 8)``.
25
+
26
+ Contract
27
+ --------
28
+ * ``backends`` is REQUIRED — an unknown name raises ``ValueError`` and a
29
+ known-but-unavailable one raises
30
+ :class:`~unicode_logic_kit.atp.protocol.BackendUnavailable`, checked for
31
+ EVERY name before anything is spawned (never a partial run that discovers
32
+ a bad name three processes in). Availability is asked for the route the
33
+ call's own options select (``use_wsl=``, ``minizinc_path=``, ...), as
34
+ ``run_backend`` asks it.
35
+ * The options are planned exactly as ``prove()`` plans them
36
+ (:func:`~unicode_logic_kit.atp.protocol.plan_options`): a member is handed only
37
+ the options it reads, an option no member reads is a ``ValueError``, and a
38
+ member that cannot read an option that changes the question (a ``subsorts=``
39
+ edge, a modal ``frame=``) is not run -- it is listed as
40
+ ``unknown/unsupported`` and casts no vote, so no member ever answers a
41
+ question other than the one that was asked.
42
+ * ``require_agreement=1`` (the default): the first DEFINITIVE (proved /
43
+ refuted) verdict wins immediately; every other backend is left running
44
+ (see above) and the executor is shut down without waiting for them.
45
+ * ``require_agreement=n>1``: verdicts are tallied by status until ``n``
46
+ backends report the SAME definitive status; the winning ``Verdict`` carries
47
+ ``agreement`` as the tuple of backend names that concurred.
48
+ * **Contradiction is a soundness alarm, not something to smooth over.** If
49
+ one backend reports PROVED and another reports REFUTED for the same
50
+ query, that is a live bug in one of the two calculi (or their translation
51
+ to/from the shared AST) — auto-resolving it (e.g. "trust the majority")
52
+ would hide exactly the kind of bug this module exists to catch. The
53
+ result is ``Verdict(status="error", reason="infra", ...)`` naming both
54
+ backends, never a silent pick.
55
+ * No backend reaches a definitive verdict within ``require_agreement``
56
+ copies → an UNKNOWN verdict from the pseudo-backend ``"portfolio"``,
57
+ ``detail`` summarising every backend's own answer (mirrors ``prove()``'s
58
+ ``"chain"`` pseudo-backend, including its treatment of a member that
59
+ FAILED: quoted by its own message, and an ERROR verdict when every member
60
+ failed).
61
+ * ``jobs=1`` (forced, or the natural result of a single-element
62
+ ``backends``) never touches ``ProcessPoolExecutor`` — it runs the SAME
63
+ backends sequentially in THIS process. This is not just an optimisation:
64
+ it is also the only way to portfolio-race a backend that was registered
65
+ ad hoc in the calling process (e.g. a test double) and would not be
66
+ importable by name inside a spawned worker.
67
+ * **A caller's error is raised, whatever ``jobs`` is.** A member that refuses the
68
+ CALL — a modal ``frame=`` it does not know, a ``premise_names=`` list that does not
69
+ match the premises — raises ``ValueError`` (or
70
+ :class:`~unicode_logic_kit.atp.protocol.BackendUnavailable`) out of
71
+ :func:`portfolio_prove`: :func:`~unicode_logic_kit.atp.protocol.run_backend` keeps a
72
+ caller's error loud, and so do the sequential path and ``api.prove``. In a race the
73
+ error that is raised is the first one a member reports, and a member that has
74
+ already won when another reports its error wins, as the first member of
75
+ ``api.prove``'s chain that answers does. Only a failure that is NOT the caller's — a
76
+ worker process that died, an answer that cannot be read back — is recorded as
77
+ ``error`` / ``infra`` and quoted in the collective verdict.
78
+ * The premises are counted as ``api.prove`` counts them: the caller's own premises are
79
+ the ones ``premise_names=`` names and the ones an index (``relevant_premises``, a
80
+ Z3 core) refers to; the sentences of ``signature=`` and the side axioms of a
81
+ :class:`~unicode_logic_kit.logic.Sentence` are background, named for the writer and
82
+ never reported back.
83
+ """
84
+
85
+ from concurrent.futures import ProcessPoolExecutor, as_completed
86
+ from dataclasses import replace
87
+ from typing import Dict, List, Mapping, Optional, Sequence, Tuple
88
+
89
+ from ..fol.nodes import Node
90
+ from ..fol.serialize import deserialize as _fol_deserialize
91
+ from ..fol.serialize import serialize as _fol_serialize
92
+ from .protocol import (
93
+ ERROR, PROVED, REFUTED, UNKNOWN,
94
+ BackendUnavailable, Verdict, get_backend, plan_options, run_backend,
95
+ _available_for, _no_definitive_verdict,
96
+ )
97
+
98
+ __all__ = ["portfolio_prove"]
99
+
100
+ # Project-wide parallelism cap (see memory: "cap all parallelism at 8").
101
+ _MAX_JOBS = 8
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Agreement tally: shared by the sequential and process-pool paths
106
+ # ---------------------------------------------------------------------------
107
+
108
+ class _Contradiction(Exception):
109
+ """Raised internally when two definitive verdicts disagree.
110
+
111
+ Carries the PROVED and REFUTED verdicts that clashed so the caller can
112
+ build the soundness-alarm ``Verdict`` naming both backends.
113
+ """
114
+
115
+ def __init__(self, proved: Verdict, refuted: Verdict):
116
+ super().__init__(f"{proved.backend} says proved, {refuted.backend} says refuted")
117
+ self.proved = proved
118
+ self.refuted = refuted
119
+
120
+
121
+ def _feed(agreeing: Dict[str, List[Verdict]], verdict: Verdict,
122
+ require_agreement: int) -> Optional[Verdict]:
123
+ """Fold one DEFINITIVE verdict into the running tally.
124
+
125
+ Returns the winning ``Verdict`` (agreement reached), raises
126
+ :class:`_Contradiction` (a proved/refuted split), or returns ``None``
127
+ (keep collecting) — the three, and only three, outcomes of adding one
128
+ more definitive vote.
129
+ """
130
+ if verdict.status == PROVED and agreeing.get(REFUTED):
131
+ raise _Contradiction(verdict, agreeing[REFUTED][0])
132
+ if verdict.status == REFUTED and agreeing.get(PROVED):
133
+ raise _Contradiction(agreeing[PROVED][0], verdict)
134
+ group = agreeing.setdefault(verdict.status, [])
135
+ group.append(verdict)
136
+ if len(group) >= require_agreement:
137
+ first = group[0]
138
+ if len(group) == 1:
139
+ return first
140
+ return replace(first, agreement=tuple(v.backend for v in group))
141
+ return None
142
+
143
+
144
+ def _contradiction_verdict(logic: str, proved: Verdict, refuted: Verdict) -> Verdict:
145
+ """Build the ERROR verdict for a proved/refuted split between backends."""
146
+ return Verdict(
147
+ ERROR, "portfolio", logic=logic, reason="infra",
148
+ detail=(f"soundness alarm: {proved.backend!r} reports proved but "
149
+ f"{refuted.backend!r} reports refuted for the same query — "
150
+ f"not auto-resolved, investigate both backends"),
151
+ agreement=(proved.backend, refuted.backend),
152
+ )
153
+
154
+
155
+ def _unknown_verdict(logic: str, verdicts: List[Verdict]) -> Verdict:
156
+ """Build the collective verdict when nobody reached agreement.
157
+
158
+ The same rule as ``api.prove``'s chain (they must not answer one question
159
+ two ways): UNKNOWN, with a member that failed quoted by its own message,
160
+ and ERROR when every member failed -- see
161
+ :func:`~unicode_logic_kit.atp.protocol._no_definitive_verdict`.
162
+ """
163
+ return _no_definitive_verdict("portfolio", logic, verdicts, "empty backend list")
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Sequential path (jobs == 1): same process, no pool
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def _refused_verdict(name: str, logic: str, refusal: str) -> Verdict:
171
+ """The verdict of a member that was not run because it cannot read an option
172
+ that changes the question (the same verdict ``api.prove`` lists for it)."""
173
+ return Verdict(UNKNOWN, name, logic=logic, reason="unsupported", detail=refusal)
174
+
175
+
176
+ def _run_sequential(formula: Node, premises: Sequence[Node], backends: Sequence[str],
177
+ logic: str, timeout: int, require_agreement: int,
178
+ plan: Dict[str, Tuple[dict, Optional[str]]]) -> Verdict:
179
+ """Run ``backends`` one at a time in THIS process.
180
+
181
+ Used whenever ``jobs`` resolves to 1: either the caller forced it (e.g.
182
+ to portfolio-race a locally-registered test backend that a spawned
183
+ worker could not import) or a single-element ``backends`` made pooling
184
+ pointless. ``plan`` is what :func:`~unicode_logic_kit.atp.protocol.plan_options`
185
+ decided: the options each member is handed, or the reason it is not run.
186
+ """
187
+ agreeing: Dict[str, List[Verdict]] = {}
188
+ verdicts: List[Verdict] = []
189
+ for name in backends:
190
+ passed, refusal = plan[name]
191
+ if refusal is not None:
192
+ verdicts.append(_refused_verdict(name, logic, refusal))
193
+ continue
194
+ extra = dict(passed)
195
+ if name == "isabelle":
196
+ extra["logic"] = logic
197
+ verdict = run_backend(name, formula, premises, timeout=timeout, **extra)
198
+ verdicts.append(verdict)
199
+ if verdict.is_definitive:
200
+ try:
201
+ won = _feed(agreeing, verdict, require_agreement)
202
+ except _Contradiction as exc:
203
+ return _contradiction_verdict(logic, exc.proved, exc.refuted)
204
+ if won is not None:
205
+ return won
206
+ return _unknown_verdict(logic, verdicts)
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Process-pool path (jobs > 1)
211
+ # ---------------------------------------------------------------------------
212
+
213
+ def _verdict_from_dict(d: dict) -> Verdict:
214
+ """Reconstruct a ``Verdict`` from ``Verdict.to_dict()`` — round-trips
215
+ EVERY field, including ``relevant_premises`` and ``solver_version``
216
+ (both were silently dropped here before K1: a real bug, since this is
217
+ the process-pool path's only way back from a worker's JSON-safe dict to
218
+ a live ``Verdict``, so both fields vanished on every ``jobs>1``
219
+ portfolio race — see ``tests/test_portfolio.py``'s full-fields round
220
+ trip, which pins ``Verdict.to_dict()`` unchanged by this reconstruction).
221
+ """
222
+ return Verdict(
223
+ status=d["status"], backend=d["backend"], logic=d["logic"],
224
+ reason=d["reason"], szs_status=d["szs_status"], wall_time=d["wall_time"],
225
+ solver_version=d["solver_version"],
226
+ countermodel=d["countermodel"], proof=d["proof"], detail=d["detail"],
227
+ agreement=tuple(d["agreement"]),
228
+ relevant_premises=(tuple(d["relevant_premises"])
229
+ if d["relevant_premises"] is not None else None),
230
+ )
231
+
232
+
233
+ def _crash_verdict(name: str, logic: str, exc: BaseException) -> Verdict:
234
+ """The verdict recorded for a member whose worker failed for a reason that is not the
235
+ caller's: a broken pool, an answer that does not come back as a verdict."""
236
+ return Verdict(ERROR, name, logic=logic, reason="infra",
237
+ detail=f"{type(exc).__name__}: {exc}")
238
+
239
+
240
+ def _worker_decide(payload: dict) -> dict:
241
+ """``ProcessPoolExecutor`` target — MUST stay module-level for Windows
242
+ spawn (it pickles the target by qualified name, not by closure).
243
+
244
+ ``payload`` carries only JSON-safe, picklable data: the formula and
245
+ premises travel as :mod:`unicode_logic_kit.fol.serialize` envelopes rather
246
+ than raw ``Node`` objects, so this worker does not depend on ``Node``'s
247
+ own pickle-ability. Deserialises, decides through
248
+ :func:`~unicode_logic_kit.atp.protocol.run_backend` (the SAME
249
+ availability/error contract as any other caller), and returns a plain
250
+ ``{"backend", "verdict"}`` dict — itself picklable back to the parent.
251
+ """
252
+ formula = _fol_deserialize(payload["formula"])
253
+ premises = [_fol_deserialize(p) for p in payload["premises"]]
254
+ name = payload["backend"]
255
+ extra = dict(payload["options"])
256
+ if name == "isabelle":
257
+ extra["logic"] = payload["logic"]
258
+ verdict = run_backend(name, formula, premises, timeout=payload["timeout"], **extra)
259
+ return {"backend": name, "verdict": verdict.to_dict()}
260
+
261
+
262
+ def _as_plain_data(value):
263
+ """``value`` with every read-only mapping inside it as a plain ``dict``.
264
+
265
+ An option travels to a worker process by pickling. A mapping that is not a ``dict``
266
+ need not pickle: :attr:`~unicode_logic_kit.fol.signature.Signature.subsorts` is a
267
+ read-only view (``mappingproxy``), and ``subsorts=sig.subsorts`` is how a caller
268
+ passes it. The copy has the same keys and values, which is all a backend reads.
269
+ """
270
+ if isinstance(value, Mapping) and type(value) is not dict:
271
+ return {key: _as_plain_data(item) for key, item in value.items()}
272
+ return value
273
+
274
+
275
+ def _run_parallel(formula: Node, premises: Sequence[Node], backends: Sequence[str],
276
+ logic: str, timeout: int, require_agreement: int, n_jobs: int,
277
+ plan: Dict[str, Tuple[dict, Optional[str]]]) -> Verdict:
278
+ """Race ``backends`` across ``n_jobs`` worker processes.
279
+
280
+ Every backend is submitted up front, each with the options ``plan`` hands it
281
+ (a member that ``plan`` refuses is not submitted; its refusal is listed with
282
+ the verdicts); verdicts are folded into the
283
+ agreement tally in COMPLETION order (``as_completed``), so the winner is
284
+ genuinely whichever finishes first, not submission order. On a win or a
285
+ contradiction the executor is shut down with ``wait=False,
286
+ cancel_futures=True``: futures not yet started are dropped, and futures
287
+ already running are simply abandoned — a running native solver call
288
+ cannot be interrupted from here (see module docstring), so the orphaned
289
+ worker process just finishes on its own and exits.
290
+ """
291
+ formula_env = _fol_serialize(formula)
292
+ premise_envs = [_fol_serialize(p) for p in premises]
293
+
294
+ agreeing: Dict[str, List[Verdict]] = {}
295
+ verdicts: List[Verdict] = []
296
+ for name in backends:
297
+ refusal = plan[name][1]
298
+ if refusal is not None:
299
+ verdicts.append(_refused_verdict(name, logic, refusal))
300
+ executor = ProcessPoolExecutor(max_workers=n_jobs)
301
+ try:
302
+ future_to_name = {}
303
+ for name in backends:
304
+ if plan[name][1] is not None:
305
+ continue
306
+ payload = {
307
+ "backend": name,
308
+ "formula": formula_env,
309
+ "premises": premise_envs,
310
+ "logic": logic,
311
+ "timeout": timeout,
312
+ "options": {key: _as_plain_data(value) for key, value in plan[name][0].items()},
313
+ }
314
+ future_to_name[executor.submit(_worker_decide, payload)] = name
315
+
316
+ for future in as_completed(future_to_name):
317
+ name = future_to_name[future]
318
+ try:
319
+ result = future.result()
320
+ except (ValueError, BackendUnavailable):
321
+ # A member refused the CALL (an unknown frame, a premise_names list of the
322
+ # wrong length). run_backend keeps that loud, and so do the sequential path
323
+ # and api.prove: it is raised here too, not recorded as a failure of the
324
+ # infrastructure.
325
+ raise
326
+ except Exception as exc: # worker crash (e.g. broken pool) -> recorded
327
+ verdict = _crash_verdict(name, logic, exc)
328
+ else:
329
+ try:
330
+ verdict = _verdict_from_dict(result["verdict"])
331
+ except Exception as exc:
332
+ verdict = _crash_verdict(name, logic, exc)
333
+ verdicts.append(verdict)
334
+ if verdict.is_definitive:
335
+ try:
336
+ won = _feed(agreeing, verdict, require_agreement)
337
+ except _Contradiction as exc2:
338
+ return _contradiction_verdict(logic, exc2.proved, exc2.refuted)
339
+ if won is not None:
340
+ return won
341
+ return _unknown_verdict(logic, verdicts)
342
+ finally:
343
+ executor.shutdown(wait=False, cancel_futures=True)
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Public entry point
348
+ # ---------------------------------------------------------------------------
349
+
350
+ def portfolio_prove(formula: Node, premises: Sequence[Node] = (), *,
351
+ backends: Sequence[str], logic: str = "fol",
352
+ timeout: int = 10000, require_agreement: int = 1,
353
+ jobs: Optional[int] = None, signature=None,
354
+ **options) -> Verdict:
355
+ """Decide ``premises ⊨ formula`` by racing ``backends`` concurrently.
356
+
357
+ Unlike :func:`unicode_logic_kit.api.prove`, this is an explicit-opt-in
358
+ tool: there is no default backend list, so a caller always names exactly
359
+ which routes it wants raced against each other.
360
+
361
+ Args:
362
+ formula: the goal to decide. A :class:`~unicode_logic_kit.logic.Sentence` in
363
+ classical first-order logic is accepted as :func:`unicode_logic_kit.api.prove`
364
+ accepts it: its side axioms are added to the premises.
365
+ premises: local premises; folded into ``(∧ premises) → formula``
366
+ exactly as every other backend entry point does. A Sentence among
367
+ them brings its side axioms, as for ``formula``.
368
+ backends: REQUIRED, non-empty. Every name is validated (unknown name
369
+ → ``ValueError``, known-but-unavailable → ``BackendUnavailable``)
370
+ and checked against ``logic`` (mismatched → ``ValueError``)
371
+ BEFORE anything is spawned — never a partial run that discovers
372
+ a bad name mid-flight.
373
+ logic: the logic every backend in ``backends`` must support
374
+ (``"fol"``, ``"modal"``, ...). No auto-detection here (contrast
375
+ ``prove(logic="auto")``) — a portfolio's whole point is a
376
+ caller-chosen, homogeneous backend set.
377
+ timeout: forwarded to every backend, in milliseconds.
378
+ require_agreement: how many backends must report the SAME definitive
379
+ status before their verdict is returned (default 1: first
380
+ definitive answer wins). The returned ``Verdict.agreement``
381
+ lists exactly those backend names.
382
+ jobs: worker processes. ``None`` (default) picks
383
+ ``min(len(backends), 8)``; any explicit value is clamped to
384
+ ``min(jobs, 8)`` (project-wide cap) and floored at 1. ``jobs==1``
385
+ runs sequentially in THIS process — no
386
+ ``ProcessPoolExecutor`` — which is also the only way to race a
387
+ backend registered ad hoc in the caller's own process (a spawned
388
+ worker cannot import a name that only exists there).
389
+ signature: a :class:`~unicode_logic_kit.fol.signature.Signature`, read
390
+ exactly as :func:`unicode_logic_kit.api.prove` reads it: the sentences
391
+ :func:`~unicode_logic_kit.fol.signature_axioms` returns are added to the
392
+ premises of every member, and a premise index that comes back
393
+ (``relevant_premises``, a Z3 core) stays an index into ``premises``.
394
+ ``premise_names=`` names the caller's own premises only; the sentences the
395
+ signature adds are named for the writer. Classical first-order routes only.
396
+ **options: planned exactly as :func:`unicode_logic_kit.api.prove` plans
397
+ them (:func:`~unicode_logic_kit.atp.protocol.plan_options`). A member is
398
+ handed only the options it reads. An option that NO member reads is a
399
+ ``ValueError`` naming it, raised before anything runs: it would
400
+ otherwise be ignored and the answer given to a question the caller
401
+ did not ask. A member that does not read an option that changes the
402
+ question (a modal ``frame=``, a ``subsorts=`` edge, ``bridges=``)
403
+ while another member does is NOT run: it is listed in the collective
404
+ verdict's ``detail`` as ``unknown/unsupported`` with the option
405
+ named, and it never casts a vote. An option that only bounds a search
406
+ or says where a binary lives (``max_steps=``, ``use_wsl=``) is simply
407
+ not handed to a member that has no use for it.
408
+
409
+ Returns:
410
+ The winning ``Verdict`` on agreement; a ``Verdict(status="error",
411
+ reason="infra", ...)`` naming both sides if two backends disagree
412
+ (PROVED vs REFUTED — a soundness alarm, never silently resolved);
413
+ otherwise a collective ``Verdict(status="unknown", backend=
414
+ "portfolio", ...)`` whose ``detail`` summarises every backend's own
415
+ answer.
416
+
417
+ Raises:
418
+ ValueError: ``backends`` is empty, ``require_agreement < 1``, a
419
+ backend name is unregistered, a named backend does not
420
+ support ``logic``, an option is read by no member, a Sentence is
421
+ in a logic other than classical first-order logic, ``signature``
422
+ is given for a logic other than ``"fol"``, or a member refuses the
423
+ call itself (an unknown ``frame=``, a ``premise_names=`` list of the
424
+ wrong length) — the same ``ValueError`` for every ``jobs``.
425
+ TypeError: ``signature`` is not a
426
+ :class:`~unicode_logic_kit.fol.signature.Signature`.
427
+ BackendUnavailable: a named backend is registered but its
428
+ prerequisites (binary, install) are missing, asked for the route
429
+ this call's own options select
430
+ (:meth:`~unicode_logic_kit.atp.protocol.ProverBackend.available_for`).
431
+ """
432
+ if not backends:
433
+ raise ValueError(
434
+ "portfolio_prove: `backends` must be a non-empty explicit list — "
435
+ "the portfolio is opt-in, there is no default chain "
436
+ "(use unicode_logic_kit.api.prove for that).")
437
+ if require_agreement < 1:
438
+ raise ValueError(
439
+ f"portfolio_prove: require_agreement must be >= 1, got {require_agreement!r}")
440
+
441
+ from ..api import ( # lazy: api imports this package
442
+ _name_background, _signature_premises, _unwrap_sentences, _without_background,
443
+ )
444
+
445
+ backends = list(backends)
446
+ premises = list(premises)
447
+ given = len(premises) # the caller's own premises
448
+ formula, premises = _unwrap_sentences(formula, premises, "portfolio_prove")
449
+
450
+ for name in backends:
451
+ backend = get_backend(name) # ValueError on unknown name
452
+ if logic not in backend.logics:
453
+ raise ValueError(
454
+ f"portfolio_prove: backend {name!r} does not support logic {logic!r} "
455
+ f"(it handles {sorted(backend.logics)})")
456
+
457
+ if signature is not None:
458
+ premises = [*premises, *_signature_premises("portfolio_prove", signature, logic)]
459
+ # What was appended to the caller's premises (a signature's sentences, the side
460
+ # axioms of a Sentence) is background: the caller's premise_names cover the caller's
461
+ # premises, and the background is named for the writer, exactly as api.prove does.
462
+ options = _name_background(options, given, len(premises), "portfolio_prove")
463
+
464
+ # The options of the call are planned as api.prove plans them: a member is
465
+ # handed what it reads, an option nobody reads is a ValueError, and a member
466
+ # that cannot read an option that changes the question is not run at all.
467
+ plan = plan_options("portfolio_prove", backends, logic, options)
468
+
469
+ for name in backends:
470
+ backend = get_backend(name)
471
+ if not _available_for(backend, plan[name][0]): # the route this call selects
472
+ raise BackendUnavailable(
473
+ f"{name}: backend is not available on this machine "
474
+ f"(external={backend.external}) — install it or drop it from `backends`.")
475
+
476
+ if jobs is None:
477
+ n_jobs = min(len(backends), _MAX_JOBS)
478
+ else:
479
+ n_jobs = max(1, min(int(jobs), _MAX_JOBS))
480
+
481
+ if n_jobs == 1:
482
+ verdict = _run_sequential(formula, premises, backends, logic, timeout,
483
+ require_agreement, plan)
484
+ else:
485
+ verdict = _run_parallel(formula, premises, backends, logic, timeout,
486
+ require_agreement, n_jobs, plan)
487
+ if len(premises) > given:
488
+ verdict = _without_background(verdict, given)
489
+ return verdict