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,913 @@
1
+ r"""Free logic — first-order logic without the existence assumption.
2
+
3
+ Classical FOL assumes every term denotes an *existing* individual, so universal
4
+ instantiation ``∀x φ → φ(c)`` and existential generalisation ``φ(c) → ∃x φ`` are
5
+ valid. **Free logic** drops that assumption: quantifiers range only over an *inner
6
+ domain* of existing objects ``E``, while constants and function terms may denote an
7
+ object of the wider outer domain, or fail to denote at all. An existence predicate
8
+ ``E!(t)`` says "``t`` denotes an existing object", and the classical inference rules
9
+ hold only in their *guarded* forms ``(∀x φ ∧ E!(c)) → φ(c)`` and ``(φ(c) ∧ E!(c)) → ∃x φ``.
10
+
11
+ A :class:`FreeModel` carries an ``outer`` domain, the ``existing`` inner subset, a
12
+ (possibly partial) constant/function interpretation, and predicate tables over the
13
+ outer domain. Three policies for an atom that contains a **non-denoting** term:
14
+
15
+ - ``"negative"`` (default) — the atom is simply **false** (negative free logic;
16
+ ``t = t`` then also fails when ``t`` does not denote);
17
+ - ``"positive"`` — self-identity ``t = t`` is **true** for any term, while every other
18
+ atom with a non-denoting term is false (the common positive-free-logic convention
19
+ for identity);
20
+ - ``"supervaluation"`` — treats every ground atom with a non-denoting term as a
21
+ genuine truth-value **gap** rather than forcing it false, then asks whether the
22
+ *whole formula* comes out true under **every** classical way of filling the gaps
23
+ (a *precisification*), false under every one, or neither. A formula is true
24
+ (*supertrue*) iff every precisification makes it true, false (*superfalse*) iff
25
+ every precisification makes it false, and otherwise it is itself a gap — reported
26
+ as ``False``, the same convention ``"negative"`` already uses for a single gappy
27
+ atom (``free_satisfies`` always returns a plain ``bool``; there is no third value
28
+ in the return type). The distinguishing case is a classical tautology such as
29
+ ``P(e) ∨ ¬P(e)`` for non-denoting ``e``: each disjunct is individually a gap, yet
30
+ *every* precisification of ``P(e)`` (true or false) makes the disjunction true, so
31
+ the whole formula is supertrue — unlike naively combining each disjunct's own
32
+ gappy verdict, which would stay a gap. See :func:`free_satisfies` for the bounded
33
+ precisification search this policy runs (:data:`SUPERVALUATION_MAX_GAPS`).
34
+
35
+ Beyond single-model checking, :func:`free_find_model`, :func:`free_countermodel`,
36
+ :func:`free_is_valid` and :func:`free_entails` add a Mace4-style **bounded
37
+ search** over ``FreeModel``\ s, in the style of
38
+ :mod:`~unicode_logic_kit.semantics.modelfinder`: every outer domain size up to a bound,
39
+ every existing/outer split, every partial constant/function assignment, and every
40
+ predicate extension. See their docstrings for the honest bounded-search contract.
41
+ By default (``symmetry_breaking=True``) the partial constant assignment is
42
+ LNH-canonical rather than exhaustive — the same symmetry-breaking pass
43
+ :mod:`~unicode_logic_kit.semantics.modelfinder` applies (roadmap C23); see
44
+ ``_search``'s docstring.
45
+
46
+ **Numerals.** A numeral is a constant identified by its VALUE (``Number(1) == Number(1.0)``:
47
+ one constant, named ``'1'`` in ``FreeModel.constants``), so it may fail to denote like any
48
+ constant, and nothing else is known about it: ``P(1) ⊢ P(1.0)`` is valid and ``⊢ 1 ≠ 2`` is
49
+ not. ``+ - * /`` are partial function symbols and ``< > ≤ ≥`` ordinary predicates. A
50
+ numeral and a constant spelled like its value (``Number(1)`` next to ``Constant('1')``) would
51
+ share one entry of ``constants``, so the pair is refused by name.
52
+
53
+ **A free variable is a parameter.** The search routes read a free variable as ONE unknown
54
+ EXISTING object, the same in every formula handed to a call (the assignment-wise consequence
55
+ relation: a variable ranges over the inner domain, as a bound one does). It is replaced, in all
56
+ the formulas of the call together, by a constant of its own name
57
+ (:func:`~unicode_logic_kit.fol._free_parameters.parameterize`) and the model must satisfy
58
+ ``E!`` of that constant, so a model whose ``existing`` domain is empty is no model of a
59
+ problem that has a free variable, and the constant is reported in ``FreeModel.constants`` under
60
+ the variable's name. No formula is closed universally, and a conclusion is never negated
61
+ before its variables are replaced: ``P(x) ⊢ P(x)`` and ``P(x) ⊢ ∃y P(y)`` are valid,
62
+ ``P(x) ⊢ P(alpha)`` is not. A free variable spelled like a constant of the problem is refused
63
+ (``NotImplementedError``): ``FreeModel.constants`` holds one entry per name.
64
+
65
+ Public API: :class:`FreeModel`, :data:`NONDENOTING`, :data:`SUPERVALUATION_MAX_GAPS`,
66
+ :func:`free_satisfies`, :func:`free_holds`, :func:`free_find_model`,
67
+ :func:`free_countermodel`, :func:`free_is_valid`, :func:`free_entails`.
68
+ """
69
+
70
+ from dataclasses import dataclass, field
71
+ from itertools import product
72
+ from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Sequence, Tuple
73
+
74
+ from ..fol.nodes import (
75
+ Node, Atom, Not, And, Or, Xor, Implies, Iff, Quantifier,
76
+ Variable, Constant, Number, Function,
77
+ )
78
+ from ..fol._fol_nodes import numeral_key
79
+ from ..fol._truth_constants import truth_value as _truth_value
80
+ from ..fol._free_parameters import parameterize
81
+ from .modelfinder import _lnh_choices
82
+ from .tarski import _refuse_numeral_constant_pair
83
+
84
+ # Sentinel returned by term evaluation when a term has no referent.
85
+ NONDENOTING = object()
86
+
87
+ _FORALL = ("∀", "forall")
88
+ _EXISTS = ("∃", "exists")
89
+ _EXISTS_PRED = "E!" # the existence predicate
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class FreeModel:
94
+ """A free-logic model: an inner ``existing`` domain inside an ``outer`` domain.
95
+
96
+ ``outer`` lists every object (existing or merely possible); ``existing`` is the
97
+ inner domain the quantifiers range over (⊆ ``outer``). ``constants`` maps a name to
98
+ an ``outer`` element — a name absent from the map is **non-denoting**. ``functions``
99
+ maps ``(name, arity)`` to a partial table ``{argtuple: value}`` (a missing entry, or
100
+ any non-denoting argument, makes the application non-denoting). ``predicates`` maps
101
+ ``(name, arity)`` to a set of ``outer`` tuples.
102
+ """
103
+
104
+ outer: Tuple[Any, ...]
105
+ existing: FrozenSet[Any]
106
+ constants: Mapping[str, Any] = field(default_factory=dict)
107
+ functions: Mapping[Tuple[str, int], Mapping[Tuple[Any, ...], Any]] = field(default_factory=dict)
108
+ predicates: Mapping[Tuple[str, int], FrozenSet[Tuple[Any, ...]]] = field(default_factory=dict)
109
+
110
+
111
+ def _term_value(term: Node, model: FreeModel, assignment: Mapping[str, Any]):
112
+ """Evaluate a term to an outer-domain element, or :data:`NONDENOTING`."""
113
+ if isinstance(term, Variable):
114
+ return assignment.get(term.name, NONDENOTING)
115
+ if isinstance(term, Constant):
116
+ return model.constants.get(term.name, NONDENOTING)
117
+ if isinstance(term, Number):
118
+ # a numeral is the constant of its VALUE (1 and 1.0 are one), absent = non-denoting
119
+ return model.constants.get(numeral_key(term.value), NONDENOTING)
120
+ if isinstance(term, Function):
121
+ args = tuple(_term_value(a, model, assignment) for a in term.args)
122
+ if any(v is NONDENOTING for v in args):
123
+ return NONDENOTING
124
+ table = model.functions.get((term.name, len(term.args)), {})
125
+ return table.get(args, NONDENOTING)
126
+ raise TypeError(f"free_logic: not a term: {type(term).__name__}")
127
+
128
+
129
+ def free_satisfies(formula: Node, model: FreeModel,
130
+ assignment: Optional[Mapping[str, Any]] = None,
131
+ policy: str = "negative") -> bool:
132
+ """Return whether ``model`` satisfies ``formula`` under free-logic semantics.
133
+
134
+ Quantifiers range over ``model.existing``; ``E!(t)`` is true iff ``t`` denotes an
135
+ existing object; an atom with a non-denoting term is handled per ``policy``
136
+ (``"negative"`` / ``"positive"`` / ``"supervaluation"`` — see the module
137
+ docstring). Under ``"supervaluation"`` this delegates to :func:`_supervaluate`,
138
+ a bounded search over every classical completion of the formula's gap atoms
139
+ (capped at :data:`SUPERVALUATION_MAX_GAPS` distinct gaps).
140
+
141
+ A numeral is a constant identified by its VALUE (``1`` and ``1.0`` are one, looked up
142
+ under ``'1'`` in ``model.constants``, and non-denoting when absent there).
143
+
144
+ Raises:
145
+ NotImplementedError: ``formula`` holds a :class:`Number` and a constant spelled like
146
+ its value (``Number(1)`` next to ``Constant('1')``): ``model.constants`` has ONE
147
+ entry ``'1'`` for both, so the kit refuses to merge a numeral with the constant
148
+ of the same spelling.
149
+ """
150
+ if assignment is None:
151
+ assignment = {}
152
+ if policy not in ("negative", "positive", "supervaluation"):
153
+ raise ValueError(
154
+ f"free_satisfies: unknown policy {policy!r} (negative / positive / supervaluation).")
155
+ _refuse_numeral_constant_pair([formula], "semantics.free_logic")
156
+ return _satisfies(formula, model, assignment, policy)
157
+
158
+
159
+ def _satisfies(formula: Node, model: FreeModel, assignment: Mapping[str, Any],
160
+ policy: str) -> bool:
161
+ """:func:`free_satisfies` without the checks of the formula as a whole, which the
162
+ entry made: every recursive step and the model search land here."""
163
+ if policy == "supervaluation":
164
+ return _supervaluate(formula, model, assignment)
165
+
166
+ if isinstance(formula, Atom):
167
+ return _atom(formula, model, assignment, policy)
168
+ if isinstance(formula, Not):
169
+ return not _satisfies(formula.formula, model, assignment, policy)
170
+ if isinstance(formula, And):
171
+ return (_satisfies(formula.left, model, assignment, policy)
172
+ and _satisfies(formula.right, model, assignment, policy))
173
+ if isinstance(formula, Or):
174
+ return (_satisfies(formula.left, model, assignment, policy)
175
+ or _satisfies(formula.right, model, assignment, policy))
176
+ if isinstance(formula, Xor):
177
+ return (_satisfies(formula.left, model, assignment, policy)
178
+ != _satisfies(formula.right, model, assignment, policy))
179
+ if isinstance(formula, Implies):
180
+ return ((not _satisfies(formula.left, model, assignment, policy))
181
+ or _satisfies(formula.right, model, assignment, policy))
182
+ if isinstance(formula, Iff):
183
+ return (_satisfies(formula.left, model, assignment, policy)
184
+ == _satisfies(formula.right, model, assignment, policy))
185
+ if isinstance(formula, Quantifier):
186
+ var = formula.variable.name
187
+ results = (
188
+ _satisfies(formula.formula, model, {**assignment, var: d}, policy)
189
+ for d in model.existing
190
+ )
191
+ if formula.type in _FORALL:
192
+ return all(results)
193
+ if formula.type in _EXISTS:
194
+ return any(results)
195
+ raise ValueError(f"free_satisfies: unknown quantifier {formula.type!r}")
196
+ raise TypeError(f"free_satisfies: unsupported node {type(formula).__name__}")
197
+
198
+
199
+ def _atom(atom: Atom, model: FreeModel, assignment: Mapping[str, Any], policy: str) -> bool:
200
+ """Truth value of an atom, applying the non-denoting policy."""
201
+ constant = _truth_value(atom)
202
+ if constant is not None:
203
+ return constant # `$true` / `$false` have no term, so nothing can fail to denote
204
+ values = tuple(_term_value(a, model, assignment) for a in atom.args)
205
+ nondenoting = any(v is NONDENOTING for v in values)
206
+
207
+ if atom.predicate == _EXISTS_PRED and len(atom.args) == 1:
208
+ return (not nondenoting) and values[0] in model.existing
209
+ if atom.predicate in ("=", "≠"):
210
+ if nondenoting:
211
+ # negative: t=t false when t non-denoting; positive: self-identity true.
212
+ eq = (policy == "positive" and atom.args[0] == atom.args[1])
213
+ return eq if atom.predicate == "=" else (not eq)
214
+ eq = values[0] == values[1]
215
+ return eq if atom.predicate == "=" else (not eq)
216
+ if nondenoting:
217
+ return False # negative & positive agree for predicates
218
+ relation = model.predicates.get((atom.predicate, len(atom.args)), frozenset())
219
+ return values in relation
220
+
221
+
222
+ def free_holds(formula: Node, model: FreeModel, policy: str = "negative") -> bool:
223
+ """Convenience: ``free_satisfies`` of a closed ``formula`` (empty assignment)."""
224
+ return free_satisfies(formula, model, {}, policy)
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Supervaluationism — policy="supervaluation" for free_satisfies.
229
+ #
230
+ # A "gap atom" is a ground occurrence of an ordinary predicate atom (never ``=``,
231
+ # ``≠`` or ``E!`` — those are always decided, per the negative-free-logic reading,
232
+ # regardless of policy: identity/existence are not treated as an additional locus
233
+ # of gap here) that has a non-denoting argument. A *precisification* assigns each
234
+ # distinct gap atom a classical truth value, consistently everywhere it recurs in
235
+ # the formula (the same ground atom under a quantifier, e.g. one instantiated at
236
+ # the same domain element from two different sub-formulas, gets the same value).
237
+ # The formula is supertrue / superfalse / a gap according to whether every, no, or
238
+ # some-but-not-all of the 2**n precisifications make it true — see the module
239
+ # docstring for the running P(e) ∨ ¬P(e) example.
240
+ # ---------------------------------------------------------------------------
241
+
242
+ #: Hard cap on the number of distinct gap atoms a single supervaluation may
243
+ #: enumerate (2**n precisifications to check, each a full recursive evaluation of
244
+ #: the formula) — exceeding it raises rather than silently truncating the search.
245
+ SUPERVALUATION_MAX_GAPS = 16
246
+
247
+
248
+ def _term_repr(term: Node, assignment: Mapping[str, Any]) -> Tuple[Any, ...]:
249
+ """A hashable structural key identifying ``term`` under ``assignment``.
250
+
251
+ Unlike :func:`_term_value`, this never collapses to :data:`NONDENOTING` — two
252
+ syntactically different non-denoting terms (e.g. two different constants
253
+ absent from ``model.constants``) get different keys, so they can be
254
+ precisified independently, while the same term recurring (e.g. under a
255
+ quantifier, once per bound variable) gets the same key both times, so
256
+ :func:`_supervaluate` can hold it to a single consistent precisified value.
257
+ """
258
+ if isinstance(term, Variable):
259
+ return ("var", assignment.get(term.name))
260
+ if isinstance(term, Constant):
261
+ return ("const", term.name)
262
+ if isinstance(term, Number):
263
+ return ("num", numeral_key(term.value))
264
+ if isinstance(term, Function):
265
+ return ("fn", term.name, tuple(_term_repr(a, assignment) for a in term.args))
266
+ raise TypeError(f"free_logic: not a term: {type(term).__name__}")
267
+
268
+
269
+ def _is_gap_candidate(atom: Atom) -> bool:
270
+ """Whether ``atom`` is ever subject to precisification (identity/E!/$true/$false never are)."""
271
+ if _truth_value(atom) is not None:
272
+ return False
273
+ if atom.predicate == _EXISTS_PRED and len(atom.args) == 1:
274
+ return False
275
+ if atom.predicate in ("=", "≠"):
276
+ return False
277
+ return True
278
+
279
+
280
+ def _gap_key(atom: Atom, assignment: Mapping[str, Any]) -> Tuple[Any, ...]:
281
+ """The precisification-dict key for a gappy occurrence of ``atom``."""
282
+ return (atom.predicate, tuple(_term_repr(a, assignment) for a in atom.args))
283
+
284
+
285
+ def _collect_gap_atoms(formula: Node, model: FreeModel,
286
+ assignment: Mapping[str, Any], gaps: set) -> None:
287
+ """Populate ``gaps`` with the key of every non-denoting gap-candidate atom in
288
+ ``formula``, instantiating quantifiers over ``model.existing`` (finite domain)."""
289
+ if isinstance(formula, Atom):
290
+ if not _is_gap_candidate(formula):
291
+ return
292
+ values = tuple(_term_value(a, model, assignment) for a in formula.args)
293
+ if any(v is NONDENOTING for v in values):
294
+ gaps.add(_gap_key(formula, assignment))
295
+ return
296
+ if isinstance(formula, Quantifier):
297
+ var = formula.variable.name
298
+ for d in model.existing:
299
+ _collect_gap_atoms(formula.formula, model, {**assignment, var: d}, gaps)
300
+ return
301
+ for child in formula._child_nodes():
302
+ _collect_gap_atoms(child, model, assignment, gaps)
303
+
304
+
305
+ def _atom_precisified(atom: Atom, model: FreeModel, assignment: Mapping[str, Any],
306
+ precisification: Mapping[Tuple[Any, ...], bool]) -> bool:
307
+ """Truth value of ``atom`` under one precisification (identity/E! bypass it)."""
308
+ if not _is_gap_candidate(atom):
309
+ return _atom(atom, model, assignment, "negative")
310
+ values = tuple(_term_value(a, model, assignment) for a in atom.args)
311
+ if any(v is NONDENOTING for v in values):
312
+ return precisification[_gap_key(atom, assignment)]
313
+ relation = model.predicates.get((atom.predicate, len(atom.args)), frozenset())
314
+ return values in relation
315
+
316
+
317
+ def _eval_precisified(formula: Node, model: FreeModel, assignment: Mapping[str, Any],
318
+ precisification: Mapping[Tuple[Any, ...], bool]) -> bool:
319
+ """``free_satisfies``'s recursion, but gap atoms resolve via ``precisification``."""
320
+ if isinstance(formula, Atom):
321
+ return _atom_precisified(formula, model, assignment, precisification)
322
+ if isinstance(formula, Not):
323
+ return not _eval_precisified(formula.formula, model, assignment, precisification)
324
+ if isinstance(formula, And):
325
+ return (_eval_precisified(formula.left, model, assignment, precisification)
326
+ and _eval_precisified(formula.right, model, assignment, precisification))
327
+ if isinstance(formula, Or):
328
+ return (_eval_precisified(formula.left, model, assignment, precisification)
329
+ or _eval_precisified(formula.right, model, assignment, precisification))
330
+ if isinstance(formula, Xor):
331
+ return (_eval_precisified(formula.left, model, assignment, precisification)
332
+ != _eval_precisified(formula.right, model, assignment, precisification))
333
+ if isinstance(formula, Implies):
334
+ return ((not _eval_precisified(formula.left, model, assignment, precisification))
335
+ or _eval_precisified(formula.right, model, assignment, precisification))
336
+ if isinstance(formula, Iff):
337
+ return (_eval_precisified(formula.left, model, assignment, precisification)
338
+ == _eval_precisified(formula.right, model, assignment, precisification))
339
+ if isinstance(formula, Quantifier):
340
+ var = formula.variable.name
341
+ results = (
342
+ _eval_precisified(formula.formula, model, {**assignment, var: d}, precisification)
343
+ for d in model.existing
344
+ )
345
+ if formula.type in _FORALL:
346
+ return all(results)
347
+ if formula.type in _EXISTS:
348
+ return any(results)
349
+ raise ValueError(f"free_satisfies: unknown quantifier {formula.type!r}")
350
+ raise TypeError(f"free_satisfies: unsupported node {type(formula).__name__}")
351
+
352
+
353
+ def _supervaluate(formula: Node, model: FreeModel, assignment: Mapping[str, Any]) -> bool:
354
+ """``policy="supervaluation"`` entry point: enumerate every precisification.
355
+
356
+ Collects the formula's gap atoms (quantifiers instantiated over
357
+ ``model.existing``), enumerates all ``2**n`` classical completions, and
358
+ evaluates the formula under each with :func:`_eval_precisified`. Returns
359
+ ``True`` iff every completion agrees on ``True`` (supertrue), ``False`` iff
360
+ every completion agrees on ``False`` (superfalse) OR the completions disagree
361
+ (a genuine gap — see the module docstring for why this collapses to the same
362
+ ``False`` a lone gappy atom already returns under ``"negative"``).
363
+ """
364
+ gaps: set = set()
365
+ _collect_gap_atoms(formula, model, assignment, gaps)
366
+ if len(gaps) > SUPERVALUATION_MAX_GAPS:
367
+ raise ValueError(
368
+ f"free_satisfies: supervaluation over {len(gaps)} distinct gap atom(s) exceeds "
369
+ f"SUPERVALUATION_MAX_GAPS = {SUPERVALUATION_MAX_GAPS} (2**n precisifications to "
370
+ "check, each a full evaluation of the formula); reformulate with fewer "
371
+ "non-denoting ground atoms, or evaluate under policy='negative' / 'positive' "
372
+ "instead.")
373
+ gap_list = sorted(gaps, key=repr) # order just needs to be fixed across the loop below
374
+
375
+ saw_true = False
376
+ saw_false = False
377
+ for bits in product((False, True), repeat=len(gap_list)):
378
+ precisification = dict(zip(gap_list, bits))
379
+ if _eval_precisified(formula, model, assignment, precisification):
380
+ saw_true = True
381
+ else:
382
+ saw_false = True
383
+ if saw_true and saw_false:
384
+ return False # already a gap; no need to check the rest
385
+ return saw_true and not saw_false
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Bounded model search — the Mace4-style partner of free_satisfies, in the
390
+ # style of semantics/modelfinder.py (read it first: _Signature, _candidate_count,
391
+ # _interpretations, find_model / find_countermodel / is_valid_finite).
392
+ #
393
+ # Symmetry breaking (roadmap C23, ``symmetry_breaking=True`` below, the default):
394
+ # hand-ports modelfinder's LNH generator to this module's PARTIAL constant tables
395
+ # — see ``_canonical_constant_assignments`` and ``_canonical_candidate_models``,
396
+ # and ``modelfinder._canonical_interpretations``'s docstring for the full
397
+ # soundness argument (and the hand-checked counterexample showing why functions
398
+ # are deliberately NOT also LNH-reduced here, for the identical reason).
399
+ # ---------------------------------------------------------------------------
400
+
401
+ #: A partial function's table has, per argument tuple, (n+1) choices (one of the n
402
+ #: domain elements, or "undefined"/non-denoting — see _term_value) and n**arity
403
+ #: argument tuples, so the table space is (n+1)**(n**arity): already ~4·10^7 at
404
+ #: n=3, arity=3. Rather than let max_candidates silently starve every domain size
405
+ #: for a function this shape (see _search's "every size skipped" check), functions
406
+ #: above this arity are rejected outright, up front, with a clean message.
407
+ MAX_FUNCTION_ARITY = 2
408
+
409
+ #: Interpretations visited per domain size before that size is skipped (mirrors
410
+ #: modelfinder.MAX_CANDIDATES). Free logic multiplies further by the number of
411
+ #: existing/outer splits tried (2**k under domain_split="any", 1 under "total"), so
412
+ #: a signature that modelfinder would happily search at some k can cost more here.
413
+ MAX_CANDIDATES = 1 << 20
414
+
415
+
416
+ def _free_var_names(node: Node, bound: FrozenSet[str] = frozenset()) -> set:
417
+ """Return the names of variables occurring free in ``node`` (Quantifier binds).
418
+
419
+ Mirrors modelfinder._free_var_names, restricted to the node types free_satisfies
420
+ supports (no Count/Cardinality/SlashedExists binders exist in this fragment).
421
+ """
422
+ if isinstance(node, Variable):
423
+ return set() if node.name in bound else {node.name}
424
+ if isinstance(node, Quantifier):
425
+ return _free_var_names(node.formula, bound | {node.variable.name})
426
+ names: set = set()
427
+ for child in node._child_nodes():
428
+ names |= _free_var_names(child, bound)
429
+ return names
430
+
431
+
432
+ def _universal_closure(node: Node) -> Node:
433
+ """Wrap ``node`` in ∀ for each free variable (deterministic order).
434
+
435
+ ∀ ranges over ``existing`` under free-logic semantics (see the module
436
+ docstring), so the closure of ONE sentence says it holds under every assignment of
437
+ its free variables to existing objects. It is a tool for a single sentence that is
438
+ evaluated, never the reading of a problem: the search routes read a free variable as
439
+ a parameter shared by every formula of the call (:func:`_with_parameters`).
440
+ """
441
+ result = node
442
+ for name in sorted(_free_var_names(node), reverse=True):
443
+ result = Quantifier("∀", Variable(name), result)
444
+ return result
445
+
446
+
447
+ def _signature(node: Node) -> Tuple[set, set, set]:
448
+ """Return ``(constant names, {(name, arity)} functions, {(name, arity)} predicates)``.
449
+
450
+ Mirrors modelfinder._Signature.scan for the node types free_satisfies supports.
451
+ ``=``, ``≠`` and the existence predicate ``E!`` are handled natively by
452
+ free_satisfies / _atom and are never collected as ordinary predicates to
453
+ interpret (interpreting ``E!`` would be incoherent: it is defined FROM
454
+ ``existing``, not a free table).
455
+ """
456
+ constants: set = set()
457
+ functions: set = set()
458
+ predicates: set = set()
459
+
460
+ def scan(n: Node) -> None:
461
+ if isinstance(n, Constant):
462
+ constants.add(n.name)
463
+ elif isinstance(n, Number):
464
+ constants.add(numeral_key(n.value))
465
+ elif isinstance(n, Function):
466
+ functions.add((n.name, len(n.args)))
467
+ for a in n.args:
468
+ scan(a)
469
+ elif isinstance(n, Atom):
470
+ if n.predicate not in ("=", "≠", _EXISTS_PRED) and _truth_value(n) is None:
471
+ predicates.add((n.predicate, len(n.args)))
472
+ for a in n.args:
473
+ scan(a)
474
+ else:
475
+ for child in n._child_nodes():
476
+ scan(child)
477
+
478
+ scan(node)
479
+ return constants, functions, predicates
480
+
481
+
482
+ def _candidate_count(n_constants: int, functions: set, predicates: set,
483
+ k: int, domain_split: str) -> int:
484
+ """The number of distinct FreeModel candidates of outer-domain size ``k``.
485
+
486
+ Existing/outer splits (``2**k`` under ``"any"`` — every subset, INCLUDING the
487
+ empty set: free logic, unlike classical FOL, does not require an existing
488
+ object, so an empty inner domain is a legitimate model, see
489
+ :func:`_existing_subsets`; exactly ``1`` under ``"total"``) times
490
+ ``(k+1)**n_constants`` partial constant assignments (k denoting choices +
491
+ non-denoting) times, per function of arity a, ``(k+1)**(k**a)`` partial tables,
492
+ times, per predicate of arity a, ``2**(k**a)`` extensional choices over the
493
+ OUTER domain (predicates apply to outer elements whether or not they exist —
494
+ see ``_atom``).
495
+ """
496
+ splits = (1 << k) if domain_split == "any" else 1
497
+ total = splits * ((k + 1) ** n_constants)
498
+ for _, arity in functions:
499
+ total *= (k + 1) ** (k ** arity)
500
+ for _, arity in predicates:
501
+ total *= 1 << (k ** arity)
502
+ return total
503
+
504
+
505
+ def _canonical_constant_count(n_slots: int, k: int) -> int:
506
+ """The EXACT number of LNH-canonical PARTIAL constant assignments
507
+ :func:`_canonical_constant_assignments` yields for ``n_slots`` constant names
508
+ over a ``k``-element domain — computed analytically (``O(n_slots * k)``
509
+ arithmetic, no enumeration), mirroring
510
+ :func:`~unicode_logic_kit.semantics.modelfinder._lnh_constant_count`'s DP but with
511
+ the extra "non-denoting" (``None``) option :func:`_canonical_constant_assignments`
512
+ offers at every cell alongside :func:`~unicode_logic_kit.semantics.modelfinder._lnh_choices`'s
513
+ domain-value range — a cell can go non-denoting, repeat one of the ``j``
514
+ already-used domain values, or (if ``j < k``) introduce the next unused one.
515
+ ``None`` is never subject to the relabeling argument (a term either denotes an
516
+ outer-domain element or it does not, independent of which permutation would
517
+ rename that element were it to denote one — see
518
+ :func:`_canonical_constant_assignments`'s docstring), so it is simply one more
519
+ option per cell, orthogonal to ``j``. Hand-checked for n_slots=1,k=1 -> 2 in
520
+ ``tests/test_modelfinder_symmetry.py``.
521
+ """
522
+ if n_slots == 0:
523
+ return 1
524
+ dp = [1] * (k + 1)
525
+ for _ in range(n_slots):
526
+ new_dp = [0] * (k + 1)
527
+ for j in range(k + 1):
528
+ total = (1 + j) * dp[j] # non-denoting, or repeat a used value
529
+ if j < k:
530
+ total += dp[j + 1] # or introduce the next still-unused one
531
+ new_dp[j] = total
532
+ dp = new_dp
533
+ return dp[0]
534
+
535
+
536
+ def _canonical_candidate_count(n_constants: int, functions: set, predicates: set,
537
+ k: int, domain_split: str) -> int:
538
+ """The EXACT number of FreeModel candidates :func:`_canonical_candidate_models`
539
+ yields for this signature over an outer domain of size ``k`` — the analytic
540
+ pre-flight count backing :func:`_search`'s "skip this size" check when
541
+ ``symmetry_breaking=True``, playing the same role :func:`_candidate_count` plays
542
+ for the unbroken enumeration (see
543
+ :func:`~unicode_logic_kit.semantics.modelfinder._canonical_candidate_count` for the
544
+ classical-FOL analogue). Only the constants factor changes, via
545
+ :func:`_canonical_constant_count`; functions and predicates keep the exact same
546
+ raw factors :func:`_candidate_count` uses, because
547
+ :func:`_canonical_candidate_models` leaves those fully exhaustive. Being exact
548
+ means this alone decides whether a size is searched — a signature with few or no
549
+ constants (where LNH cannot reduce anything) is skipped exactly as cheaply as it
550
+ was before symmetry breaking existed, instead of live-enumerating candidates and
551
+ calling :func:`free_satisfies` on each up to ``max_candidates`` before finding
552
+ that out.
553
+ """
554
+ splits = (1 << k) if domain_split == "any" else 1
555
+ total = splits * _canonical_constant_count(n_constants, k)
556
+ for _, arity in functions:
557
+ total *= (k + 1) ** (k ** arity)
558
+ for _, arity in predicates:
559
+ total *= 1 << (k ** arity)
560
+ return total
561
+
562
+
563
+ def _existing_subsets(domain: Tuple[Any, ...], domain_split: str):
564
+ """Yield every candidate ``existing`` set for ``domain`` under ``domain_split``.
565
+
566
+ ``"any"`` — every subset of the outer domain, INCLUDING the empty set (an
567
+ empty inner domain is a legitimate free-logic model — ∀ vacuously true, ∃
568
+ always false there, per free_satisfies). ``"total"`` — only
569
+ ``existing == outer`` (everything denotable exists), the classical-FOL-
570
+ equivalent reading; with no constants/functions in the formula this makes the
571
+ search coincide with :mod:`~unicode_logic_kit.semantics.modelfinder` exactly
572
+ (see the differential test in tests/test_free_logic_search.py).
573
+ """
574
+ if domain_split == "total":
575
+ yield frozenset(domain)
576
+ return
577
+ if domain_split != "any":
578
+ raise ValueError(
579
+ f"free_logic model search: unknown domain_split {domain_split!r} "
580
+ '("any" / "total").')
581
+ n = len(domain)
582
+ for mask in range(1 << n):
583
+ yield frozenset(domain[i] for i in range(n) if (mask >> i) & 1)
584
+
585
+
586
+ def _constant_assignments(domain: Tuple[Any, ...], const_names: List[str]):
587
+ """Yield every partial constant assignment: each name to a domain element, or omitted."""
588
+ options = list(domain) + [None] # None = "omit" = non-denoting
589
+ for choice in product(options, repeat=len(const_names)):
590
+ yield {name: val for name, val in zip(const_names, choice) if val is not None}
591
+
592
+
593
+ def _function_interpretations(domain: Tuple[Any, ...], func_sig: List[Tuple[str, int]]):
594
+ """Yield every partial function-table dict for ``func_sig`` over ``domain``.
595
+
596
+ Each argument tuple maps to a domain element, or is omitted (the application is
597
+ non-denoting on that tuple — see ``_term_value``).
598
+ """
599
+ if not func_sig:
600
+ yield {}
601
+ return
602
+ per_function = []
603
+ for _, arity in func_sig:
604
+ arg_tuples = list(product(domain, repeat=arity))
605
+ value_options = list(domain) + [None]
606
+ per_function.append(list(product(value_options, repeat=len(arg_tuples))))
607
+ for combo in product(*per_function):
608
+ functions = {}
609
+ for (name, arity), values in zip(func_sig, combo):
610
+ arg_tuples = list(product(domain, repeat=arity))
611
+ functions[(name, arity)] = {
612
+ at: v for at, v in zip(arg_tuples, values) if v is not None
613
+ }
614
+ yield functions
615
+
616
+
617
+ def _predicate_interpretations(domain: Tuple[Any, ...], pred_sig: List[Tuple[str, int]]):
618
+ """Yield every predicate-table dict for ``pred_sig``: a subset of outer**arity each."""
619
+ if not pred_sig:
620
+ yield {}
621
+ return
622
+ per_predicate = []
623
+ for _, arity in pred_sig:
624
+ arg_tuples = list(product(domain, repeat=arity))
625
+ per_predicate.append(list(product((False, True), repeat=len(arg_tuples))))
626
+ for combo in product(*per_predicate):
627
+ predicates = {}
628
+ for (name, arity), mask in zip(pred_sig, combo):
629
+ arg_tuples = list(product(domain, repeat=arity))
630
+ predicates[(name, arity)] = frozenset(t for t, inc in zip(arg_tuples, mask) if inc)
631
+ yield predicates
632
+
633
+
634
+ def _candidate_models(domain, const_names, func_sig, pred_sig, domain_split):
635
+ """Yield every FreeModel candidate over ``domain`` for the given signature."""
636
+ for existing in _existing_subsets(domain, domain_split):
637
+ for constants in _constant_assignments(domain, const_names):
638
+ for functions in _function_interpretations(domain, func_sig):
639
+ for predicates in _predicate_interpretations(domain, pred_sig):
640
+ yield FreeModel(outer=domain, existing=existing, constants=constants,
641
+ functions=functions, predicates=predicates)
642
+
643
+
644
+ def _canonical_constant_assignments(domain: Tuple[Any, ...], const_names: List[str]):
645
+ """Yield every partial constant assignment, LNH-canonical (roadmap C23).
646
+
647
+ The free-logic analogue of ``modelfinder._canonical_interpretations``,
648
+ restricted — like it — to CONSTANTS only; see that function's docstring for
649
+ the hand-checked counterexample showing why a flat per-cell LNH cap is sound
650
+ for an argument-free cell (a constant) but NOT for a function's table (its
651
+ cells are indexed by argument tuples that are themselves domain elements,
652
+ subject to the same relabeling as the stored value).
653
+
654
+ Each cell is offered, in this order: "non-denoting" first (never subject to
655
+ the relabeling argument at all — a term either denotes some outer-domain
656
+ element or it does not, independent of *which* permutation would rename that
657
+ element were it to denote one), then :func:`~unicode_logic_kit.semantics.modelfinder._lnh_choices`'s
658
+ domain-value range: an already-used domain value, or the smallest still-unused
659
+ one. Functions, predicates and the existing/outer split are exhaustively
660
+ enumerated around this generator by the caller
661
+ (:func:`_canonical_candidate_models`), exactly as
662
+ ``modelfinder._canonical_interpretations`` leaves functions and predicates
663
+ untouched — see its docstring for why that keeps the search complete.
664
+ """
665
+ k = len(domain)
666
+ n = len(const_names)
667
+
668
+ def backtrack(i: int, next_new: int, values: list):
669
+ if i == n:
670
+ yield {name: domain[v] for name, v in zip(const_names, values) if v is not None}
671
+ return
672
+ for v in (None,) + tuple(_lnh_choices(next_new, k)):
673
+ values.append(v)
674
+ new_next = next_new + 1 if (v is not None and v == next_new) else next_new
675
+ yield from backtrack(i + 1, new_next, values)
676
+ values.pop()
677
+
678
+ yield from backtrack(0, 0, [])
679
+
680
+
681
+ def _canonical_candidate_models(domain, const_names, func_sig, pred_sig, domain_split):
682
+ """Yield every FreeModel candidate with LNH-canonical constants (roadmap C23).
683
+
684
+ Mirrors :func:`_candidate_models`, but the constant assignment comes from
685
+ :func:`_canonical_constant_assignments` instead of the exhaustive
686
+ :func:`_constant_assignments`; functions, predicates and the existing/outer
687
+ split stay fully exhaustive (see that function's docstring, and
688
+ ``modelfinder._canonical_interpretations``'s, for why that keeps the search
689
+ sound and complete).
690
+ """
691
+ for existing in _existing_subsets(domain, domain_split):
692
+ for constants in _canonical_constant_assignments(domain, const_names):
693
+ for functions in _function_interpretations(domain, func_sig):
694
+ for predicates in _predicate_interpretations(domain, pred_sig):
695
+ yield FreeModel(outer=domain, existing=existing, constants=constants,
696
+ functions=functions, predicates=predicates)
697
+
698
+
699
+ def _with_parameters(formulas: Sequence[Node]) -> Tuple[List[Node], List[Node]]:
700
+ """Read every free variable of ``formulas`` as a PARAMETER: ``(formulas, guards)``.
701
+
702
+ The variable is replaced, in all the formulas together, by a constant of its own name
703
+ (:func:`~unicode_logic_kit.fol._free_parameters.parameterize`). A variable of free logic
704
+ ranges over the EXISTING objects, so each parameter comes with the guard ``E!(c)``
705
+ that a model has to satisfy too: ``c`` denotes and is in ``existing``. A model with
706
+ no existing object therefore satisfies no problem that has a free variable.
707
+
708
+ Raises:
709
+ NotImplementedError: a free variable has the spelling of a constant of ``formulas``.
710
+ """
711
+ closed, parameters = parameterize(list(formulas), after_variables=True)
712
+ return closed, [Atom(_EXISTS_PRED, (constant,)) for constant in parameters.values()]
713
+
714
+
715
+ def _search(formulas: Sequence[Node], max_size: int, policy: str, domain_split: str,
716
+ max_candidates: int, symmetry_breaking: bool = True) -> Optional[FreeModel]:
717
+ """Return the first FreeModel making every one of ``formulas`` true, or None.
718
+
719
+ ``formulas`` must not have a free variable: the callers read each one as a parameter
720
+ first (:func:`_with_parameters`), before any formula is negated. The signature
721
+ (constants/functions/predicates) is collected from the formulas jointly, and every
722
+ domain size ``1..max_size`` is tried in turn.
723
+
724
+ ``symmetry_breaking`` (default True, roadmap C23) enumerates constants with
725
+ :func:`_canonical_candidate_models` (LNH — see its docstring) instead of the
726
+ plain :func:`_candidate_models`. Because the LNH-reduced candidate count for a
727
+ size can be far below the analytic ``_candidate_count`` estimate, this path
728
+ uses :func:`_canonical_candidate_count` for the pre-flight "skip this size"
729
+ check instead — the EXACT count of what the canonical generator will yield,
730
+ computed analytically (``O(n_constants * domain size)`` arithmetic, no
731
+ enumeration, no ``free_satisfies`` calls), mirroring
732
+ ``modelfinder.find_model``'s own analytic pre-flight. This is deliberately an
733
+ O(1)-per-size check, not a live count of the generator: a signature with few or
734
+ no constants (where LNH cannot reduce anything — functions and predicates stay
735
+ fully exhaustive either way) is skipped exactly as cheaply as it was before
736
+ symmetry breaking existed, instead of paying to enumerate and call
737
+ ``free_satisfies`` on up to ``max_candidates`` models on every size just to
738
+ discover that a function- or predicate-dominated signature was never going to
739
+ fit the budget. A size counts as genuinely searched (``tried_any_size = True``
740
+ below) only if its exact canonical count is ``<= max_candidates`` (so it is
741
+ always FULLY enumerated, never truncated mid-generator), so the honesty
742
+ contract below is unchanged: if every size still ends up skipped, nothing was
743
+ actually searched, and a bare ``None`` would misleadingly look like a completed
744
+ bounded search — this raises instead (see the arity/message in the
745
+ ValueError). With ``symmetry_breaking=False`` the search is byte-for-byte the
746
+ original exhaustive one (the analytic pre-flight check, then
747
+ :func:`_candidate_models`).
748
+ """
749
+ if policy not in ("negative", "positive", "supervaluation"):
750
+ raise ValueError(
751
+ f"free_logic model search: unknown policy {policy!r} "
752
+ "(negative / positive / supervaluation).")
753
+ closed = list(formulas)
754
+ _refuse_numeral_constant_pair(closed, "semantics.free_logic")
755
+
756
+ constants: set = set()
757
+ functions: set = set()
758
+ predicates: set = set()
759
+ for f in closed:
760
+ c, fn, p = _signature(f)
761
+ constants |= c
762
+ functions |= fn
763
+ predicates |= p
764
+
765
+ for name, arity in functions:
766
+ if arity > MAX_FUNCTION_ARITY:
767
+ raise ValueError(
768
+ f"free_logic model search: function {name}/{arity} exceeds "
769
+ f"MAX_FUNCTION_ARITY = {MAX_FUNCTION_ARITY}. A partial function table over "
770
+ f"an n-element domain has (n+1)**(n**arity) entries — arity {arity} blows up "
771
+ "too fast to enumerate at any useful domain size. Reformulate with "
772
+ "lower-arity functions/predicates, or curry the function.")
773
+
774
+ const_names = sorted(constants)
775
+ func_sig = sorted(functions)
776
+ pred_sig = sorted(predicates)
777
+
778
+ tried_any_size = False
779
+ for k in range(1, max_size + 1):
780
+ domain = tuple(range(k))
781
+ if symmetry_breaking:
782
+ if _canonical_candidate_count(len(const_names), functions, predicates,
783
+ k, domain_split) > max_candidates:
784
+ continue
785
+ tried_any_size = True
786
+ for model in _canonical_candidate_models(domain, const_names, func_sig,
787
+ pred_sig, domain_split):
788
+ if all(_satisfies(f, model, {}, policy) for f in closed):
789
+ return model
790
+ continue
791
+ if _candidate_count(len(const_names), functions, predicates, k, domain_split) > max_candidates:
792
+ continue
793
+ tried_any_size = True
794
+ for model in _candidate_models(domain, const_names, func_sig, pred_sig, domain_split):
795
+ if all(_satisfies(f, model, {}, policy) for f in closed):
796
+ return model
797
+ if not tried_any_size:
798
+ raise ValueError(
799
+ f"free_logic model search: every domain size 1..{max_size} exceeds "
800
+ f"max_candidates = {max_candidates} for this signature "
801
+ f"({len(const_names)} constant(s), {len(func_sig)} function(s), "
802
+ f"{len(pred_sig)} predicate(s)). Raise max_candidates, lower max_size, or "
803
+ "simplify the formula.")
804
+ return None
805
+
806
+
807
+ def free_find_model(formula: Node, max_size: int = 3, *, policy: str = "negative",
808
+ domain_split: str = "any",
809
+ max_candidates: int = MAX_CANDIDATES,
810
+ symmetry_breaking: bool = True) -> Optional[FreeModel]:
811
+ """Return a FreeModel satisfying ``formula``, or None.
812
+
813
+ Mace4-style search (see
814
+ :func:`~unicode_logic_kit.semantics.modelfinder.find_model`) over outer domain
815
+ sizes ``1..max_size``: for each size, every existing/outer split (``domain_split``
816
+ — ``"any"`` tries every subset including empty ``existing``; ``"total"`` forces
817
+ ``existing == outer``), every partial constant/function assignment (a symbol may
818
+ be non-denoting), and every predicate extension over the OUTER domain (predicates
819
+ apply regardless of existence — see ``_atom``). ``formula``'s free variables are
820
+ PARAMETERS (see the module docstring): each is one existing object, reported in
821
+ ``constants`` under the variable's name, so ``P(x) ∧ ¬P(y)`` has a model and a model
822
+ with no existing object is none for a formula with a free variable. A domain size
823
+ whose candidate count exceeds ``max_candidates`` is skipped; a function whose
824
+ arity exceeds :data:`MAX_FUNCTION_ARITY` is rejected outright (see :func:`_search`).
825
+ ``None`` means "none found within the bounds", not "unsatisfiable" — free FOL is
826
+ exactly as undecidable as classical FOL.
827
+
828
+ ``symmetry_breaking`` (default True, roadmap C23) enumerates CONSTANT
829
+ assignments with the LNH generator instead of exhaustively — see
830
+ :func:`_search`'s docstring for the live-counted budget it uses instead of the
831
+ plain pre-flight check, and
832
+ :func:`~unicode_logic_kit.semantics.modelfinder._canonical_interpretations`'s
833
+ docstring for why this stays sound and complete, and why FUNCTIONS are
834
+ deliberately not also LNH-reduced.
835
+
836
+ Raises:
837
+ NotImplementedError: a free variable has the spelling of a constant of ``formula``.
838
+ """
839
+ closed, guards = _with_parameters([formula])
840
+ return _search(closed + guards, max_size, policy, domain_split, max_candidates,
841
+ symmetry_breaking)
842
+
843
+
844
+ def free_countermodel(formula: Node, max_size: int = 3, *, policy: str = "negative",
845
+ domain_split: str = "any",
846
+ max_candidates: int = MAX_CANDIDATES,
847
+ symmetry_breaking: bool = True) -> Optional[FreeModel]:
848
+ """Return a FreeModel where ``formula`` is FALSE (a witness against its validity), or None.
849
+
850
+ Searches for a model of ``¬formula`` exactly like :func:`free_find_model`
851
+ (including its ``symmetry_breaking``); the search's success check
852
+ (``free_satisfies`` of the negation) already verifies the returned model, so a
853
+ non-None result is a definitive refutation of validity — the same
854
+ verify-before-return discipline as
855
+ :func:`~unicode_logic_kit.semantics.relevant.rel_countermodel` /
856
+ :func:`~unicode_logic_kit.semantics.conditional.cf_countermodel`.
857
+
858
+ A free variable is a parameter (see the module docstring) that is read BEFORE the
859
+ formula is negated: the countermodel falsifies ``formula`` under one assignment of
860
+ existing objects, reported in ``constants`` under the variables' names. (Negating
861
+ first would close ``¬formula`` as ``∀x ¬formula``, which an empty ``existing``
862
+ domain satisfies for every formula.)
863
+
864
+ Raises:
865
+ NotImplementedError: a free variable has the spelling of a constant of ``formula``.
866
+ """
867
+ closed, guards = _with_parameters([formula])
868
+ return _search([Not(closed[0])] + guards, max_size, policy, domain_split, max_candidates,
869
+ symmetry_breaking)
870
+
871
+
872
+ def free_is_valid(formula: Node, max_size: int = 3, *, policy: str = "negative",
873
+ domain_split: str = "any",
874
+ max_candidates: int = MAX_CANDIDATES,
875
+ symmetry_breaking: bool = True) -> bool:
876
+ """Return True iff no free-logic countermodel to ``formula`` is found within the bound.
877
+
878
+ HONEST CONTRACT (mirroring
879
+ :func:`~unicode_logic_kit.semantics.relevant.rel_valid` /
880
+ :func:`~unicode_logic_kit.semantics.conditional.cf_valid`): ``False`` is
881
+ *definitive* — it is backed by an explicit, :func:`free_satisfies`-verified
882
+ countermodel from :func:`free_countermodel`, so ``formula`` is certainly not
883
+ valid under this free-logic semantics (for this ``policy``). ``True`` means only
884
+ "no countermodel with outer domain size ≤ ``max_size``" — free FOL is as
885
+ undecidable as classical FOL, so this is bounded evidence, not a proof; raising
886
+ ``max_size`` never turns a ``False`` into a ``True``, only ever the reverse.
887
+ """
888
+ return free_countermodel(formula, max_size, policy=policy, domain_split=domain_split,
889
+ max_candidates=max_candidates,
890
+ symmetry_breaking=symmetry_breaking) is None
891
+
892
+
893
+ def free_entails(premises: Sequence[Node], conclusion: Node, max_size: int = 3, *,
894
+ policy: str = "negative", domain_split: str = "any",
895
+ max_candidates: int = MAX_CANDIDATES,
896
+ symmetry_breaking: bool = True) -> bool:
897
+ """Return True iff no bounded FreeModel satisfies every premise but not ``conclusion``.
898
+
899
+ Same honest contract as :func:`free_is_valid`: a ``False`` is backed by a
900
+ verified countermodel (every premise true there, ``conclusion`` false there, under
901
+ one assignment); ``True`` means only "none found within the bounds".
902
+ A free variable is a PARAMETER shared by every premise and the conclusion
903
+ (see the module docstring), as in
904
+ :func:`~unicode_logic_kit.semantics.modelfinder.find_countermodel`: ``P(x) ⊢ P(x)`` and
905
+ ``P(x) ⊢ ∃y P(y)`` are valid, ``P(x) ⊢ P(alpha)`` is not, and a premise is never
906
+ closed universally. The variables are read before the conclusion is negated.
907
+
908
+ Raises:
909
+ NotImplementedError: a free variable has the spelling of a constant of the formulas.
910
+ """
911
+ closed, guards = _with_parameters(list(premises) + [conclusion])
912
+ return _search(closed[:-1] + [Not(closed[-1])] + guards, max_size, policy, domain_split,
913
+ max_candidates, symmetry_breaking) is None