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,663 @@
1
+ """Canonical form and canonical exact match for NL→FOL evaluation.
2
+
3
+ ``canonicalize`` maps a formula to a normal form that quotients out exactly the
4
+ "free" syntactic differences that should NOT count as a mismatch when comparing
5
+ a predicted FOL formula against a reference one, while staying LOGICALLY
6
+ EQUIVALENT to the input. It normalizes four — and only four — kinds of
7
+ difference:
8
+
9
+ (a) ALPHA-renaming of bound variables (Quantifier / SortedQuantifier bound
10
+ Variable, Lambda param, and the counting-quantifier / set-cardinality
11
+ binders Count and Cardinality, which likewise bind a Variable over their
12
+ matrix): two formulas that differ only in the names of their bound
13
+ variables canonicalize identically. Bound variables are
14
+ renamed to a deterministic scheme (``q0``, ``q1``, … — a single lowercase
15
+ letter plus digits, which is a valid VARIABLE token and therefore
16
+ round-trips through the parser; underscores are deliberately avoided
17
+ because they do not round-trip). The rename is capture-safe.
18
+
19
+ (b) COMMUTATIVITY + ASSOCIATIVITY of the commutative connectives: classical
20
+ And, Or, Iff, Xor and fuzzy WeakConjunction, WeakDisjunction,
21
+ StrongConjunction, StrongDisjunction. Nested same-class chains are
22
+ flattened, each operand is canonicalized, the operands are sorted by a
23
+ deterministic alpha-invariant key, and the group is rebuilt left-folded.
24
+ Identical operands — the same formula up to the names of bound variables,
25
+ so ``P(alice) ∧ P(bob)`` keeps both — are de-duplicated (``P ∧ P`` → ``P``)
26
+ ONLY for the IDEMPOTENT connectives — classical And/Or and fuzzy WeakConjunction (min)
27
+ / WeakDisjunction (max), where ``a ⋆ a ≡ a`` makes removal
28
+ equivalence-preserving. The remaining commutative connectives are NOT
29
+ idempotent (``P ⊕ P ≡ ⊥``, ``P ↔ P ≡ ⊤``, and the Łukasiewicz strong
30
+ conjunction/disjunction t-norm/t-conorm), so their repeated operands are
31
+ kept intact; only flatten + sort applies to them. Implies and
32
+ LukImplication are NOT commutative, so their operand order is preserved.
33
+
34
+ (c) DOUBLE NEGATION: ``Not(Not(x))`` → ``x`` and ``LukNegation(LukNegation(x))``
35
+ → ``x`` (Łukasiewicz negation is involutive: 1−(1−x)=x).
36
+
37
+ It does NOT perform distributivity, CNF/DNF conversion, or any full
38
+ logical-equivalence decision. ``canonicalize`` is a normal form for EXACTLY the
39
+ set {alpha, commutativity, associativity, operand-dedup, double-negation}; it
40
+ sits strictly between raw structural equality (``==`` on frozen nodes) and full
41
+ logical equivalence. Two formulas with the same canonical form are logically
42
+ equivalent, but logically-equivalent formulas need not share a canonical form
43
+ (e.g. ``P → Q`` and ``¬P ∨ Q`` canonicalize differently).
44
+
45
+ REQUIRED INVARIANTS (each is exercised by tests/test_canonical.py):
46
+ P1 equivalence-preserving: ``canonicalize(f)`` is logically equivalent to f.
47
+ P2 idempotent: ``canonicalize(canonicalize(f)) == canonicalize(f)``.
48
+ P3 alpha-invariance: renaming f's bound variables does not change
49
+ ``canonicalize(f)``.
50
+ P4 comm/assoc-invariance: reordering or reassociating the operands of the
51
+ commutative connectives does not change the result.
52
+ P5 double-negation: ``canonicalize(¬¬f) == canonicalize(f)``.
53
+
54
+ Implementation note on the alpha-vs-sort ordering interplay: the operand sort
55
+ key must be invariant under bound-variable renaming, otherwise sorting and
56
+ alpha-renaming would race. The key is therefore built from the operand's tree
57
+ directly (see ``_sort_key``), not from its printed text: every variable occurrence
58
+ is encoded by POSITION (the binder it belongs to), never by its name, and a final
59
+ deterministic alpha-normalization pass then renames the bound variables of the
60
+ fully structured tree. Because the sort key is alpha-invariant and the final pass
61
+ assigns names by binder-encounter order on a now-stable structure, the whole
62
+ pipeline reaches a fixpoint — idempotency (P2) guards against any residual
63
+ ordering bug.
64
+
65
+ The same key decides which operands are DUPLICATES, so it has to tell apart every
66
+ two operands that are not the same formula up to the names of bound variables:
67
+ a constant, a numeral, a sorted constant, a nominal, a predicate term, an agent,
68
+ a group of agents, the type or bound name of a second-order quantifier — every
69
+ field of a node that is not itself a child node goes into the key, by name and
70
+ value. Two operands that differ in any of them are two operands, never one.
71
+
72
+ Which binders are encoded by position (and so renamed by the final pass, P3):
73
+ Quantifier, SortedQuantifier, Lambda, Count, Cardinality, SortedCount,
74
+ SortedCardinality and SlashedExists (with the names of its slash set). Which are
75
+ encoded by NAME: every other binder — SecondOrderQuantifier (its bound predicate
76
+ name is an ordinary field) and the hybrid binder Down (its bound state variable is
77
+ a Nominal, recorded by its name) — and any binder added to the kit later until it
78
+ is taught to ``_alpha``. That is the safe direction: two operands that differ only
79
+ by the renaming of such a binder are kept as two operands, which can cost a match
80
+ but never produces a wrong one. Their bound names are not renamed, so P3 does not
81
+ extend to them.
82
+ """
83
+
84
+ import dataclasses
85
+ from typing import Any, List
86
+
87
+ from unicode_logic_kit.fol.nodes import (
88
+ Node, Variable,
89
+ And, Or, Xor, Iff, Implies,
90
+ Not, Quantifier, SortedQuantifier,
91
+ Count, Cardinality, SortedCount, SortedCardinality,
92
+ SlashedExists,
93
+ WeakConjunction, WeakDisjunction, StrongConjunction, StrongDisjunction,
94
+ LukNegation, LukImplication, LukEquivalence,
95
+ Lambda, LambdaVar,
96
+ free_variables,
97
+ )
98
+
99
+
100
+ # Commutative connective classes: associative + commutative, so their operand
101
+ # groups may be flattened, sorted, and rebuilt left-folded. Implies /
102
+ # LukImplication and the (non-commutative) negations are deliberately excluded.
103
+ _COMMUTATIVE = (
104
+ And, Or, Xor, Iff,
105
+ WeakConjunction, WeakDisjunction, StrongConjunction, StrongDisjunction,
106
+ )
107
+
108
+ # IDEMPOTENT commutative connectives — exactly those for which ``a ⋆ a ≡ a``, so
109
+ # that de-duplicating repeated operands is equivalence-preserving:
110
+ # And / Or (classical ∧ ∨ are idempotent)
111
+ # WeakConjunction / WeakDisjunction (fuzzy min / max are idempotent)
112
+ # The remaining commutative connectives are NOT idempotent and MUST NOT be
113
+ # de-duplicated:
114
+ # Xor / StrongDisjunction (⊕): a ⊕ a ≡ ⊥ (classical) / x⊕x = min{1,2x} (fuzzy)
115
+ # Iff (↔): a ↔ a ≡ ⊤
116
+ # StrongConjunction (⊗): x⊗x = max{0,2x−1} ≢ x
117
+ # Removing a duplicate from any of these would change the truth value, breaking
118
+ # the equivalence-preservation invariant P1.
119
+ _IDEMPOTENT = (And, Or, WeakConjunction, WeakDisjunction)
120
+
121
+ # Involutive negations: ``op(op(x)) == x``.
122
+ _INVOLUTIVE_NEG = (Not, LukNegation)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Capture-safe alpha-normalization
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def _alpha_normalize(node: Node) -> Node:
130
+ """Rename every bound variable to a canonical ``q0``, ``q1``, … name.
131
+
132
+ Performs a deterministic pre-order traversal: each binder encountered
133
+ (Quantifier / SortedQuantifier / Count / Cardinality over a Variable, Lambda
134
+ over a LambdaVar) consumes the next FREE-name-avoiding ``q``-index and its
135
+ bound occurrences are rewritten to a fresh canonical name of the matching kind
136
+ (Variable for quantifiers/counts/cardinalities, LambdaVar for lambdas). An
137
+ environment maps each currently-in-scope original bound name to its canonical
138
+ replacement; inner binders shadow outer ones of the same name, exactly as
139
+ scope demands.
140
+
141
+ The scheme is capture-safe in BOTH directions. (i) An inner binder never
142
+ reuses an enclosing canonical name, because names are minted from a single
143
+ monotonic counter. (ii) A bound variable is never renamed onto a FREE variable
144
+ of the matrix: ``avoid`` holds every free name in ``node`` (a ``q``-name is a
145
+ legal VARIABLE token, so a formula may legitimately contain a free ``q0``), and
146
+ the mint skips any ``q``-index already in ``avoid``. Without (ii) the disjoint
147
+ formulas ``∃x P(x)`` and ``∃x P(q0)`` would both collapse to ``∃q0 P(q0)`` and
148
+ wrongly compare equal — a false positive that breaks equivalence-preservation
149
+ (P1). Free variables themselves are left untouched. The result is invariant
150
+ under any alpha-renaming of ``node`` (P3; renaming bound variables cannot change
151
+ the free-name set) and is idempotent (P2; a second pass sees the same free
152
+ names and the same binder-encounter order, so it assigns identical names).
153
+ """
154
+ counter = [0]
155
+ avoid = frozenset(v.name for v in free_variables(node))
156
+ return _alpha(node, {}, {}, counter, avoid)
157
+
158
+
159
+ def _fresh_canonical_name(counter: list, avoid) -> str:
160
+ """Return the next ``q``-index name that does NOT collide with a free name.
161
+
162
+ Advances the monotonic ``counter`` past any ``q``-index present in ``avoid``
163
+ (the set of names free in the whole formula), so a minted bound name is always
164
+ disjoint from every free variable — the capture-safety guarantee (ii) in
165
+ :func:`_alpha_normalize`. Distinctness among minted names is preserved because
166
+ every successful mint consumes a strictly larger counter value.
167
+ """
168
+ name = f"q{counter[0]}"
169
+ counter[0] += 1
170
+ while name in avoid:
171
+ name = f"q{counter[0]}"
172
+ counter[0] += 1
173
+ return name
174
+
175
+
176
+ def _alpha(node: Node, var_env: dict, lam_env: dict, counter: list, avoid) -> Node:
177
+ """Recurse, rewriting bound occurrences per the two environments.
178
+
179
+ ``var_env`` maps an in-scope original logical-variable name to its canonical
180
+ Variable; ``lam_env`` does the same for lambda-bound names → canonical
181
+ LambdaVar. The environments are copied (never mutated) when entering a
182
+ binder, so siblings never see each other's bindings. ``avoid`` is the frozen
183
+ set of names free in the whole formula, threaded unchanged so every fresh
184
+ canonical name skips them (see :func:`_fresh_canonical_name`).
185
+ """
186
+ if isinstance(node, Variable):
187
+ return var_env.get(node.name, node)
188
+ if isinstance(node, LambdaVar):
189
+ return lam_env.get(node.name, node)
190
+
191
+ if isinstance(node, Quantifier):
192
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
193
+ inner = dict(var_env)
194
+ inner[node.variable.name] = fresh
195
+ return Quantifier(node.type, fresh, _alpha(node.formula, inner, lam_env, counter, avoid))
196
+
197
+ if isinstance(node, SortedQuantifier):
198
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
199
+ inner = dict(var_env)
200
+ inner[node.variable.name] = fresh
201
+ return SortedQuantifier(
202
+ node.type, fresh, node.sort,
203
+ _alpha(node.formula, inner, lam_env, counter, avoid),
204
+ )
205
+
206
+ if isinstance(node, Lambda):
207
+ fresh = LambdaVar(_fresh_canonical_name(counter, avoid))
208
+ inner = dict(lam_env)
209
+ inner[node.param.name] = fresh
210
+ return Lambda(fresh, _alpha(node.body, var_env, inner, counter, avoid))
211
+
212
+ # Count / Cardinality also bind a logical Variable over their matrix (Count
213
+ # additionally carries the op code and the symbolic bound n, both copied
214
+ # verbatim — neither contains a variable). They rename like a quantifier.
215
+ if isinstance(node, Count):
216
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
217
+ inner = dict(var_env)
218
+ inner[node.variable.name] = fresh
219
+ return Count(node.op, node.n, fresh,
220
+ _alpha(node.formula, inner, lam_env, counter, avoid))
221
+
222
+ if isinstance(node, Cardinality):
223
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
224
+ inner = dict(var_env)
225
+ inner[node.variable.name] = fresh
226
+ return Cardinality(fresh, _alpha(node.formula, inner, lam_env, counter, avoid))
227
+
228
+ # The sorted counterparts bind a Variable too; the sort string is copied verbatim.
229
+ if isinstance(node, SortedCount):
230
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
231
+ inner = dict(var_env)
232
+ inner[node.variable.name] = fresh
233
+ return SortedCount(node.op, node.n, fresh, node.sort,
234
+ _alpha(node.formula, inner, lam_env, counter, avoid))
235
+
236
+ if isinstance(node, SortedCardinality):
237
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
238
+ inner = dict(var_env)
239
+ inner[node.variable.name] = fresh
240
+ return SortedCardinality(fresh, node.sort,
241
+ _alpha(node.formula, inner, lam_env, counter, avoid))
242
+
243
+ # The IF slashed existential binds its variable AND carries a slash set of
244
+ # NAMES referring to enclosing binders: those names follow the enclosing
245
+ # binders' canonical renames (looked up in var_env), so alpha-variant slash
246
+ # annotations canonicalize identically.
247
+ if isinstance(node, SlashedExists):
248
+ fresh = Variable(_fresh_canonical_name(counter, avoid))
249
+ slashed = tuple(var_env[n].name if n in var_env else n
250
+ for n in node.slashed)
251
+ inner = dict(var_env)
252
+ inner[node.variable.name] = fresh
253
+ return SlashedExists(fresh, slashed,
254
+ _alpha(node.formula, inner, lam_env, counter, avoid))
255
+
256
+ # Non-binder structural node (Atom, Function, Not, binary connectives,
257
+ # Measure, Contrast, Application, …): recurse into every child with the same
258
+ # environments.
259
+ return node.map_children(lambda c: _alpha(c, var_env, lam_env, counter, avoid))
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Structural canonicalization (comm/assoc/dedupe/double-negation)
264
+ # ---------------------------------------------------------------------------
265
+
266
+ def _flatten(node: Node, cls: type) -> List[Node]:
267
+ """Collect the operands of a maximal same-class commutative chain.
268
+
269
+ Walks the left/right spine of ``node`` (assumed an instance of ``cls``),
270
+ descending into any nested node of the same class so that, e.g.,
271
+ ``(P ∧ Q) ∧ R`` and ``P ∧ (Q ∧ R)`` both yield ``[P, Q, R]``. Operands of a
272
+ different class terminate the chain and are returned as-is (not yet
273
+ canonicalized — the caller canonicalizes each).
274
+ """
275
+ out: List[Node] = []
276
+ for side in (node.left, node.right):
277
+ if isinstance(side, cls):
278
+ out.extend(_flatten(side, cls))
279
+ else:
280
+ out.append(side)
281
+ return out
282
+
283
+
284
+ def _canon_operands(node: Node, cls: type, levels: dict) -> List[Node]:
285
+ """Return the canonicalized operands of a commutative ``cls`` group.
286
+
287
+ Flattens the raw chain, canonicalizes each operand (threading the enclosing
288
+ binder ``levels`` map), then RE-FLATTENS any operand that itself canonicalized
289
+ into the same class. The re-flatten step is essential: a double-negation
290
+ collapse can turn an operand such as ``¬¬(P ∨ Q)`` into a bare ``P ∨ Q`` (the
291
+ same class as its parent ``∨``), and that newly-exposed nested group must be
292
+ absorbed into the parent chain — otherwise the duplicate ``Q`` in
293
+ ``¬¬(P ∨ Q) ∨ Q`` would survive a single pass (breaking idempotency P2 and
294
+ associativity-invariance P4).
295
+ """
296
+ out: List[Node] = []
297
+ for raw in _flatten(node, cls):
298
+ canon = _structural(raw, levels)
299
+ if isinstance(canon, cls):
300
+ out.extend(_flatten(canon, cls))
301
+ else:
302
+ out.append(canon)
303
+ return out
304
+
305
+
306
+ def _scope_name(node: Any):
307
+ """Key of a variable occurrence or of a lambda parameter in the scope maps.
308
+
309
+ A logical variable and a lambda variable live in two name spaces (``_alpha``
310
+ keeps one environment for each): ``∀x`` binds the variable ``x`` and leaves a
311
+ lambda variable ``x`` alone, so the two must not share a scope entry. A plain
312
+ name is a logical variable.
313
+ """
314
+ return ("lambda", node.name) if isinstance(node, LambdaVar) else node.name
315
+
316
+
317
+ def _plain(value) -> str:
318
+ """Text of a field value that is the same for equal values in every run.
319
+
320
+ ``repr`` already is, except for a set, whose element order depends on the
321
+ hash seed of the process and on how the set was built; its elements are sorted.
322
+ """
323
+ if isinstance(value, (set, frozenset)):
324
+ return type(value).__name__ + "{" + ", ".join(sorted(_plain(v) for v in value)) + "}"
325
+ return repr(value)
326
+
327
+
328
+ def _loose_fields(node: Node, skip: tuple = ()) -> List[str]:
329
+ """One token per part of ``node`` that is NOT a child node, except the fields in ``skip``.
330
+
331
+ A scalar field gives ``name=value``; a sequence field gives ``name#length`` and,
332
+ for every item that is not a node, ``name[position]=value`` (the nodes among
333
+ the items are the node's children, which the caller visits). The fields are
334
+ read from the node itself, so a node class added to the kit later is covered
335
+ without being listed here: the name of a ``Constant``, the value of a
336
+ ``Number``, name and sort of a ``SortedConstant``, a string agent, the type and
337
+ bound name of a second-order quantifier, and so on. Every node class of the kit
338
+ is a dataclass — ``Node._child_nodes`` and ``Node.map_children``, which the
339
+ canonical form is built on, read ``dataclasses.fields`` as well.
340
+ """
341
+ tokens: List[str] = []
342
+ for f in dataclasses.fields(node): # type: ignore[arg-type]
343
+ if f.name in skip:
344
+ continue
345
+ value = getattr(node, f.name)
346
+ if isinstance(value, Node):
347
+ continue
348
+ if isinstance(value, (list, tuple)):
349
+ tokens.append(f"{f.name}#{len(value)}")
350
+ tokens.extend(f"{f.name}[{i}]={_plain(item)}"
351
+ for i, item in enumerate(value) if not isinstance(item, Node))
352
+ else:
353
+ tokens.append(f"{f.name}={_plain(value)}")
354
+ return tokens
355
+
356
+
357
+ def _sort_key(operand: Node, levels: dict) -> tuple:
358
+ """Deterministic sort key, invariant under bound-variable renaming AND under
359
+ commutative reordering of sibling operands, and equal for two operands ONLY
360
+ when they are the same formula up to the names of their bound variables.
361
+
362
+ The commutative sort runs bottom-up *before* the final whole-tree
363
+ ``_alpha_normalize`` pass renames variables bound by ENCLOSING binders. A
364
+ name-sensitive key would therefore order operands differently on the first
365
+ pass (enclosing binders still named ``x``, ``w``) than on the second (renamed
366
+ ``q0``, ``q1``) — the classic alpha-vs-sort non-idempotence trap (P2). We
367
+ avoid it by encoding every variable occurrence by POSITION, not name:
368
+
369
+ * a variable bound by an ENCLOSING binder is encoded by that binder's LEVEL
370
+ (its depth from the root, supplied in ``levels``). The level is invariant
371
+ under commutative reordering of siblings — reordering operands never
372
+ changes any binder's depth — so two operands that reference *different*
373
+ enclosing binders (e.g. ``P(x)`` vs ``P(y)`` under ``∀y ∀x``) get
374
+ DIFFERENT keys and a stable order, fixing comm/assoc-invariance (P4);
375
+ * a variable bound INSIDE the operand is encoded by a local de-Bruijn-style
376
+ index assigned in binder-encounter order;
377
+ * a genuinely free variable is encoded by its name (such variables are not
378
+ renamed by canonicalize, so the name is already stable).
379
+
380
+ Because the key depends on no renamable name, the ordering is identical
381
+ across passes (P2) and identical for any alpha-variant of the input (P3).
382
+
383
+ The key is a pair. Its first part, the SKELETON, is a string that holds the
384
+ class of every node, the predicate of an ``Atom``, the name of a ``Function``,
385
+ and the binder and variable encodings above. Its second part, the DETAIL, is a
386
+ tuple of tokens that holds all of that again as separate items, and in addition
387
+ every field of every node that is no child node (see :func:`_loose_fields`):
388
+ without them ``P(alice)`` and ``P(bob)`` would have one key, and so would
389
+ ``P(1)`` and ``P(2)``, or ``K_alice φ`` and ``K_bob φ``, and one of two such
390
+ conjuncts would be removed as a duplicate. The skeleton comes first so that
391
+ operands it tells apart sort exactly as they always did; the detail only
392
+ decides between operands whose skeletons are equal, and being a tuple of
393
+ items (not a joined string) it stays exact for a name that contains the
394
+ separator.
395
+
396
+ A binder this function does not know (a second-order quantifier, the hybrid
397
+ ``Down``, a binder added later) is read like any other node: its bound name is
398
+ one of the fields that go into the detail (for ``Down`` it is the ``Nominal``
399
+ child, which is recorded by its name). Two operands that differ only by the
400
+ renaming of such a binder therefore have different keys: a match that
401
+ ``canonicalize`` does not make, never a wrong one.
402
+ """
403
+ skeleton: List[str] = []
404
+ detail: List[str] = []
405
+ local_depth = [0] # monotonic binder counter; immune to shadowing
406
+
407
+ def rec(n: Node, local: dict) -> None:
408
+ cls_name = type(n).__name__
409
+ if isinstance(n, (Variable, LambdaVar)):
410
+ scope = _scope_name(n)
411
+ if scope in local:
412
+ where = f"b{local[scope]}" # operand-bound
413
+ elif scope in levels:
414
+ where = f"L{levels[scope]}" # enclosing-bound
415
+ else:
416
+ where = f"v:{n.name}" # genuinely free
417
+ skeleton.append(where)
418
+ # The class keeps a free variable apart from a free lambda variable of
419
+ # the same spelling.
420
+ detail.extend((cls_name, where))
421
+ return
422
+ if isinstance(n, (Quantifier, SortedQuantifier)):
423
+ inner = dict(local)
424
+ inner[n.variable.name] = local_depth[0]
425
+ local_depth[0] += 1
426
+ sort = getattr(n, "sort", "")
427
+ skeleton.append(f"Q{n.type}:{sort}")
428
+ detail.append(cls_name)
429
+ detail.extend(_loose_fields(n, ("variable", "formula")))
430
+ rec(n.formula, inner)
431
+ return
432
+ if isinstance(n, Lambda):
433
+ inner = dict(local)
434
+ inner[_scope_name(n.param)] = local_depth[0]
435
+ local_depth[0] += 1
436
+ skeleton.append("LAM")
437
+ detail.append(cls_name)
438
+ detail.extend(_loose_fields(n, ("param", "body")))
439
+ rec(n.body, inner)
440
+ return
441
+ if isinstance(n, Count):
442
+ inner = dict(local)
443
+ inner[n.variable.name] = local_depth[0]
444
+ local_depth[0] += 1
445
+ # op and n distinguish ∃≥3 from ∃≤3 / ∃≥5; neither is renamable, so
446
+ # the key stays invariant under bound-variable renaming (P3).
447
+ skeleton.append(f"CNT{n.op}:{n.n.value}")
448
+ detail.append(cls_name)
449
+ detail.extend(_loose_fields(n, ("variable", "formula")))
450
+ detail.append(f"n={_plain(n.n.value)}")
451
+ rec(n.formula, inner)
452
+ return
453
+ if isinstance(n, Cardinality):
454
+ inner = dict(local)
455
+ inner[n.variable.name] = local_depth[0]
456
+ local_depth[0] += 1
457
+ skeleton.append("CARD")
458
+ detail.append(cls_name)
459
+ detail.extend(_loose_fields(n, ("variable", "formula")))
460
+ rec(n.formula, inner)
461
+ return
462
+ if isinstance(n, SortedCount):
463
+ inner = dict(local)
464
+ inner[n.variable.name] = local_depth[0]
465
+ local_depth[0] += 1
466
+ # op, n AND sort are all significant and non-renamable.
467
+ skeleton.append(f"SCNT{n.op}:{n.n.value}:{n.sort}")
468
+ detail.append(cls_name)
469
+ detail.extend(_loose_fields(n, ("variable", "formula")))
470
+ detail.append(f"n={_plain(n.n.value)}")
471
+ rec(n.formula, inner)
472
+ return
473
+ if isinstance(n, SortedCardinality):
474
+ inner = dict(local)
475
+ inner[n.variable.name] = local_depth[0]
476
+ local_depth[0] += 1
477
+ skeleton.append(f"SCARD:{n.sort}")
478
+ detail.append(cls_name)
479
+ detail.extend(_loose_fields(n, ("variable", "formula")))
480
+ rec(n.formula, inner)
481
+ return
482
+ if isinstance(n, SlashedExists):
483
+ # Slash names are encoded positionally like variable occurrences
484
+ # (operand-bound by local index, enclosing-bound by level), so the
485
+ # key stays invariant under bound-variable renaming (P3).
486
+ enc = []
487
+ for s in n.slashed:
488
+ if s in local:
489
+ enc.append(f"b{local[s]}")
490
+ elif s in levels:
491
+ enc.append(f"L{levels[s]}")
492
+ else:
493
+ enc.append(f"v:{s}")
494
+ inner = dict(local)
495
+ inner[n.variable.name] = local_depth[0]
496
+ local_depth[0] += 1
497
+ skeleton.append("SLEX:" + ",".join(enc))
498
+ detail.append(cls_name)
499
+ detail.extend(_loose_fields(n, ("variable", "formula", "slashed")))
500
+ detail.append(f"slashed#{len(enc)}")
501
+ detail.extend(enc)
502
+ rec(n.formula, inner)
503
+ return
504
+ skeleton.append(cls_name)
505
+ if cls_name == "Atom":
506
+ skeleton.append(n.predicate)
507
+ elif cls_name == "Function":
508
+ skeleton.append(n.name)
509
+ detail.append(cls_name)
510
+ detail.extend(_loose_fields(n))
511
+ for child in n._child_nodes():
512
+ rec(child, local)
513
+ skeleton.append("/")
514
+
515
+ rec(operand, {})
516
+ return "|".join(skeleton), tuple(detail)
517
+
518
+
519
+ # NOTE on de-duplication: the dedup identity key is ``_sort_key`` itself. Two
520
+ # operands sharing the same enclosing scope are logically identical iff they are
521
+ # alpha-equivalent with the variables bound by ENCLOSING binders held fixed —
522
+ # which is EXACTLY what ``_sort_key`` encodes (enclosing-bound variables by
523
+ # level, operand-bound by local index, genuinely-free by name), together with
524
+ # every other field of every node (constant names, numeral values, sorts, agents,
525
+ # …) compared as it is, so two operands that differ in any of them are never taken
526
+ # for duplicates. Crucially this is capture-proof: a string built from
527
+ # ``_alpha_normalize(operand)`` would, on a second pass, capture a free variable
528
+ # that the first pass had renamed to ``q0`` under a freshly-minted bound ``q0``
529
+ # (e.g. the shadowing case ``∃x R(x) ∧ ∃z R(x)``), wrongly collapsing two
530
+ # NON-equivalent operands and breaking both idempotency (P2) and
531
+ # equivalence-preservation (P1). Keying dedup on ``_sort_key`` avoids any
532
+ # name-based capture entirely.
533
+
534
+
535
+ def _structural(node: Node, levels: dict) -> Node:
536
+ """Apply comm/assoc/dedupe and double-negation, bottom-up.
537
+
538
+ ``levels`` maps each variable bound by an ENCLOSING binder to that binder's
539
+ level (depth from the root); it is extended when recursing through a
540
+ quantifier or lambda and consulted by ``_sort_key`` so the commutative sort
541
+ is stable under bound-variable renaming (see ``_sort_key``).
542
+
543
+ Children are canonicalized first; then double negations collapse and
544
+ commutative groups are flattened, their operands sorted by ``_sort_key`` and
545
+ (for the idempotent connectives only) de-duplicated, and the group is rebuilt
546
+ left-folded. Bound-variable renaming is deferred to a single final pass (see
547
+ ``canonicalize``).
548
+ """
549
+ # Double negation: collapse op(op(x)) for the involutive negations.
550
+ for neg_cls in _INVOLUTIVE_NEG:
551
+ if isinstance(node, neg_cls) and isinstance(node.formula, neg_cls):
552
+ return _structural(node.formula.formula, levels)
553
+
554
+ if isinstance(node, _COMMUTATIVE):
555
+ cls = type(node)
556
+ operands = _canon_operands(node, cls, levels)
557
+ operands.sort(key=lambda op: _sort_key(op, levels))
558
+ if isinstance(node, _IDEMPOTENT):
559
+ # Dedupe operands ONLY for the idempotent connectives (∧ ∨ and fuzzy
560
+ # min/max), where P ⋆ P ≡ P makes removal equivalence-preserving.
561
+ # Dedup keys on ``_sort_key`` (level-encoded, capture-proof — see the
562
+ # note above), so alpha-equivalent operands like (∀x P(x)) and
563
+ # (∀y P(y)) collapse while genuinely distinct operands like R(x) and
564
+ # R(w) do NOT. Xor / Iff / StrongConjunction / StrongDisjunction are
565
+ # NOT idempotent and are left intact (deduping them would break P1).
566
+ seen: set = set()
567
+ deduped: List[Node] = []
568
+ for op in operands:
569
+ key = _sort_key(op, levels)
570
+ if key not in seen:
571
+ seen.add(key)
572
+ deduped.append(op)
573
+ operands = deduped
574
+ result = operands[0]
575
+ for op in operands[1:]:
576
+ result = cls(result, op)
577
+ return result
578
+
579
+ # Binders extend the enclosing-level map for their body, then keep their bound
580
+ # variable verbatim (it is renamed by the final _alpha_normalize pass). The
581
+ # new level is one past the deepest existing one — NOT ``len(levels)`` — so a
582
+ # shadowing binder (e.g. the inner ``∀z`` in ``∀z ∀z ∀w …``) overwrites its
583
+ # name's entry yet still advances the depth, keeping every level distinct.
584
+ next_level = (max(levels.values()) + 1) if levels else 0
585
+ if isinstance(node, Quantifier):
586
+ inner = dict(levels)
587
+ inner[node.variable.name] = next_level
588
+ return Quantifier(node.type, node.variable, _structural(node.formula, inner))
589
+ if isinstance(node, SortedQuantifier):
590
+ inner = dict(levels)
591
+ inner[node.variable.name] = next_level
592
+ return SortedQuantifier(node.type, node.variable, node.sort,
593
+ _structural(node.formula, inner))
594
+ if isinstance(node, Lambda):
595
+ inner = dict(levels)
596
+ inner[_scope_name(node.param)] = next_level
597
+ return Lambda(node.param, _structural(node.body, inner))
598
+ if isinstance(node, Count):
599
+ inner = dict(levels)
600
+ inner[node.variable.name] = next_level
601
+ return Count(node.op, node.n, node.variable,
602
+ _structural(node.formula, inner))
603
+ if isinstance(node, Cardinality):
604
+ inner = dict(levels)
605
+ inner[node.variable.name] = next_level
606
+ return Cardinality(node.variable, _structural(node.formula, inner))
607
+ if isinstance(node, SortedCount):
608
+ inner = dict(levels)
609
+ inner[node.variable.name] = next_level
610
+ return SortedCount(node.op, node.n, node.variable, node.sort,
611
+ _structural(node.formula, inner))
612
+ if isinstance(node, SortedCardinality):
613
+ inner = dict(levels)
614
+ inner[node.variable.name] = next_level
615
+ return SortedCardinality(node.variable, node.sort,
616
+ _structural(node.formula, inner))
617
+ if isinstance(node, SlashedExists):
618
+ inner = dict(levels)
619
+ inner[node.variable.name] = next_level
620
+ return SlashedExists(node.variable, node.slashed,
621
+ _structural(node.formula, inner))
622
+
623
+ # Any other node (Implies, LukImplication, Not/LukNegation without a nested
624
+ # negation, Contrast, Measure, atoms, terms, Application): keep its shape,
625
+ # recursing structurally into the children. Operand order of the
626
+ # non-commutative connectives is thereby preserved.
627
+ return node.map_children(lambda c: _structural(c, levels))
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Public API
632
+ # ---------------------------------------------------------------------------
633
+
634
+ def canonicalize(node: Node) -> Node:
635
+ """Return the canonical form of ``node`` (see the module docstring).
636
+
637
+ The result is logically equivalent to ``node`` (P1) and quotients out
638
+ exactly alpha-renaming (P3), commutativity/associativity (P4), operand
639
+ duplication, and double negation (P5). It is idempotent (P2).
640
+
641
+ Pipeline: first ``_structural`` rewrites the tree under comm/assoc/dedupe and
642
+ double-negation (sorting commutative operands by a key invariant under
643
+ bound-variable renaming and sibling reordering — see ``_sort_key``), then a
644
+ single ``_alpha_normalize`` pass assigns canonical bound-variable names over
645
+ the now-stable structure.
646
+ """
647
+ return _alpha_normalize(_structural(node, {}))
648
+
649
+
650
+ def exact_match(pred: Node, ref: Node, canonical: bool = True) -> bool:
651
+ """Return whether ``pred`` matches ``ref``.
652
+
653
+ When ``canonical`` is True (the default), the comparison is up to canonical
654
+ form: ``canonicalize(pred) == canonicalize(ref)``, so differences in
655
+ bound-variable names, commutative-operand order/association, duplicated
656
+ operands, and double negation do not cause a mismatch. When ``canonical`` is
657
+ False, the comparison is raw structural equality (``pred == ref``); because
658
+ the nodes are frozen and hashable with tuple-normalized argument lists, this
659
+ already ignores list-vs-tuple construction but nothing else.
660
+ """
661
+ if canonical:
662
+ return canonicalize(pred) == canonicalize(ref)
663
+ return pred == ref