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,1290 @@
1
+ """A first-class, explicit ``Signature`` object: the kit's declared vocabulary.
2
+
3
+ A signature names what a formula is allowed to talk about — which predicate
4
+ symbols exist and at what arity, which functions, which constants, and (for
5
+ many-sorted formulas) which sorts and which argument/result positions are
6
+ typed. Today that vocabulary is checked in at least three independent, ad hoc
7
+ places:
8
+
9
+ * ``unicode_logic_kit.api.check(formula, signature={...})`` takes a LOOSE DICT
10
+ (see "The api.check loose dict convention" below) and reports unknown
11
+ symbols / arity mismatches with did-you-mean suggestions.
12
+ * ``unicode_logic_kit.eval.predicate_match`` extracts a per-namespace symbol
13
+ INVENTORY (``(name, arity)`` sets for predicates/functions, a name set for
14
+ constants) purely to realign two formulas' vocabularies against each other.
15
+ * ``unicode_logic_kit.fol.casl_export`` INFERS a signature (arities, and a
16
+ per-argument-position sort via union-find) from a batch of formulas, purely
17
+ as an internal step of emitting a CASL ``spec`` block.
18
+
19
+ Each of the three is correct for its own narrow purpose, but none of them is a
20
+ reusable, inspectable, first-class object — you cannot ask any of them "here
21
+ is a signature, does this OTHER formula conform to it", or serialise one to
22
+ disk, or merge two signatures from different sources. :class:`Signature` is
23
+ that missing object: predicates, functions, constants, and sorts as one
24
+ explicit, immutable value, with two ways to build one (from a dict, or by
25
+ inferring it from a batch of ASTs), a way to check an arbitrary formula
26
+ against it (:meth:`Signature.validate`), a way to combine two of them
27
+ (:meth:`Signature.merge`), and a stable JSON-compatible round-trip
28
+ (:meth:`Signature.to_dict` / :meth:`Signature.from_dict`).
29
+
30
+ DESIGN NOTE — the shared carrier, partially wired
31
+ ---------------------------------------------------------
32
+ ``api.check(formula, signature=Signature(...))`` IS wired: the facade
33
+ projects a :class:`Signature` onto its loose dict convention for the
34
+ classic unknown-symbol/arity diagnostics (keeping their did-you-mean
35
+ suggestions) and ADDITIONALLY reports this module's sort-mismatch messages
36
+ (``kind="sort_mismatch"`` entries) — see ``api.check``.
37
+
38
+ ``predicate_match`` and ``casl_export`` keep their own independently-tested
39
+ walks — a full rewrite onto :meth:`from_formulas` remains out of scope,
40
+ because each has a genuine, load-bearing reason to differ (see below) — but
41
+ they no longer duplicate this module's low-level bookkeeping:
42
+
43
+ * :func:`inventory_of` is the LENIENT counterpart of :meth:`from_formulas`'s
44
+ own classification walk, factored out so
45
+ ``unicode_logic_kit.eval.predicate_match``'s ``_symbol_inventory`` (which
46
+ must never raise — it scores possibly-malformed model output, see that
47
+ module's docstring) can share it instead of maintaining a second,
48
+ independently-written ``node.walk()`` classification pass. Where
49
+ :meth:`from_formulas` REFUSES a vocabulary conflict, :func:`inventory_of`
50
+ keeps every conflicting entry side by side — see its own docstring.
51
+ * the ARITY-conflict and constant/function name-clash REFUSAL itself (the
52
+ actual comparison-and-raise, as opposed to how each caller accumulates
53
+ the values being compared) is factored into the module-private
54
+ :func:`_check_single_valued` / :func:`_check_not_dual_use`, called by
55
+ both :meth:`from_formulas` and
56
+ :mod:`unicode_logic_kit.fol.casl_export`'s ``_analyze``. Each caller keeps
57
+ its OWN accumulation strategy and OWN message wording:
58
+ :meth:`from_formulas` collects the full set of arities/sorts seen for a
59
+ symbol across the whole batch before checking it once (so a three-way
60
+ conflict names every arity involved); ``casl_export._analyze`` checks
61
+ incrementally, per occurrence, against a running single recorded value
62
+ (so an arity conflict is reported — and the whole batch's sort-inference
63
+ union-find work is short-circuited — as soon as it is first seen, before
64
+ any later, unrelated formula in the batch is even walked). These are two
65
+ different, independently-justified accumulation policies (see
66
+ ``casl_export``'s own module docstring), not two implementations of one
67
+ policy — only the underlying "more than one distinct value is a
68
+ conflict" / "this name is claimed in the other namespace" predicates are
69
+ shared.
70
+ * ``casl_export``'s per-argument-position sort inference (the
71
+ ``_UnionFind`` / ``_Signature`` union-find, and its ``default_sort``
72
+ total-resolution policy) stays exactly where it is, unshared — see the
73
+ "Signature.from_formulas — scope" section above for why.
74
+
75
+ The module stays self-contained and importable on its own
76
+ (``from unicode_logic_kit.fol.signature import Signature``).
77
+
78
+ The api.check loose dict convention (verified against unicode_logic_kit/api.py)
79
+ -------------------------------------------------------------------------------
80
+ ``api.check``'s ``_signature_errors`` (api.py) reads a plain dict with up to
81
+ three OPTIONAL top-level keys — a key that is absent means that namespace is
82
+ UNCONSTRAINED (nothing is flagged unknown in it), not empty:
83
+
84
+ {
85
+ "predicates": {"Human": 1, "Mortal": 1}, # name -> arity ...
86
+ "functions": {"double": 1}, # ... or an iterable of
87
+ "constants": ["a", "b"], # allowed arities
88
+ }
89
+
90
+ ``predicates`` / ``functions`` map a name to EITHER a single ``int`` arity or
91
+ an iterable of allowed arities (``_signature_errors``'s ``allowed_arities``
92
+ helper does ``{value}`` for an int, ``set(value)`` otherwise); ``constants``
93
+ is a plain iterable of bare names. This is confirmed against
94
+ ``tests/test_api.py`` (``test_check_signature_wrong_arity_and_suggestion``,
95
+ ``test_check_passes_a_clean_sentence``), which construct exactly
96
+ ``{"predicates": {"Human": 1, "Mortal": 1}, "constants": ["a", "b"]}``.
97
+ :meth:`Signature.from_dict` accepts this convention 1:1, EXCEPT for the
98
+ multi-arity-iterable case: ``api.check``'s dict describes a permissive
99
+ CONSTRAINT ("P may be used at arity 1 or 2"), whereas a ``Signature``
100
+ DECLARES what a symbol IS — it has no notion of "one of several arities". A
101
+ single-element iterable (``{"P": [1]}`` or ``{"P": {1}}``) is accepted as
102
+ just arity 1 (no information lost); an iterable naming more than one distinct
103
+ arity raises :class:`ValueError` naming the symbol and the arities, per the
104
+ kit's loud-refusal convention (see ``unicode_logic_kit.fol.casl_export``'s
105
+ arity-conflict checks for the same idea applied elsewhere).
106
+
107
+ The richer explicit dict form (what :meth:`Signature.to_dict` emits)
108
+ ------------------------------------------------------------------------
109
+ :meth:`Signature.to_dict` always emits full-fidelity, self-describing JSON
110
+ (``json.dumps`` works directly on it) with a STABLE key order (sections in
111
+ declaration order, entries within a section sorted alphabetically by name —
112
+ independent of the ``Mapping``'s own internal order, so two signatures with
113
+ the same content always serialise byte-identically):
114
+
115
+ {
116
+ "predicates": {
117
+ "Loves": {"arity": 2, "arg_sorts": ["Human", "Human"]},
118
+ "Rain": {"arity": 0, "arg_sorts": None},
119
+ },
120
+ "functions": {
121
+ "fatherOf": {"arity": 1, "arg_sorts": ["Human"],
122
+ "result_sort": "Human"},
123
+ },
124
+ "constants": {"alice": "Human", "unsorted_thing": None},
125
+ "sorts": ["Human"],
126
+ "subsorts": {"Human": ["Animal"]},
127
+ }
128
+
129
+ ``"subsorts"`` (present only when at least one edge is declared) is the
130
+ CHILD-sort-to-DIRECT-parent-sorts mapping, JSON-rendered as name -> a
131
+ sorted list of parent names (never the transitive closure — see the
132
+ "Subsorting" section below for why only direct edges are the canonical
133
+ form). :meth:`Signature.from_dict` reads it back the same shape, and every
134
+ sort it mentions (child or parent) is unioned into :attr:`Signature.sorts`
135
+ exactly like an ``arg_sorts``/``result_sort``/constant ``sort`` mention.
136
+
137
+ :meth:`Signature.from_dict` recognises this rich per-entry shape (a ``dict``
138
+ with the required key ``"arity"`` for predicates/functions, an optional
139
+ ``"arg_sorts"`` list, and — functions only — an optional ``"result_sort"``;
140
+ an unrecognised key inside such an entry, or a ``"result_sort"`` on a
141
+ PREDICATE entry, is refused with :class:`ValueError`) alongside the loose
142
+ convention above, distinguishing the two purely by the shape of each value
143
+ (``int``/iterable → loose; ``dict`` → rich). ``constants`` is rich when the
144
+ section itself is a ``dict`` (name -> sort-or-``None``) and loose when it is
145
+ a bare iterable of names (sort defaults to ``None`` for all of them). The
146
+ top-level ``"sorts"`` key (loose convention has none) supplies sort names not
147
+ otherwise implied by any declaration — e.g. a sort with no symbol
148
+ referencing it yet. Regardless of what ``"sorts"`` says, :attr:`Signature.sorts`
149
+ always also contains every sort mentioned by any ``arg_sorts`` / ``result_sort``
150
+ / constant ``sort`` / ``subsorts`` in the same dict — the two are unioned,
151
+ never one silently overriding the other. An unrecognised TOP-LEVEL key (a
152
+ typo like ``"predicate"`` for ``"predicates"``) is refused rather than
153
+ silently ignored.
154
+
155
+ ``Signature`` constructed directly (the dataclass constructor, not
156
+ ``from_dict``/``from_formulas``) does NOT perform this implied-sorts
157
+ derivation — ``sorts`` is then exactly what was passed in, verbatim
158
+ (frozenset-coerced). This keeps the plain constructor simple/predictable; the
159
+ two smart constructors below are where the derivation happens.
160
+
161
+ Signature.from_formulas — scope and what it deliberately does NOT infer
162
+ -----------------------------------------------------------------------
163
+ :meth:`Signature.from_formulas` walks a batch of ASTs and infers:
164
+
165
+ * every predicate's and every function's arity from how it is actually
166
+ applied (an arity conflict ACROSS the batch — the same symbol used with two
167
+ different argument counts somewhere in the batch — is refused with
168
+ :class:`ValueError` naming the symbol and every arity seen);
169
+ * every constant's sort from its :class:`~unicode_logic_kit.fol.nodes.SortedConstant`
170
+ occurrences (a plain, unsorted :class:`~unicode_logic_kit.fol.nodes.Constant`
171
+ occurrence of the same name contributes no sort; a genuine conflict — the
172
+ SAME constant name annotated with two different concrete sorts somewhere in
173
+ the batch — is refused with :class:`ValueError`, the same spirit as the
174
+ arity conflict above);
175
+ * the top-level :attr:`Signature.sorts` set, from every
176
+ :class:`~unicode_logic_kit.fol.nodes.SortedQuantifier`'s and
177
+ :class:`~unicode_logic_kit.fol.nodes.SortedConstant`'s own sort name (a plain,
178
+ unsorted :class:`~unicode_logic_kit.fol.nodes.Quantifier` contributes nothing
179
+ here — there is no ``default_sort`` concept in this module, unlike
180
+ ``casl_export``);
181
+ * a name used BOTH as a bare constant (:class:`Constant` or
182
+ :class:`SortedConstant`) and as a function application
183
+ (:class:`~unicode_logic_kit.fol.nodes.Function`) anywhere in the batch is
184
+ refused with :class:`ValueError` — mirroring
185
+ ``unicode_logic_kit.fol.casl_export``'s identical refusal, for the identical
186
+ reason: a signature declares at most one meaning per name per namespace,
187
+ and "sometimes a ground term, sometimes an applied function" cannot be
188
+ reconciled into one declaration.
189
+
190
+ It deliberately does **not** attempt ``casl_export``'s full per-ARGUMENT-POSITION
191
+ sort inference (the union-find over ``("pred"|"func", name, i)`` slots
192
+ propagating sort annotations through shared variables and equalities) — every
193
+ :class:`~unicode_logic_kit.fol.signature.PredicateDecl` / :class:`FunctionDecl`
194
+ built by ``from_formulas`` has ``arg_sorts=None`` (unsorted argument
195
+ positions), even when the formulas passed in would, under that fuller
196
+ algorithm, pin down concrete argument sorts. Duplicating that union-find here
197
+ would either diverge from ``casl_export``'s (already correct, already tested)
198
+ implementation or need to import its private internals; the task this module
199
+ was built for scopes ``from_formulas`` to arity + constant-sort + sort-name
200
+ inference only, leaving richer per-argument-position inference as a possible
201
+ FUTURE addition once/if a shared implementation is worth extracting. A
202
+ :class:`Signature` with unsorted argument positions is still fully usable —
203
+ ``validate()`` simply never reports a sort mismatch on a position neither
204
+ side has pinned down (see below).
205
+
206
+ Signature.validate — violation vocabulary
207
+ ------------------------------------------
208
+ :meth:`Signature.validate` walks a formula (recognising
209
+ :class:`~unicode_logic_kit.fol.nodes.Atom`,
210
+ :class:`~unicode_logic_kit.fol.nodes.Function`,
211
+ :class:`~unicode_logic_kit.fol.nodes.Constant`,
212
+ :class:`~unicode_logic_kit.fol.nodes.SortedConstant`,
213
+ :class:`~unicode_logic_kit.fol.nodes.Variable`,
214
+ :class:`~unicode_logic_kit.fol.nodes.Quantifier`, and
215
+ :class:`~unicode_logic_kit.fol.nodes.SortedQuantifier`, plus the binders that
216
+ introduce a variable the way a quantifier does
217
+ (:class:`~unicode_logic_kit.fol.nodes.Count`,
218
+ :class:`~unicode_logic_kit.fol.nodes.SortedCount`,
219
+ :class:`~unicode_logic_kit.fol.nodes.Cardinality` and
220
+ :class:`~unicode_logic_kit.fol.nodes.SortedCardinality`, whose bound variable
221
+ carries the counting sort, or none for the unsorted forms); every OTHER node
222
+ type — modal/temporal operators, second-order quantifiers,
223
+ Lambda/Application, Measure, the linear/Lambek/team-semantic connectives, …—
224
+ is transparently recursed into via the generic
225
+ :meth:`~unicode_logic_kit.fol.nodes.Node._child_nodes` traversal rather than
226
+ rejected, so an :class:`Atom` buried inside e.g. a modal box or a counting
227
+ quantifier is still checked; there is no ``casl_export``-style fragment
228
+ gate here) and returns a list of precise, human-readable violation strings —
229
+ one per OFFENDING OCCURRENCE (an undeclared symbol used twice in the formula
230
+ produces two separate messages, one per use site; this module never
231
+ deduplicates, so the length of the list is a genuine occurrence count, not
232
+ just a distinct-problem count). An empty list means the formula conforms.
233
+ Five violation shapes are produced, all following the pattern
234
+ ``"<kind> '<symbol>' <detail>"``:
235
+
236
+ * ``undeclared predicate 'Foo' (arity 2)`` / ``undeclared function 'bar'
237
+ (arity 1)`` / ``undeclared constant 'alice'`` — the symbol is not a key of
238
+ the corresponding :attr:`Signature.predicates` / :attr:`functions` /
239
+ :attr:`constants` mapping. The six comparison/equality predicates (``=``,
240
+ ``≠``, ``<``, ``>``, ``≤``, ``≥``) and the four arithmetic functions (``+``,
241
+ ``-``, ``*``, ``/``) are the kit's BUILT-IN operators, never user
242
+ vocabulary (mirroring ``unicode_logic_kit.eval.validate``'s identical
243
+ ``_BUILTIN_PREDS`` / ``_BUILTIN_FUNCS`` split — duplicated here as a
244
+ small, independent, literal copy rather than an import, since this module
245
+ is meant to become something ``eval.validate`` itself could eventually
246
+ depend on, and depending the other way around would be backwards) — they
247
+ are never flagged as undeclared, though their OWN arguments are still
248
+ recursed into and checked. The two truth constants are not user vocabulary
249
+ either: the nullary atoms ``$true`` and ``$false`` (and the atoms named
250
+ ``⊤`` and ``⊥``, which are the same constants) are never flagged as
251
+ undeclared predicates, and neither :meth:`Signature.from_formulas` nor
252
+ :func:`inventory_of` lists them.
253
+ * ``predicate 'Human' expects arity 1, used with arity 2`` / the ``function``
254
+ equivalent — the symbol IS declared, but this occurrence's argument count
255
+ does not match :attr:`PredicateDecl.arity` / :attr:`FunctionDecl.arity`.
256
+ * ``predicate 'Loves' argument 1 expects sort 'Human', got sort 'Animal'`` /
257
+ the ``function`` equivalent — argument position ``i`` (1-based in the
258
+ message) has a declared sort (``PredicateDecl.arg_sorts[i]`` /
259
+ ``FunctionDecl.arg_sorts[i]``) that is not ``None``, AND the term actually
260
+ passed there resolves to a DIFFERENT concrete sort (also not ``None``). A
261
+ term's own concrete sort comes from: a :class:`Variable` bound by an
262
+ enclosing :class:`SortedQuantifier` or sorted counting binder (that
263
+ binder's sort; a plain :class:`Quantifier`, an unsorted counting binder or
264
+ a free variable gives ``None`` — unknown, not a conflict); a :class:`SortedConstant`'s own inline sort annotation; a
265
+ :class:`Constant`'s signature-declared :attr:`ConstantDecl.sort` (``None``
266
+ if undeclared, or if declared unsorted); or a :class:`Function`
267
+ application's declared :attr:`FunctionDecl.result_sort`. Per the task's
268
+ own rule — repeated here because it is the crux of the whole check — **a
269
+ ``None`` sort on EITHER side never conflicts**; only two BOTH-concrete,
270
+ DIFFERENT sorts are a violation. This is a local, single-pass check (no
271
+ union-find / propagation across the formula the way ``casl_export``'s sort
272
+ inference does): it only ever compares a declared argument-position sort
273
+ against whatever concrete sort the term passed there ALREADY carries by
274
+ itself (from its own binder or its own signature entry), never propagates
275
+ a sort backward onto an otherwise-unsorted variable.
276
+ * ``constant 'alice' is annotated sort 'Animal' here but declared sort
277
+ 'Human' in the signature`` — a :class:`SortedConstant` occurrence's own
278
+ inline sort disagrees with that same name's :attr:`ConstantDecl.sort` in
279
+ the signature (both concrete, both known, and different — the same "both
280
+ sides concrete" rule as above, applied to a constant instead of an
281
+ argument position).
282
+
283
+ Variables never need declaring (per the task's own framing) — a bare
284
+ :class:`Variable` is only ever looked up in the quantifier-scope environment
285
+ built while walking, never checked against :attr:`Signature.constants`; an
286
+ unbound (free) variable simply resolves to sort ``None`` and is silently
287
+ skipped, exactly like an unbound one under a plain :class:`Quantifier`.
288
+
289
+ Subsorting (``S < T``) — a subset-semantics reading, not full order-sorted algebra
290
+ -----------------------------------------------------------------------------------
291
+ :attr:`Signature.subsorts` declares a CHILD sort's DIRECT parent sorts —
292
+ ``{"Human": frozenset({"Animal"})}`` reads "Human is declared a subsort of
293
+ Animal". The semantics is the plain SUBSET reading MSFOL relativisation
294
+ already gives a single sort's own universe (see
295
+ :mod:`unicode_logic_kit.semantics.modelfinder`'s "many-sorted" section): a
296
+ subsort edge ``S < T`` means every structure's universe for ``S`` is a
297
+ SUBSET of its universe for ``T`` — ``ext(S) ⊆ ext(T)`` — nothing more. This
298
+ is deliberately NOT full CASL order-sorted algebra: there are no injection/
299
+ retract functions, no ``x as S`` / ``x in S`` casts, and no operation/
300
+ predicate overloading across the hierarchy — a symbol still has exactly one
301
+ declared arity/argument-sort profile, subsort or not (see
302
+ :mod:`unicode_logic_kit.fol.casl_import`'s identical scoping decision on the
303
+ CASL side).
304
+
305
+ ``__post_init__`` computes the REFLEXIVE-TRANSITIVE closure of the declared
306
+ DIRECT edges purely to answer :meth:`is_subsort` and to detect a cycle (a
307
+ sort that is, directly or transitively, its own ancestor) — refused with
308
+ :class:`ValueError` naming the cycle path. The closure is a private,
309
+ derived cache (not a dataclass field, so it never participates in equality/
310
+ repr/hashing); :attr:`Signature.subsorts` itself always stays exactly what
311
+ was declared — the DIRECT edges only — which is what
312
+ :func:`unicode_logic_kit.fol.to_fol` needs to emit the minimal axiom set (a
313
+ transitive edge follows from chaining two direct ones' implications, so
314
+ re-emitting it would be redundant, not wrong, but is avoided).
315
+
316
+ :meth:`Signature.validate`'s two "declared vs. actual argument sort"
317
+ violations (the ``predicate``/``function`` "argument ``i`` expects sort"
318
+ messages above) accept a SUBSORT of the declared sort as well as an exact
319
+ match — passing a ``Human``-sorted term where ``Animal`` is declared now
320
+ validates clean once ``Human < Animal`` is declared, but NOT the reverse (an
321
+ ``Animal``-sorted term where ``Human`` is declared is still reported):
322
+ subsort substitutability is one-directional, exactly like Liskov
323
+ substitution. The two constant-sort-annotation checks (a
324
+ :class:`SortedConstant` occurrence's own inline sort vs. its declared
325
+ :attr:`ConstantDecl.sort`) stay EXACT-match — those compare one name's own
326
+ two annotations for self-consistency, not a substitutability question.
327
+
328
+ :meth:`Signature.merge` unions ``subsorts`` the same way it unions ``sorts``
329
+ — per CHILD sort, the two sides' direct-parent sets are unioned (never
330
+ flagged as conflicting merely for differing, since two DIFFERENT declared
331
+ parent sets for the same child are both true constraints, not competing
332
+ claims about a single fact the way two different arities would be). The one
333
+ way a merge can still fail is if the UNION of both sides' edges creates a
334
+ cycle that did not exist in either signature alone — caught by the same
335
+ cycle check :meth:`__post_init__` runs on the merged result, so it is named
336
+ exactly like any other cycle.
337
+
338
+ Equality, immutability, and hashability
339
+ ------------------------------------------
340
+ :class:`PredicateDecl` / :class:`FunctionDecl` / :class:`ConstantDecl` are
341
+ plain frozen dataclasses over only hashable fields (``str`` / ``int`` /
342
+ ``Optional[str]`` / a coerced ``Tuple[Optional[str], ...]``), so they are
343
+ fully immutable AND hashable, and compare equal structurally.
344
+ :class:`Signature` itself is a frozen dataclass too (no attribute can be
345
+ reassigned after construction) whose three symbol-table fields are coerced
346
+ in ``__post_init__`` to :class:`types.MappingProxyType` (a read-only VIEW —
347
+ mutating the dict a caller originally passed in after construction does NOT
348
+ retroactively change the ``Signature``, since a fresh internal ``dict`` is
349
+ built and wrapped) and whose ``sorts`` field is coerced to a genuine
350
+ ``frozenset``. Two ``Signature`` values compare equal iff every predicate,
351
+ function, constant, and sort matches — regardless of which constructor built
352
+ them (``from_dict``, ``from_formulas``, ``merge``, or the plain constructor
353
+ all funnel through the same ``__post_init__`` coercion, so construction path
354
+ never affects equality). ``Signature`` is NOT hashable, however (a
355
+ ``MappingProxyType`` wrapping a ``dict`` is itself unhashable) — "frozen"
356
+ here means immutable-by-construction, not usable as a ``dict``/``set`` key.
357
+ """
358
+
359
+ from dataclasses import dataclass, field, replace
360
+ from types import MappingProxyType
361
+ from typing import Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, Tuple
362
+
363
+ from .nodes import (
364
+ Node, Atom, Function, Constant, SortedConstant, Variable,
365
+ Quantifier, SortedQuantifier, SortedCount, SortedCardinality,
366
+ Count, Cardinality,
367
+ )
368
+ from ._truth_constants import is_truth_constant
369
+
370
+ __all__ = ["Signature", "PredicateDecl", "FunctionDecl", "ConstantDecl", "inventory_of"]
371
+
372
+
373
+ # Comparison/equality predicates and arithmetic functions are the kit's
374
+ # BUILT-IN operators, not user vocabulary that a Signature declares — see the
375
+ # module docstring's "violation vocabulary" section for why this is a small
376
+ # independent literal copy of unicode_logic_kit.eval.validate's identical sets
377
+ # rather than an import.
378
+ _BUILTIN_PREDS = frozenset({"=", "≠", "<", ">", "≤", "≥"})
379
+ _BUILTIN_FUNCS = frozenset({"+", "-", "*", "/"})
380
+
381
+
382
+ # =============================================================================
383
+ # inventory_of — the LENIENT, non-raising counterpart of from_formulas
384
+ # =============================================================================
385
+
386
+ def inventory_of(node: Node):
387
+ """Return the lenient symbol inventory of ``node``: every user predicate/
388
+ function ``(name, arity)`` pair and every constant name reachable
389
+ anywhere in the tree — NEVER raising, unlike :meth:`Signature.from_formulas`.
390
+
391
+ This is the classification :meth:`Signature.from_formulas` would also
392
+ need to do, minus its refusals: a predicate or function used at two
393
+ different arities somewhere under ``node`` contributes TWO separate
394
+ ``(name, arity)`` entries to the returned set (rather than being
395
+ refused as a conflict), and a name used both as a bare constant and as
396
+ an applied function contributes to BOTH the constants set and the
397
+ functions set (rather than being refused as a namespace clash). It
398
+ exists for :mod:`unicode_logic_kit.eval.predicate_match`, whose whole
399
+ purpose is scoring possibly-malformed model output gracefully — a
400
+ genuine vocabulary conflict is exactly the kind of input it must still
401
+ align rather than reject (see that module's docstring).
402
+
403
+ Returns ``(predicates, functions, constants)`` — ``predicates`` /
404
+ ``functions`` are sets of ``(name, arity)`` pairs (the kit's built-in
405
+ operators, :data:`_BUILTIN_PREDS` / :data:`_BUILTIN_FUNCS`, and the two truth
406
+ constants ``$true`` / ``$false`` excluded, exactly like
407
+ :meth:`from_formulas`'s classification); ``constants`` is
408
+ a set of names (:class:`Constant` and :class:`SortedConstant`
409
+ occurrences alike — a sorted constant's own inline sort annotation is
410
+ not part of this vocabulary-shape inventory).
411
+
412
+ The walk is :meth:`~unicode_logic_kit.fol.nodes.Node.walk` — every node in
413
+ the tree, pre-order, via the generic ``_child_nodes()`` recursion — so a
414
+ symbol nested under a modal operator, inside a counting quantifier's set
415
+ builder, or under any other construct this module does not itself know
416
+ about is still found. This flat "classify every node, regardless of
417
+ whether it sits in formula or term position" pass needs no
418
+ formula/term dispatch to be complete (unlike :meth:`from_formulas`'s own
419
+ walk, which routes a nested FORMULA field back through its
420
+ formula-walker for the same reason) — every ``Node``-valued field,
421
+ wherever it lives structurally, is a ``walk()`` stop in its own right.
422
+ """
423
+ predicates: Set[Tuple[str, int]] = set()
424
+ functions: Set[Tuple[str, int]] = set()
425
+ constants: Set[str] = set()
426
+ for n in node.walk():
427
+ if isinstance(n, Atom):
428
+ if n.predicate not in _BUILTIN_PREDS and not is_truth_constant(n):
429
+ predicates.add((n.predicate, len(n.args)))
430
+ elif isinstance(n, Function):
431
+ if n.name not in _BUILTIN_FUNCS:
432
+ functions.add((n.name, len(n.args)))
433
+ elif isinstance(n, (Constant, SortedConstant)):
434
+ constants.add(n.name)
435
+ return predicates, functions, constants
436
+
437
+
438
+ # =============================================================================
439
+ # Shared refusal bookkeeping — used by this module's own from_formulas AND by
440
+ # unicode_logic_kit.fol.casl_export's independently-tested _analyze pass (see
441
+ # the module docstring's DESIGN NOTE for why only the comparison-and-raise
442
+ # itself is shared, not each caller's accumulation strategy or wording).
443
+ # =============================================================================
444
+
445
+ def _check_single_valued(values, message: str):
446
+ """Return the sole element of ``values`` if every member is the same
447
+ value; raise ``ValueError(message)`` if ``values`` names more than one
448
+ distinct value.
449
+
450
+ ``values`` is never empty at either call site (both only call this once
451
+ a symbol has been recorded at least once). The shared "this symbol was
452
+ declared inconsistently" predicate: :meth:`Signature.from_formulas`
453
+ calls it once per symbol, over the FULL set of arities/sorts collected
454
+ across an entire formula batch; ``casl_export._analyze`` calls it
455
+ incrementally, per occurrence, over just ``{previously recorded value,
456
+ this occurrence's value}`` — so it can raise as soon as a conflict
457
+ first appears, before any later formula in the batch is even walked.
458
+ """
459
+ if len(values) > 1:
460
+ raise ValueError(message)
461
+ return next(iter(values))
462
+
463
+
464
+ def _check_not_dual_use(name: str, other_namespace_names, message: str) -> None:
465
+ """Raise ``ValueError(message)`` iff ``name`` is a member of
466
+ ``other_namespace_names`` — the shared "used as both a constant and a
467
+ function" clash check behind both :meth:`Signature.from_formulas` and
468
+ ``casl_export._analyze``'s independent bookkeeping. Each caller keeps
469
+ its own message wording (the two modules describe the same underlying
470
+ fact in their own terms), so only the comparison is shared here.
471
+ """
472
+ if name in other_namespace_names:
473
+ raise ValueError(message)
474
+
475
+
476
+ # =============================================================================
477
+ # Declaration dataclasses
478
+ # =============================================================================
479
+
480
+ def _require_arity(value, name: str, kind: str) -> int:
481
+ """Return ``value`` coerced to a validated non-negative arity, or refuse.
482
+
483
+ ``bool`` is explicitly rejected even though it is a ``int`` subclass in
484
+ Python — an accidental ``True``/``False`` silently read as arity 1/0
485
+ would be exactly the kind of silent-acceptance bug this kit's loud-
486
+ refusal convention exists to catch.
487
+ """
488
+ if isinstance(value, bool) or not isinstance(value, int):
489
+ raise TypeError(
490
+ f"{kind} {name!r}: arity must be an int, got {type(value).__name__}."
491
+ )
492
+ if value < 0:
493
+ raise ValueError(f"{kind} {name!r}: arity must be >= 0, got {value}.")
494
+ return value
495
+
496
+
497
+ @dataclass(frozen=True)
498
+ class PredicateDecl:
499
+ """A predicate symbol's declared shape.
500
+
501
+ ``arg_sorts``, when given, must have exactly ``arity`` entries, each a
502
+ sort name or ``None`` (that argument position's sort is left
503
+ unconstrained — see the module docstring's "both sides concrete" rule).
504
+ ``arg_sorts=None`` (the default) is the canonical "nothing declared"
505
+ form for a fully unsorted predicate — NOT a tuple of ``arity`` ``None``
506
+ entries;
507
+ the two are equivalent to :meth:`Signature.validate` (neither ever
508
+ conflicts with anything), but only the former is what
509
+ :meth:`Signature.from_dict` / :meth:`Signature.from_formulas` produce.
510
+ """
511
+
512
+ name: str
513
+ arity: int
514
+ arg_sorts: Optional[Tuple[Optional[str], ...]] = None
515
+
516
+ def __post_init__(self):
517
+ object.__setattr__(
518
+ self, "arity", _require_arity(self.arity, self.name, "PredicateDecl"))
519
+ if self.arg_sorts is not None:
520
+ coerced = tuple(self.arg_sorts)
521
+ if len(coerced) != self.arity:
522
+ raise ValueError(
523
+ f"PredicateDecl {self.name!r}: arg_sorts has {len(coerced)} "
524
+ f"entries but arity is {self.arity}."
525
+ )
526
+ object.__setattr__(self, "arg_sorts", coerced)
527
+
528
+
529
+ @dataclass(frozen=True)
530
+ class FunctionDecl:
531
+ """A function symbol's declared shape: like :class:`PredicateDecl`, plus
532
+ an optional ``result_sort`` for the sort of ``name(...)`` itself as a
533
+ term (``None`` — the default — means the result sort is unconstrained)."""
534
+
535
+ name: str
536
+ arity: int
537
+ arg_sorts: Optional[Tuple[Optional[str], ...]] = None
538
+ result_sort: Optional[str] = None
539
+
540
+ def __post_init__(self):
541
+ object.__setattr__(
542
+ self, "arity", _require_arity(self.arity, self.name, "FunctionDecl"))
543
+ if self.arg_sorts is not None:
544
+ coerced = tuple(self.arg_sorts)
545
+ if len(coerced) != self.arity:
546
+ raise ValueError(
547
+ f"FunctionDecl {self.name!r}: arg_sorts has {len(coerced)} "
548
+ f"entries but arity is {self.arity}."
549
+ )
550
+ object.__setattr__(self, "arg_sorts", coerced)
551
+ if self.result_sort is not None and not isinstance(self.result_sort, str):
552
+ raise TypeError(
553
+ f"FunctionDecl {self.name!r}: result_sort must be a str or "
554
+ f"None, got {type(self.result_sort).__name__}."
555
+ )
556
+
557
+
558
+ @dataclass(frozen=True)
559
+ class ConstantDecl:
560
+ """A constant symbol's declared shape: just a name and an optional sort
561
+ (arity is implicitly 0 — a constant is never applied to arguments)."""
562
+
563
+ name: str
564
+ sort: Optional[str] = None
565
+
566
+ def __post_init__(self):
567
+ if self.sort is not None and not isinstance(self.sort, str):
568
+ raise TypeError(
569
+ f"ConstantDecl {self.name!r}: sort must be a str or None, "
570
+ f"got {type(self.sort).__name__}."
571
+ )
572
+
573
+
574
+ # =============================================================================
575
+ # Signature: the aggregate carrier
576
+ # =============================================================================
577
+
578
+ def _freeze_section(mapping, decl_cls, kind: str) -> Mapping:
579
+ """Validate and wrap one symbol-table field as a read-only ``MappingProxyType``.
580
+
581
+ Refuses (``TypeError``) a non-mapping input, a value of the wrong decl
582
+ class, or (``ValueError``) a dict key that does not match that decl's own
583
+ ``.name`` — the map key and the declaration's self-reported name must
584
+ agree, or a lookup by key would silently return a decl that describes a
585
+ DIFFERENT symbol.
586
+ """
587
+ if not isinstance(mapping, Mapping):
588
+ raise TypeError(
589
+ f"Signature: {kind}s must be a dict, got {type(mapping).__name__}."
590
+ )
591
+ out = {}
592
+ for key, decl in mapping.items():
593
+ if not isinstance(decl, decl_cls):
594
+ raise TypeError(
595
+ f"Signature: {kind} entry {key!r} must be a {decl_cls.__name__}, "
596
+ f"got {type(decl).__name__}."
597
+ )
598
+ if decl.name != key:
599
+ raise ValueError(
600
+ f"Signature: {kind} dict key {key!r} does not match "
601
+ f"{decl_cls.__name__}.name {decl.name!r}."
602
+ )
603
+ out[key] = decl
604
+ return MappingProxyType(out)
605
+
606
+
607
+ def _freeze_subsorts(mapping) -> Mapping[str, FrozenSet[str]]:
608
+ """Validate and wrap the ``subsorts`` field as a read-only mapping of
609
+ child sort name -> a ``frozenset`` of its DIRECT parent sort names.
610
+
611
+ Refuses (``TypeError``) a non-mapping input, a non-``str`` child key, or
612
+ a per-child value that is not an iterable of ``str`` parent names.
613
+ Cycle detection happens separately, in :func:`_subsort_closure` (needs
614
+ the whole mapping built first).
615
+ """
616
+ if not isinstance(mapping, Mapping):
617
+ raise TypeError(
618
+ f"Signature: subsorts must be a dict, got {type(mapping).__name__}."
619
+ )
620
+ out = {}
621
+ for child, parents in mapping.items():
622
+ if not isinstance(child, str):
623
+ raise TypeError(
624
+ f"Signature: subsorts key must be a sort name (str), got "
625
+ f"{type(child).__name__}."
626
+ )
627
+ if isinstance(parents, str) or not isinstance(parents, Iterable):
628
+ raise TypeError(
629
+ f"Signature: subsorts[{child!r}] must be an iterable of "
630
+ f"parent sort names, got {type(parents).__name__}."
631
+ )
632
+ parent_set = frozenset(parents)
633
+ for p in parent_set:
634
+ if not isinstance(p, str):
635
+ raise TypeError(
636
+ f"Signature: subsorts[{child!r}] entries must be sort "
637
+ f"names (str), got {type(p).__name__}."
638
+ )
639
+ out[child] = parent_set
640
+ return MappingProxyType(out)
641
+
642
+
643
+ def _subsort_closure(direct: Mapping[str, FrozenSet[str]]) -> Mapping[str, FrozenSet[str]]:
644
+ """Return the transitive (non-reflexive) closure of ``direct`` — child
645
+ sort name -> every ancestor reachable by chaining one or more edges.
646
+
647
+ Raises :class:`ValueError` naming the cycle (e.g. ``"A -> B -> A"``) if
648
+ ``direct`` is not a DAG — a sort declared, directly or transitively, as
649
+ its own ancestor is not a coherent subset relation (``ext(A) ⊆ ext(A)``
650
+ trivially, but a genuine cycle among two-or-more DISTINCT declared sorts
651
+ would force them to denote the exact same set, which this module refuses
652
+ loudly rather than silently accepting as "fine, just redundant").
653
+ """
654
+ memo: Dict[str, FrozenSet[str]] = {}
655
+
656
+ def closure_of(child: str, stack: Tuple[str, ...]) -> FrozenSet[str]:
657
+ if child in memo:
658
+ return memo[child]
659
+ if child in stack:
660
+ cycle = stack[stack.index(child):] + (child,)
661
+ raise ValueError(
662
+ f"Signature: subsorts has a cycle: {' -> '.join(cycle)}."
663
+ )
664
+ ancestors: Set[str] = set()
665
+ for parent in direct.get(child, frozenset()):
666
+ ancestors.add(parent)
667
+ ancestors |= closure_of(parent, stack + (child,))
668
+ memo[child] = frozenset(ancestors)
669
+ return memo[child]
670
+
671
+ for child in direct:
672
+ closure_of(child, ())
673
+ return MappingProxyType(memo)
674
+
675
+
676
+ def _merge_subsorts(a: Mapping[str, FrozenSet[str]],
677
+ b: Mapping[str, FrozenSet[str]]) -> Dict[str, FrozenSet[str]]:
678
+ """Union ``a`` and ``b``'s direct edges, per child sort — see
679
+ :meth:`Signature.merge`'s docstring for why two differing parent sets
680
+ for the same child are unioned rather than flagged as conflicting."""
681
+ out: Dict[str, Set[str]] = {child: set(parents) for child, parents in a.items()}
682
+ for child, parents in b.items():
683
+ out.setdefault(child, set()).update(parents)
684
+ return {child: frozenset(parents) for child, parents in out.items()}
685
+
686
+
687
+ @dataclass(frozen=True)
688
+ class Signature:
689
+ """The kit's declared vocabulary: predicates, functions, constants, sorts.
690
+
691
+ See the module docstring for the full account of every constructor and
692
+ method below. All four fields default to empty (an "empty" signature
693
+ declares NOTHING — every predicate/function/constant use in any formula
694
+ is then reported as undeclared by :meth:`validate`; this is the strict,
695
+ loud-refusal-by-default reading, not a permissive "anything goes" one).
696
+ """
697
+
698
+ predicates: Mapping[str, PredicateDecl] = field(default_factory=dict)
699
+ functions: Mapping[str, FunctionDecl] = field(default_factory=dict)
700
+ constants: Mapping[str, ConstantDecl] = field(default_factory=dict)
701
+ sorts: FrozenSet[str] = field(default_factory=frozenset)
702
+ #: child sort name -> its DIRECT declared parent sorts (never the
703
+ #: transitive closure — see the module docstring's "Subsorting" section).
704
+ subsorts: Mapping[str, FrozenSet[str]] = field(default_factory=dict)
705
+
706
+ def __post_init__(self):
707
+ object.__setattr__(
708
+ self, "predicates", _freeze_section(self.predicates, PredicateDecl, "predicate"))
709
+ object.__setattr__(
710
+ self, "functions", _freeze_section(self.functions, FunctionDecl, "function"))
711
+ object.__setattr__(
712
+ self, "constants", _freeze_section(self.constants, ConstantDecl, "constant"))
713
+ object.__setattr__(self, "sorts", frozenset(self.sorts))
714
+ direct = _freeze_subsorts(self.subsorts)
715
+ object.__setattr__(self, "subsorts", direct)
716
+ object.__setattr__(self, "_subsort_closure", _subsort_closure(direct))
717
+
718
+ # -------------------------------------------------------------------
719
+ # Constructors
720
+ # -------------------------------------------------------------------
721
+
722
+ @staticmethod
723
+ def from_dict(d: Mapping) -> "Signature":
724
+ """Build a :class:`Signature` from a plain dict.
725
+
726
+ Accepts both the ``api.check`` loose convention and the richer
727
+ explicit form :meth:`to_dict` emits, per-section and per-entry — see
728
+ the module docstring's "The api.check loose dict convention" and
729
+ "The richer explicit dict form" sections for the exact shapes and
730
+ the refusals (unknown top-level key, unknown key inside a rich
731
+ entry, a multi-arity iterable naming more than one distinct arity,
732
+ a wrong-typed value) this raises on.
733
+ """
734
+ if not isinstance(d, Mapping):
735
+ raise TypeError(f"Signature.from_dict expects a dict, got {type(d).__name__}.")
736
+ extra = set(d) - _ALLOWED_TOP_KEYS
737
+ if extra:
738
+ raise ValueError(
739
+ f"Signature.from_dict: unexpected top-level key(s) {sorted(extra)} "
740
+ f"(expected a subset of {sorted(_ALLOWED_TOP_KEYS)})."
741
+ )
742
+ predicates = _parse_symbol_section(
743
+ d.get("predicates", {}), PredicateDecl, "predicate", has_result_sort=False)
744
+ functions = _parse_symbol_section(
745
+ d.get("functions", {}), FunctionDecl, "function", has_result_sort=True)
746
+ constants = _parse_constants_section(d.get("constants", {}))
747
+ subsorts = _parse_subsorts_section(d.get("subsorts", {}))
748
+
749
+ declared_sorts = d.get("sorts", ())
750
+ if not isinstance(declared_sorts, (list, tuple, set, frozenset)):
751
+ raise TypeError(
752
+ f"Signature.from_dict: 'sorts' must be an iterable of names, "
753
+ f"got {type(declared_sorts).__name__}."
754
+ )
755
+ sorts = (set(declared_sorts) | _implied_sorts(predicates, functions, constants)
756
+ | set(subsorts) | {p for parents in subsorts.values() for p in parents})
757
+ return Signature(predicates=predicates, functions=functions,
758
+ constants=constants, sorts=frozenset(sorts),
759
+ subsorts=subsorts)
760
+
761
+ @staticmethod
762
+ def from_formulas(formulas: Iterable[Node]) -> "Signature":
763
+ """Infer a :class:`Signature` from a batch of ASTs.
764
+
765
+ See the module docstring's "Signature.from_formulas — scope and what
766
+ it deliberately does NOT infer" section for exactly what this does
767
+ (arities, constant sorts, the ``sorts`` set) and does not (per-
768
+ argument-position sort inference) attempt, and for the two refusals
769
+ (a cross-formula arity or constant-sort conflict; a name used both
770
+ as a constant and as a function) it raises :class:`ValueError` on.
771
+
772
+ A constant written with TWO sorts (``c:A`` here, ``c:B`` there) is
773
+ one of those refusals, on purpose: a :class:`Signature` is a typed
774
+ DECLARATION, and a declaration gives a constant ONE sort. A formula
775
+ is not a declaration. The routes that decide formulas read such a
776
+ constant as lying in BOTH sorts (the finite model finder draws it
777
+ from the intersection of their universes), so the same input is
778
+ refused here and answered there; declare the constant under one
779
+ sort, or leave it out of the signature and let the formulas speak.
780
+ """
781
+ pred_arities: Dict[str, set] = {}
782
+ func_arities: Dict[str, set] = {}
783
+ const_sorts: Dict[str, set] = {}
784
+ const_names: set = set()
785
+ func_names: set = set()
786
+ literal_sorts: set = set()
787
+
788
+ def walk_formula(node: Node) -> None:
789
+ if isinstance(node, Atom):
790
+ if node.predicate not in _BUILTIN_PREDS and not is_truth_constant(node):
791
+ pred_arities.setdefault(node.predicate, set()).add(len(node.args))
792
+ for a in node.args:
793
+ walk_term(a)
794
+ return
795
+ if isinstance(node, SortedQuantifier):
796
+ literal_sorts.add(node.sort)
797
+ walk_formula(node.formula)
798
+ return
799
+ if isinstance(node, SortedCount):
800
+ # a sort that occurs only in a sorted counting quantifier is a sort of the signature too
801
+ literal_sorts.add(node.sort)
802
+ for child in node._child_nodes():
803
+ walk_formula(child)
804
+
805
+ def walk_term(node: Node) -> None:
806
+ if isinstance(node, SortedConstant):
807
+ const_names.add(node.name)
808
+ const_sorts.setdefault(node.name, set()).add(node.sort)
809
+ literal_sorts.add(node.sort)
810
+ return
811
+ if isinstance(node, Constant):
812
+ const_names.add(node.name)
813
+ return
814
+ if isinstance(node, Function):
815
+ if node.name not in _BUILTIN_FUNCS:
816
+ func_arities.setdefault(node.name, set()).add(len(node.args))
817
+ func_names.add(node.name)
818
+ for a in node.args:
819
+ walk_term(a)
820
+ return
821
+ # TERM nodes that carry a genuine FORMULA field (the counting
822
+ # comparisons' set builders): route it back through the formula
823
+ # walker — otherwise every predicate used only inside a
824
+ # |{v : …}| body would be invisible (review-confirmed gap).
825
+ formula_field = getattr(node, "formula", None)
826
+ if formula_field is not None:
827
+ if isinstance(node, SortedCardinality):
828
+ literal_sorts.add(node.sort)
829
+ walk_formula(formula_field)
830
+ for child in node._child_nodes():
831
+ if child is not formula_field:
832
+ walk_term(child)
833
+ return
834
+ for child in node._child_nodes():
835
+ walk_term(child)
836
+
837
+ for f in formulas:
838
+ walk_formula(f)
839
+
840
+ clash = const_names & func_names
841
+ if clash:
842
+ name = sorted(clash)[0]
843
+ _check_not_dual_use(
844
+ name, func_names,
845
+ f"Signature.from_formulas: {name!r} is used both as a constant "
846
+ "and as a function across the given formulas — a Signature "
847
+ "cannot declare one name in both namespaces from usage alone."
848
+ )
849
+
850
+ predicates = {}
851
+ for name, arities in pred_arities.items():
852
+ arity = _check_single_valued(
853
+ arities,
854
+ f"Signature.from_formulas: predicate {name!r} used with "
855
+ f"conflicting arities {tuple(sorted(arities))}."
856
+ )
857
+ predicates[name] = PredicateDecl(name, arity)
858
+
859
+ functions = {}
860
+ for name, arities in func_arities.items():
861
+ arity = _check_single_valued(
862
+ arities,
863
+ f"Signature.from_formulas: function {name!r} used with "
864
+ f"conflicting arities {tuple(sorted(arities))}."
865
+ )
866
+ functions[name] = FunctionDecl(name, arity)
867
+
868
+ constants = {}
869
+ for name in const_names:
870
+ seen = const_sorts.get(name, set())
871
+ sort = _check_single_valued(
872
+ seen,
873
+ f"Signature.from_formulas: constant {name!r} used with "
874
+ f"conflicting sorts {tuple(sorted(seen))}."
875
+ ) if seen else None
876
+ constants[name] = ConstantDecl(name, sort)
877
+
878
+ return Signature(predicates=predicates, functions=functions,
879
+ constants=constants, sorts=frozenset(literal_sorts))
880
+
881
+ # -------------------------------------------------------------------
882
+ # Serialisation
883
+ # -------------------------------------------------------------------
884
+
885
+ def to_dict(self) -> dict:
886
+ """Render this signature as the rich, JSON-compatible, key-order-stable dict.
887
+
888
+ See the module docstring's "The richer explicit dict form" section
889
+ for the exact shape. ``Signature.from_dict(sig.to_dict())`` always
890
+ reconstructs a :class:`Signature` equal to ``sig``.
891
+ """
892
+ predicates = {
893
+ name: {
894
+ "arity": decl.arity,
895
+ "arg_sorts": list(decl.arg_sorts) if decl.arg_sorts is not None else None,
896
+ }
897
+ for name, decl in sorted(self.predicates.items())
898
+ }
899
+ functions = {
900
+ name: {
901
+ "arity": decl.arity,
902
+ "arg_sorts": list(decl.arg_sorts) if decl.arg_sorts is not None else None,
903
+ "result_sort": decl.result_sort,
904
+ }
905
+ for name, decl in sorted(self.functions.items())
906
+ }
907
+ constants = {name: decl.sort for name, decl in sorted(self.constants.items())}
908
+ subsorts = {
909
+ child: sorted(parents) for child, parents in sorted(self.subsorts.items())
910
+ }
911
+ return {
912
+ "predicates": predicates,
913
+ "functions": functions,
914
+ "constants": constants,
915
+ "sorts": sorted(self.sorts),
916
+ "subsorts": subsorts,
917
+ }
918
+
919
+ # -------------------------------------------------------------------
920
+ # Checking
921
+ # -------------------------------------------------------------------
922
+
923
+ def validate(self, formula: Node) -> List[str]:
924
+ """Return the list of vocabulary violations ``formula`` commits against
925
+ this signature; an empty list means ``formula`` conforms.
926
+
927
+ See the module docstring's "Signature.validate — violation
928
+ vocabulary" section for the exact message shapes and the walk's
929
+ scope (which node types are specially recognised, and how every
930
+ other node type is transparently recursed through rather than
931
+ rejected).
932
+ """
933
+ violations: List[str] = []
934
+ _walk_formula(formula, {}, self, violations)
935
+ return violations
936
+
937
+ # -------------------------------------------------------------------
938
+ # Subsorting
939
+ # -------------------------------------------------------------------
940
+
941
+ def is_subsort(self, s: str, t: str) -> bool:
942
+ """True iff ``s`` is ``t`` itself, or a direct-or-transitive subsort
943
+ of ``t`` (reflexive-transitive lookup against the closure computed
944
+ in :meth:`__post_init__` from the declared direct edges).
945
+
946
+ Never symmetric: ``is_subsort("Human", "Animal")`` and
947
+ ``is_subsort("Animal", "Human")`` differ once only ``Human <
948
+ Animal`` is declared (see the module docstring's "Subsorting"
949
+ section).
950
+ """
951
+ if s == t:
952
+ return True
953
+ return t in self._subsort_closure.get(s, frozenset())
954
+
955
+ # -------------------------------------------------------------------
956
+ # Combining
957
+ # -------------------------------------------------------------------
958
+
959
+ def merge(self, other: "Signature") -> "Signature":
960
+ """Return the union of ``self`` and ``other``.
961
+
962
+ Every predicate/function/constant/sort present in either input is
963
+ present in the result. A symbol declared by BOTH sides with
964
+ DIFFERING declarations (different arity, ``arg_sorts``,
965
+ ``result_sort``, or ``sort``) is a genuine conflict and raises
966
+ :class:`ValueError` naming the symbol and both declarations; a
967
+ symbol declared identically by both sides merges without complaint.
968
+ Sorts merge with a plain set union (a bare sort NAME can never
969
+ itself conflict with another). ``subsorts`` merges the same way,
970
+ per-child-sort union of direct parents (see the module docstring's
971
+ "Subsorting" section for why two differing parent sets are never
972
+ themselves a conflict, and the one way a merge can still fail: the
973
+ union creating a cycle neither side had alone).
974
+ """
975
+ predicates = _merge_section(self.predicates, other.predicates, "predicate")
976
+ functions = _merge_section(self.functions, other.functions, "function")
977
+ constants = _merge_section(self.constants, other.constants, "constant")
978
+ subsorts = _merge_subsorts(self.subsorts, other.subsorts)
979
+ return Signature(predicates=predicates, functions=functions,
980
+ constants=constants, sorts=self.sorts | other.sorts,
981
+ subsorts=subsorts)
982
+
983
+
984
+ # =============================================================================
985
+ # from_dict helpers
986
+ # =============================================================================
987
+
988
+ _ALLOWED_TOP_KEYS = frozenset(
989
+ {"predicates", "functions", "constants", "sorts", "subsorts"})
990
+
991
+
992
+ def _parse_subsorts_section(section) -> dict:
993
+ """Parse the ``subsorts`` section: name -> an iterable of direct parent
994
+ sort names. Cycle detection is left to :class:`Signature`'s own
995
+ ``__post_init__`` (needs the fully-assembled mapping)."""
996
+ if not isinstance(section, Mapping):
997
+ raise TypeError(
998
+ f"Signature.from_dict: 'subsorts' must be a dict, got "
999
+ f"{type(section).__name__}."
1000
+ )
1001
+ out = {}
1002
+ for child, parents in section.items():
1003
+ if not isinstance(parents, (list, tuple, set, frozenset)):
1004
+ raise TypeError(
1005
+ f"Signature.from_dict: subsorts[{child!r}] must be an "
1006
+ f"iterable of parent sort names, got {type(parents).__name__}."
1007
+ )
1008
+ out[child] = frozenset(parents)
1009
+ return out
1010
+
1011
+
1012
+ def _parse_symbol_section(section, decl_cls, kind: str, has_result_sort: bool) -> dict:
1013
+ """Parse one ``predicates``/``functions`` section, loose OR rich per entry."""
1014
+ if not isinstance(section, Mapping):
1015
+ raise TypeError(
1016
+ f"Signature.from_dict: {kind}s section must be a dict, got "
1017
+ f"{type(section).__name__}."
1018
+ )
1019
+ allowed_keys = {"arity", "arg_sorts", "result_sort"} if has_result_sort \
1020
+ else {"arity", "arg_sorts"}
1021
+ out = {}
1022
+ for name, value in section.items():
1023
+ if isinstance(value, dict):
1024
+ if "arity" not in value:
1025
+ raise ValueError(
1026
+ f"Signature.from_dict: {kind} {name!r} rich entry is "
1027
+ "missing the required key 'arity'."
1028
+ )
1029
+ extra = set(value) - allowed_keys
1030
+ if extra:
1031
+ raise ValueError(
1032
+ f"Signature.from_dict: {kind} {name!r} entry has "
1033
+ f"unexpected key(s) {sorted(extra)}."
1034
+ )
1035
+ arity = _require_arity(value["arity"], name, kind)
1036
+ raw_arg_sorts = value.get("arg_sorts")
1037
+ arg_sorts = tuple(raw_arg_sorts) if raw_arg_sorts is not None else None
1038
+ result_sort = value.get("result_sort") if has_result_sort else None
1039
+ elif isinstance(value, (list, tuple, set, frozenset)):
1040
+ arities = sorted({_require_arity(v, name, kind) for v in value})
1041
+ if not arities:
1042
+ raise ValueError(
1043
+ f"Signature.from_dict: {kind} {name!r} has an empty arity set."
1044
+ )
1045
+ if len(arities) > 1:
1046
+ raise ValueError(
1047
+ f"Signature.from_dict: {kind} {name!r} lists multiple "
1048
+ f"allowed arities {tuple(arities)} — a Signature declares "
1049
+ "exactly one arity per symbol (api.check's multi-arity "
1050
+ "CONSTRAINT has no Signature DECLARATION equivalent)."
1051
+ )
1052
+ arity = arities[0]
1053
+ arg_sorts = None
1054
+ result_sort = None
1055
+ else:
1056
+ arity = _require_arity(value, name, kind)
1057
+ arg_sorts = None
1058
+ result_sort = None
1059
+ kwargs = {"name": name, "arity": arity, "arg_sorts": arg_sorts}
1060
+ if has_result_sort:
1061
+ kwargs["result_sort"] = result_sort
1062
+ out[name] = decl_cls(**kwargs)
1063
+ return out
1064
+
1065
+
1066
+ def _parse_constants_section(section) -> dict:
1067
+ """Parse the ``constants`` section: a dict (rich, name -> sort-or-None)
1068
+ or a bare iterable of names (loose, every constant unsorted)."""
1069
+ if isinstance(section, Mapping):
1070
+ out = {}
1071
+ for name, sort in section.items():
1072
+ out[name] = ConstantDecl(name, sort)
1073
+ return out
1074
+ if isinstance(section, (list, tuple, set, frozenset)):
1075
+ out = {}
1076
+ for name in section:
1077
+ if not isinstance(name, str):
1078
+ raise TypeError(
1079
+ f"Signature.from_dict: constants entries must be strings, "
1080
+ f"got {type(name).__name__}."
1081
+ )
1082
+ out[name] = ConstantDecl(name, None)
1083
+ return out
1084
+ raise TypeError(
1085
+ f"Signature.from_dict: constants section must be a dict or an "
1086
+ f"iterable of names, got {type(section).__name__}."
1087
+ )
1088
+
1089
+
1090
+ def _implied_sorts(predicates: dict, functions: dict, constants: dict) -> set:
1091
+ """Every sort name mentioned by any parsed decl's arg_sorts/result_sort/sort."""
1092
+ sorts = set()
1093
+ for decl in predicates.values():
1094
+ if decl.arg_sorts:
1095
+ sorts.update(s for s in decl.arg_sorts if s is not None)
1096
+ for decl in functions.values():
1097
+ if decl.arg_sorts:
1098
+ sorts.update(s for s in decl.arg_sorts if s is not None)
1099
+ if decl.result_sort is not None:
1100
+ sorts.add(decl.result_sort)
1101
+ for decl in constants.values():
1102
+ if decl.sort is not None:
1103
+ sorts.add(decl.sort)
1104
+ return sorts
1105
+
1106
+
1107
+ # =============================================================================
1108
+ # merge helper
1109
+ # =============================================================================
1110
+
1111
+ def _normalized_decl(decl):
1112
+ """A decl with vacuous sort annotations folded away, for comparison.
1113
+
1114
+ ``arg_sorts=None`` and ``arg_sorts=(None,) * arity`` are behaviourally
1115
+ identical to :meth:`Signature.validate` (neither ever conflicts with
1116
+ anything), so :func:`_merge_section` must treat them as the SAME
1117
+ declaration — plain dataclass equality does not (review-confirmed:
1118
+ ``from_dict({'arg_sorts': [None]})`` vs a bare decl raised a spurious
1119
+ merge conflict).
1120
+ """
1121
+ arg_sorts = getattr(decl, "arg_sorts", None)
1122
+ if arg_sorts is not None and all(s is None for s in arg_sorts):
1123
+ decl = replace(decl, arg_sorts=None)
1124
+ return decl
1125
+
1126
+
1127
+ def _merge_section(a: Mapping, b: Mapping, kind: str) -> dict:
1128
+ out = dict(a)
1129
+ for name, decl in b.items():
1130
+ if name in out and _normalized_decl(out[name]) != _normalized_decl(decl):
1131
+ raise ValueError(
1132
+ f"Signature.merge: conflicting {kind} declaration for "
1133
+ f"{name!r}: {out[name]!r} vs {decl!r}."
1134
+ )
1135
+ out.setdefault(name, decl)
1136
+ return out
1137
+
1138
+
1139
+ # =============================================================================
1140
+ # validate() helpers — a single-pass, generically-recursive walk
1141
+ # =============================================================================
1142
+
1143
+ def _walk_formula(node: Node, env: Dict[str, Optional[str]], sig: Signature,
1144
+ violations: List[str]) -> None:
1145
+ """Walk a formula node, threading the sorted-variable environment.
1146
+
1147
+ Recognises :class:`Atom` (checked via :func:`_check_atom`),
1148
+ :class:`SortedQuantifier` and :class:`Quantifier` (both extend ``env``
1149
+ for their body — sorted with a concrete sort, plain with ``None``,
1150
+ correctly shadowing an outer binding of the same variable name), and the
1151
+ counting binders :class:`Count` and :class:`SortedCount`, which extend
1152
+ ``env`` the same way (the set-builder terms are handled in
1153
+ :func:`_term_sort`); every
1154
+ other node type is transparently recursed into via
1155
+ ``node._child_nodes()`` with the SAME ``env`` — this is what lets an
1156
+ ``Atom`` nested under a modal/temporal/counting/… node still get
1157
+ checked, without this module needing to know about every node class in
1158
+ the kit.
1159
+ """
1160
+ if isinstance(node, Atom):
1161
+ _check_atom(node, env, sig, violations)
1162
+ return
1163
+ if isinstance(node, SortedQuantifier):
1164
+ new_env = dict(env)
1165
+ new_env[node.variable.name] = node.sort
1166
+ _walk_formula(node.formula, new_env, sig, violations)
1167
+ return
1168
+ if isinstance(node, Quantifier):
1169
+ new_env = dict(env)
1170
+ new_env[node.variable.name] = None
1171
+ _walk_formula(node.formula, new_env, sig, violations)
1172
+ return
1173
+ if isinstance(node, (SortedCount, Count)):
1174
+ # A counting quantifier binds its variable exactly as a quantifier
1175
+ # does, so an atom in its matrix sees the variable at the counting
1176
+ # sort (``None`` for the unsorted form).
1177
+ new_env = dict(env)
1178
+ new_env[node.variable.name] = getattr(node, "sort", None)
1179
+ _walk_formula(node.formula, new_env, sig, violations)
1180
+ return
1181
+ for child in node._child_nodes():
1182
+ _walk_formula(child, env, sig, violations)
1183
+
1184
+
1185
+ def _check_atom(node: Atom, env: Dict[str, Optional[str]], sig: Signature,
1186
+ violations: List[str]) -> None:
1187
+ """Check one Atom occurrence: declaredness, arity, and per-argument sort."""
1188
+ arity = len(node.args)
1189
+ if node.predicate in _BUILTIN_PREDS:
1190
+ for arg in node.args:
1191
+ _term_sort(arg, env, sig, violations)
1192
+ return
1193
+ if is_truth_constant(node):
1194
+ return # `$true` / `$false` (and ``⊤`` / ``⊥``): no argument, no vocabulary to declare
1195
+ decl = sig.predicates.get(node.predicate)
1196
+ if decl is None:
1197
+ violations.append(f"undeclared predicate '{node.predicate}' (arity {arity})")
1198
+ for arg in node.args:
1199
+ _term_sort(arg, env, sig, violations)
1200
+ return
1201
+ if decl.arity != arity:
1202
+ violations.append(
1203
+ f"predicate '{node.predicate}' expects arity {decl.arity}, used "
1204
+ f"with arity {arity}"
1205
+ )
1206
+ for i, arg in enumerate(node.args):
1207
+ arg_sort = _term_sort(arg, env, sig, violations)
1208
+ if decl.arg_sorts is not None and i < len(decl.arg_sorts):
1209
+ declared = decl.arg_sorts[i]
1210
+ if declared is not None and arg_sort is not None and not (
1211
+ arg_sort == declared or sig.is_subsort(arg_sort, declared)
1212
+ ):
1213
+ violations.append(
1214
+ f"predicate '{node.predicate}' argument {i + 1} expects "
1215
+ f"sort '{declared}', got sort '{arg_sort}'"
1216
+ )
1217
+
1218
+
1219
+ def _term_sort(node: Node, env: Dict[str, Optional[str]], sig: Signature,
1220
+ violations: List[str]) -> Optional[str]:
1221
+ """Check one term occurrence and return its resolved concrete sort (or
1222
+ ``None`` if unknown/unsorted) — see the module docstring for the rules
1223
+ determining a term's concrete sort per node kind."""
1224
+ if isinstance(node, Variable):
1225
+ return env.get(node.name)
1226
+ if isinstance(node, SortedConstant):
1227
+ decl = sig.constants.get(node.name)
1228
+ if decl is None:
1229
+ violations.append(f"undeclared constant '{node.name}'")
1230
+ elif decl.sort is not None and decl.sort != node.sort:
1231
+ violations.append(
1232
+ f"constant '{node.name}' is annotated sort '{node.sort}' "
1233
+ f"here but declared sort '{decl.sort}' in the signature"
1234
+ )
1235
+ return node.sort
1236
+ if isinstance(node, Constant):
1237
+ decl = sig.constants.get(node.name)
1238
+ if decl is None:
1239
+ violations.append(f"undeclared constant '{node.name}'")
1240
+ return None
1241
+ return decl.sort
1242
+ if isinstance(node, Function):
1243
+ arity = len(node.args)
1244
+ if node.name in _BUILTIN_FUNCS:
1245
+ for arg in node.args:
1246
+ _term_sort(arg, env, sig, violations)
1247
+ return None
1248
+ decl = sig.functions.get(node.name)
1249
+ if decl is None:
1250
+ violations.append(f"undeclared function '{node.name}' (arity {arity})")
1251
+ for arg in node.args:
1252
+ _term_sort(arg, env, sig, violations)
1253
+ return None
1254
+ if decl.arity != arity:
1255
+ violations.append(
1256
+ f"function '{node.name}' expects arity {decl.arity}, used "
1257
+ f"with arity {arity}"
1258
+ )
1259
+ for i, arg in enumerate(node.args):
1260
+ arg_sort = _term_sort(arg, env, sig, violations)
1261
+ if decl.arg_sorts is not None and i < len(decl.arg_sorts):
1262
+ declared = decl.arg_sorts[i]
1263
+ if declared is not None and arg_sort is not None and not (
1264
+ arg_sort == declared or sig.is_subsort(arg_sort, declared)
1265
+ ):
1266
+ violations.append(
1267
+ f"function '{node.name}' argument {i + 1} expects "
1268
+ f"sort '{declared}', got sort '{arg_sort}'"
1269
+ )
1270
+ return decl.result_sort
1271
+ # A TERM node with a genuine FORMULA field (Cardinality/SortedCardinality
1272
+ # set builders): the buried Atoms must go back through the FORMULA
1273
+ # walker or they escape declaredness/arity/sort checking entirely
1274
+ # (review-confirmed: '|{v : Votes(x, v)}| > …' validated as clean
1275
+ # against a Signature declaring Votes at the wrong arity).
1276
+ formula_field = getattr(node, "formula", None)
1277
+ if formula_field is not None:
1278
+ inner_env = env
1279
+ if isinstance(node, (SortedCardinality, Cardinality)):
1280
+ # The set builder binds its variable over the matrix.
1281
+ inner_env = dict(env)
1282
+ inner_env[node.variable.name] = getattr(node, "sort", None)
1283
+ _walk_formula(formula_field, inner_env, sig, violations)
1284
+ for child in node._child_nodes():
1285
+ if child is not formula_field:
1286
+ _term_sort(child, env, sig, violations)
1287
+ return None
1288
+ for child in node._child_nodes():
1289
+ _term_sort(child, env, sig, violations)
1290
+ return None