unicode-fol-kit 0.2.1__tar.gz → 0.3.1__tar.gz

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 (23) hide show
  1. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/PKG-INFO +116 -4
  2. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/README.md +115 -3
  3. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/pyproject.toml +1 -1
  4. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/__init__.py +8 -2
  5. unicode_fol_kit-0.3.1/unicode_fol_kit/atp/__init__.py +9 -0
  6. unicode_fol_kit-0.3.1/unicode_fol_kit/atp/z3_models.py +45 -0
  7. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/__init__.py +2 -0
  8. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_fol_nodes.py +104 -0
  9. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_msfl_nodes.py +290 -0
  10. unicode_fol_kit-0.3.1/unicode_fol_kit/fol/normalforms.py +282 -0
  11. unicode_fol_kit-0.2.1/unicode_fol_kit/atp/__init__.py +0 -4
  12. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/.gitignore +0 -0
  13. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/LICENSE +0 -0
  14. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/prover9_entailment.py +0 -0
  15. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/z3_equivalence.py +0 -0
  16. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fl.lark +0 -0
  17. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fol.lark +0 -0
  18. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfl.lark +0 -0
  19. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfol.lark +0 -0
  20. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/terminals.lark +0 -0
  21. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/msflparser.py +0 -0
  22. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/naming.py +0 -0
  23. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/nodes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unicode-fol-kit
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Parser and toolkit for first-order logic formulas using Unicode operators
5
5
  Project-URL: Repository, https://github.com/fvossel/unicode-fol-kit
6
6
  Project-URL: Issues, https://github.com/fvossel/unicode-fol-kit/issues
@@ -36,7 +36,14 @@ A Python toolkit for parsing and working with first-order logic (FOL) formulas w
36
36
  - **Reductions** — `to_msfol()` lowers Łukasiewicz operators to classical nodes; `to_fol()` further eliminates sorts via relativisation
37
37
  - **Serialisation** — convert formulas to/from JSON dictionaries; round-trip safe
38
38
  - **Tree view** — render any formula as a readable ASCII tree
39
+ - **Unicode round-trip** — `to_unicode_str()` renders any node back to a parseable Unicode formula; re-parsing in the matching mode yields a structurally equal AST
40
+ - **LaTeX export** — `to_latex()` renders any node as LaTeX math-mode markup, with the same precedence-aware parenthesisation as the Unicode renderer
41
+ - **Normal forms** — `to_nnf()`, `to_pnf()`, `to_cnf()` (equivalence-preserving), and `skolemize()` (satisfiability-preserving) for classical FOL
42
+ - **Horn check** — `is_horn()` reports whether a formula's clausal form consists of Horn clauses
43
+ - **Traversal API** — `walk()`, `subformulas()`, `atoms()`, `variables()`, `count()`, `depth()` on every node
44
+ - **Graphviz export** — `to_dot()` renders the AST as a Graphviz DOT digraph
39
45
  - **Z3 export** — translate formulas to Z3 expressions for SMT solving
46
+ - **Satisfiability / validity / models** — `is_satisfiable()`, `is_valid()`, and `get_model()` (counterexample extraction) via Z3
40
47
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
41
48
  - **TPTP export** — translate formulas to TPTP syntax
42
49
  - **Equivalence checking** — check if two formulas are logically equivalent via Z3
@@ -154,14 +161,67 @@ print(formula.tree_str())
154
161
  # └── Variable: x
155
162
  ```
156
163
 
164
+ ### Round-trip to Unicode
165
+
166
+ `to_unicode_str()` is the inverse of parsing: it renders any node back to a Unicode formula string. Re-parsing that string in the same mode reproduces a structurally equal AST. The renderer is precedence-aware and only inserts the parentheses the grammar requires — including the no-mixing rule for same-level connectives and the tight-binding rule for quantifiers.
167
+
168
+ ```python
169
+ parser = MSFLParser()
170
+
171
+ ast = parser.parse("∀x P(x) ∧ Q(x)")
172
+ ast.to_unicode_str() # '∀x P(x) ∧ Q(x)'
173
+ parser.parse(ast.to_unicode_str()) == ast # True
174
+
175
+ # Precedence-driven parentheses are reconstructed, not the original spelling:
176
+ parser.parse("((P(x) ∧ Q(x)))").to_unicode_str() # 'P(x) ∧ Q(x)'
177
+ parser.parse("P(x) ∧ (Q(x) ∨ R(x))").to_unicode_str() # 'P(x) ∧ (Q(x) ∨ R(x))'
178
+ ```
179
+
180
+ Available on every node, so subformulas render too. The output targets parseable
181
+ ASTs; alpha-renamed variables introduced by `beta_reduce` (e.g. `x_0`) are not
182
+ valid surface tokens and will not round-trip.
183
+
157
184
  ### Exporting to other formats
158
185
 
159
186
  ```python
160
187
  formula.to_prover9() # '(all x (Human(x) -> Mortal(x)))'
161
188
  formula.to_tptp() # '(![X]: (human(X) => mortal(X)))'
189
+ formula.to_latex() # '\\forall x\\, (Human(x) \\rightarrow Mortal(x))'
162
190
  formula.to_dict() # JSON-serialisable dict
163
191
  ```
164
192
 
193
+ `to_latex()` uses the same precedence-aware parenthesisation as `to_unicode_str()`. Sorts render as `\forall x{:}\mathrm{Human}\,`; strong Łukasiewicz operators as `\otimes` / `\oplus`. Symbol and predicate names are emitted verbatim (no `\mathrm` wrapping).
194
+
195
+ ### Traversal and inspection
196
+
197
+ Every node exposes a small traversal API:
198
+
199
+ ```python
200
+ f = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
201
+
202
+ list(f.walk()) # pre-order: every node and descendant
203
+ f.subformulas() # every sub-node that is a formula (terms excluded)
204
+ f.atoms() # [Atom("Human", …), Atom("Mortal", …)]
205
+ f.variables() # {Variable("x")} — set of logical variables (free + bound)
206
+ f.count() # total node count
207
+ f.count(Atom) # nodes of a given type
208
+ f.depth() # tree height (a leaf has depth 1)
209
+ ```
210
+
211
+ ### Graphviz export
212
+
213
+ ```python
214
+ print(f.to_dot())
215
+ # digraph AST {
216
+ # node [shape=box];
217
+ # n0 [label="∀ x"];
218
+ # n1 [label="→"];
219
+ # ...
220
+ # }
221
+ ```
222
+
223
+ `to_dot()` mirrors the `tree_str()` view (the quantifier's bound variable is folded into its node label). Pipe the output to `dot -Tpng` to render an image.
224
+
165
225
  ### Serialisation
166
226
 
167
227
  ```python
@@ -271,6 +331,38 @@ classical = to_fol(formula)
271
331
  classical_with_facts = to_fol(formula, include_sort_facts=True)
272
332
  ```
273
333
 
334
+ ### Normal forms
335
+
336
+ `to_nnf()`, `to_pnf()`, `to_cnf()`, and `skolemize()` operate on classical FOL. They accept FOL, MSFOL, MSFL, and FL inputs — sorts and Łukasiewicz operators are reduced via `to_fol()` first. (Lambda terms must be beta-reduced and lambda-eliminated beforehand.)
337
+
338
+ ```python
339
+ from unicode_fol_kit import MSFLParser, to_nnf, to_pnf, to_cnf, skolemize
340
+
341
+ parser = MSFLParser()
342
+
343
+ to_nnf(parser.parse("P → Q")) # eliminates → ↔ ⊕, pushes ¬ down to atoms
344
+ to_pnf(parser.parse("∀x P(x) ∧ ∃y Q(y)")) # quantifier prefix + quantifier-free matrix
345
+ to_cnf(parser.parse("P ∨ (Q ∧ R)")) # matrix as a conjunction of clauses
346
+ skolemize(parser.parse("∀x ∃y Loves(x, y)"))
347
+ # ∀v0 Loves(v0, sk0(v0)) — the existential becomes a Skolem function of x
348
+ ```
349
+
350
+ - `to_nnf` / `to_pnf` / `to_cnf` are **equivalence-preserving**: the result is logically equivalent to the (classical) input.
351
+ - `skolemize` is **satisfiability-preserving** (not equivalence-preserving): existentials are replaced by Skolem terms over the universals in scope, and the universal prefix is retained. Bound variables are standardised apart (renamed to fresh `v0, v1, …`); Skolem symbols are named `sk0, sk1, …`.
352
+
353
+ ### Horn check
354
+
355
+ `is_horn()` reports whether a formula's clausal form consists of Horn clauses — each clause has at most one positive literal. The formula is skolemised, its universal prefix dropped, and the matrix put into CNF before the clauses are checked.
356
+
357
+ ```python
358
+ from unicode_fol_kit import MSFLParser, is_horn
359
+
360
+ parser = MSFLParser()
361
+ is_horn(parser.parse("∀x (Body(x) → Head(x))")) # True (definite clause)
362
+ is_horn(parser.parse("P → (Q ∧ R)")) # True (splits into two Horn clauses)
363
+ is_horn(parser.parse("P → (Q ∨ R)")) # False (clause has two positive literals)
364
+ ```
365
+
274
366
  ### Equivalence checking (Z3)
275
367
 
276
368
  ```python
@@ -283,6 +375,26 @@ f2 = parser.parse("¬P(x) ∨ ¬Q(x)")
283
375
  formulas_are_equivalent(f1, f2) # True
284
376
  ```
285
377
 
378
+ ### Satisfiability, validity, and counterexamples (Z3)
379
+
380
+ ```python
381
+ from unicode_fol_kit import MSFLParser, is_satisfiable, is_valid, get_model, Not
382
+
383
+ parser = MSFLParser()
384
+
385
+ is_satisfiable(parser.parse("P ∧ Q")) # True
386
+ is_satisfiable(parser.parse("P ∧ ¬P")) # False
387
+ is_valid(parser.parse("P ∨ ¬P")) # True
388
+
389
+ get_model(parser.parse("P ∧ Q")) # {'P': 'True', 'Q': 'True'}
390
+ get_model(parser.parse("P ∧ ¬P")) # None (unsatisfiable)
391
+
392
+ # Counterexample to a claimed equivalence: a model of its negation.
393
+ get_model(Not(parser.parse("P ↔ Q"))) # e.g. {'P': 'True', 'Q': 'False'}
394
+ ```
395
+
396
+ `get_model` returns a dict mapping each Z3 declaration (constants, uninterpreted predicates/functions) to its interpretation, or `None` when the formula is unsatisfiable or Z3 returns `unknown` within the timeout.
397
+
286
398
  ### Entailment checking (Prover9)
287
399
 
288
400
  ```python
@@ -560,15 +672,15 @@ parser.parse("(λP. P(x))(Q)")
560
672
  ### A complete MSFOL example
561
673
 
562
674
  ```text
563
- ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y:Person)) → Shares(x, y)
675
+ ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y)) → Shares(x, y)
564
676
  ```
565
677
 
566
678
  ### A complete MSFL example
567
679
 
568
680
  ```text
569
681
  ∀x:Patient ∀y:Treatment
570
- (Effective(y:Treatment) ⊗ Tolerable(x:Patient, y:Treatment))
571
- → Recommended(x:Patient, y:Treatment)
682
+ (Effective(y) ⊗ Tolerable(x, y))
683
+ → Recommended(x, y)
572
684
  ```
573
685
 
574
686
  ### A complete FL example
@@ -12,7 +12,14 @@ A Python toolkit for parsing and working with first-order logic (FOL) formulas w
12
12
  - **Reductions** — `to_msfol()` lowers Łukasiewicz operators to classical nodes; `to_fol()` further eliminates sorts via relativisation
13
13
  - **Serialisation** — convert formulas to/from JSON dictionaries; round-trip safe
14
14
  - **Tree view** — render any formula as a readable ASCII tree
15
+ - **Unicode round-trip** — `to_unicode_str()` renders any node back to a parseable Unicode formula; re-parsing in the matching mode yields a structurally equal AST
16
+ - **LaTeX export** — `to_latex()` renders any node as LaTeX math-mode markup, with the same precedence-aware parenthesisation as the Unicode renderer
17
+ - **Normal forms** — `to_nnf()`, `to_pnf()`, `to_cnf()` (equivalence-preserving), and `skolemize()` (satisfiability-preserving) for classical FOL
18
+ - **Horn check** — `is_horn()` reports whether a formula's clausal form consists of Horn clauses
19
+ - **Traversal API** — `walk()`, `subformulas()`, `atoms()`, `variables()`, `count()`, `depth()` on every node
20
+ - **Graphviz export** — `to_dot()` renders the AST as a Graphviz DOT digraph
15
21
  - **Z3 export** — translate formulas to Z3 expressions for SMT solving
22
+ - **Satisfiability / validity / models** — `is_satisfiable()`, `is_valid()`, and `get_model()` (counterexample extraction) via Z3
16
23
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
17
24
  - **TPTP export** — translate formulas to TPTP syntax
18
25
  - **Equivalence checking** — check if two formulas are logically equivalent via Z3
@@ -130,14 +137,67 @@ print(formula.tree_str())
130
137
  # └── Variable: x
131
138
  ```
132
139
 
140
+ ### Round-trip to Unicode
141
+
142
+ `to_unicode_str()` is the inverse of parsing: it renders any node back to a Unicode formula string. Re-parsing that string in the same mode reproduces a structurally equal AST. The renderer is precedence-aware and only inserts the parentheses the grammar requires — including the no-mixing rule for same-level connectives and the tight-binding rule for quantifiers.
143
+
144
+ ```python
145
+ parser = MSFLParser()
146
+
147
+ ast = parser.parse("∀x P(x) ∧ Q(x)")
148
+ ast.to_unicode_str() # '∀x P(x) ∧ Q(x)'
149
+ parser.parse(ast.to_unicode_str()) == ast # True
150
+
151
+ # Precedence-driven parentheses are reconstructed, not the original spelling:
152
+ parser.parse("((P(x) ∧ Q(x)))").to_unicode_str() # 'P(x) ∧ Q(x)'
153
+ parser.parse("P(x) ∧ (Q(x) ∨ R(x))").to_unicode_str() # 'P(x) ∧ (Q(x) ∨ R(x))'
154
+ ```
155
+
156
+ Available on every node, so subformulas render too. The output targets parseable
157
+ ASTs; alpha-renamed variables introduced by `beta_reduce` (e.g. `x_0`) are not
158
+ valid surface tokens and will not round-trip.
159
+
133
160
  ### Exporting to other formats
134
161
 
135
162
  ```python
136
163
  formula.to_prover9() # '(all x (Human(x) -> Mortal(x)))'
137
164
  formula.to_tptp() # '(![X]: (human(X) => mortal(X)))'
165
+ formula.to_latex() # '\\forall x\\, (Human(x) \\rightarrow Mortal(x))'
138
166
  formula.to_dict() # JSON-serialisable dict
139
167
  ```
140
168
 
169
+ `to_latex()` uses the same precedence-aware parenthesisation as `to_unicode_str()`. Sorts render as `\forall x{:}\mathrm{Human}\,`; strong Łukasiewicz operators as `\otimes` / `\oplus`. Symbol and predicate names are emitted verbatim (no `\mathrm` wrapping).
170
+
171
+ ### Traversal and inspection
172
+
173
+ Every node exposes a small traversal API:
174
+
175
+ ```python
176
+ f = MSFLParser().parse("∀x (Human(x) → Mortal(x))")
177
+
178
+ list(f.walk()) # pre-order: every node and descendant
179
+ f.subformulas() # every sub-node that is a formula (terms excluded)
180
+ f.atoms() # [Atom("Human", …), Atom("Mortal", …)]
181
+ f.variables() # {Variable("x")} — set of logical variables (free + bound)
182
+ f.count() # total node count
183
+ f.count(Atom) # nodes of a given type
184
+ f.depth() # tree height (a leaf has depth 1)
185
+ ```
186
+
187
+ ### Graphviz export
188
+
189
+ ```python
190
+ print(f.to_dot())
191
+ # digraph AST {
192
+ # node [shape=box];
193
+ # n0 [label="∀ x"];
194
+ # n1 [label="→"];
195
+ # ...
196
+ # }
197
+ ```
198
+
199
+ `to_dot()` mirrors the `tree_str()` view (the quantifier's bound variable is folded into its node label). Pipe the output to `dot -Tpng` to render an image.
200
+
141
201
  ### Serialisation
142
202
 
143
203
  ```python
@@ -247,6 +307,38 @@ classical = to_fol(formula)
247
307
  classical_with_facts = to_fol(formula, include_sort_facts=True)
248
308
  ```
249
309
 
310
+ ### Normal forms
311
+
312
+ `to_nnf()`, `to_pnf()`, `to_cnf()`, and `skolemize()` operate on classical FOL. They accept FOL, MSFOL, MSFL, and FL inputs — sorts and Łukasiewicz operators are reduced via `to_fol()` first. (Lambda terms must be beta-reduced and lambda-eliminated beforehand.)
313
+
314
+ ```python
315
+ from unicode_fol_kit import MSFLParser, to_nnf, to_pnf, to_cnf, skolemize
316
+
317
+ parser = MSFLParser()
318
+
319
+ to_nnf(parser.parse("P → Q")) # eliminates → ↔ ⊕, pushes ¬ down to atoms
320
+ to_pnf(parser.parse("∀x P(x) ∧ ∃y Q(y)")) # quantifier prefix + quantifier-free matrix
321
+ to_cnf(parser.parse("P ∨ (Q ∧ R)")) # matrix as a conjunction of clauses
322
+ skolemize(parser.parse("∀x ∃y Loves(x, y)"))
323
+ # ∀v0 Loves(v0, sk0(v0)) — the existential becomes a Skolem function of x
324
+ ```
325
+
326
+ - `to_nnf` / `to_pnf` / `to_cnf` are **equivalence-preserving**: the result is logically equivalent to the (classical) input.
327
+ - `skolemize` is **satisfiability-preserving** (not equivalence-preserving): existentials are replaced by Skolem terms over the universals in scope, and the universal prefix is retained. Bound variables are standardised apart (renamed to fresh `v0, v1, …`); Skolem symbols are named `sk0, sk1, …`.
328
+
329
+ ### Horn check
330
+
331
+ `is_horn()` reports whether a formula's clausal form consists of Horn clauses — each clause has at most one positive literal. The formula is skolemised, its universal prefix dropped, and the matrix put into CNF before the clauses are checked.
332
+
333
+ ```python
334
+ from unicode_fol_kit import MSFLParser, is_horn
335
+
336
+ parser = MSFLParser()
337
+ is_horn(parser.parse("∀x (Body(x) → Head(x))")) # True (definite clause)
338
+ is_horn(parser.parse("P → (Q ∧ R)")) # True (splits into two Horn clauses)
339
+ is_horn(parser.parse("P → (Q ∨ R)")) # False (clause has two positive literals)
340
+ ```
341
+
250
342
  ### Equivalence checking (Z3)
251
343
 
252
344
  ```python
@@ -259,6 +351,26 @@ f2 = parser.parse("¬P(x) ∨ ¬Q(x)")
259
351
  formulas_are_equivalent(f1, f2) # True
260
352
  ```
261
353
 
354
+ ### Satisfiability, validity, and counterexamples (Z3)
355
+
356
+ ```python
357
+ from unicode_fol_kit import MSFLParser, is_satisfiable, is_valid, get_model, Not
358
+
359
+ parser = MSFLParser()
360
+
361
+ is_satisfiable(parser.parse("P ∧ Q")) # True
362
+ is_satisfiable(parser.parse("P ∧ ¬P")) # False
363
+ is_valid(parser.parse("P ∨ ¬P")) # True
364
+
365
+ get_model(parser.parse("P ∧ Q")) # {'P': 'True', 'Q': 'True'}
366
+ get_model(parser.parse("P ∧ ¬P")) # None (unsatisfiable)
367
+
368
+ # Counterexample to a claimed equivalence: a model of its negation.
369
+ get_model(Not(parser.parse("P ↔ Q"))) # e.g. {'P': 'True', 'Q': 'False'}
370
+ ```
371
+
372
+ `get_model` returns a dict mapping each Z3 declaration (constants, uninterpreted predicates/functions) to its interpretation, or `None` when the formula is unsatisfiable or Z3 returns `unknown` within the timeout.
373
+
262
374
  ### Entailment checking (Prover9)
263
375
 
264
376
  ```python
@@ -536,15 +648,15 @@ parser.parse("(λP. P(x))(Q)")
536
648
  ### A complete MSFOL example
537
649
 
538
650
  ```text
539
- ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y:Person)) → Shares(x, y)
651
+ ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y)) → Shares(x, y)
540
652
  ```
541
653
 
542
654
  ### A complete MSFL example
543
655
 
544
656
  ```text
545
657
  ∀x:Patient ∀y:Treatment
546
- (Effective(y:Treatment) ⊗ Tolerable(x:Patient, y:Treatment))
547
- → Recommended(x:Patient, y:Treatment)
658
+ (Effective(y) ⊗ Tolerable(x, y))
659
+ → Recommended(x, y)
548
660
  ```
549
661
 
550
662
  ### A complete FL example
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unicode-fol-kit"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Parser and toolkit for first-order logic formulas using Unicode operators"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -14,10 +14,14 @@ from .fol import (
14
14
  eta_reduce, beta_eta_normalize,
15
15
  resolve_lambda_scope,
16
16
  to_fol,
17
+ to_nnf, to_pnf, to_cnf, skolemize, is_horn,
18
+ )
19
+ from .atp import (
20
+ formulas_are_equivalent, check_logical_entailment,
21
+ is_satisfiable, is_valid, get_model,
17
22
  )
18
- from .atp import formulas_are_equivalent, check_logical_entailment
19
23
 
20
- __version__ = "0.2.1"
24
+ __version__ = "0.3.1"
21
25
 
22
26
  __all__ = [
23
27
  "MSFLParser",
@@ -36,4 +40,6 @@ __all__ = [
36
40
  "eta_reduce", "beta_eta_normalize",
37
41
  "resolve_lambda_scope",
38
42
  "to_fol",
43
+ "to_nnf", "to_pnf", "to_cnf", "skolemize", "is_horn",
44
+ "is_satisfiable", "is_valid", "get_model",
39
45
  ]
@@ -0,0 +1,9 @@
1
+ from .z3_equivalence import formulas_are_equivalent
2
+ from .prover9_entailment import check_logical_entailment
3
+ from .z3_models import is_satisfiable, is_valid, get_model
4
+
5
+ __all__ = [
6
+ "formulas_are_equivalent",
7
+ "check_logical_entailment",
8
+ "is_satisfiable", "is_valid", "get_model",
9
+ ]
@@ -0,0 +1,45 @@
1
+ """Satisfiability / validity checks and model (counterexample) extraction via Z3."""
2
+
3
+ from ..fol.nodes import Node
4
+ from z3 import Solver, sat, unsat, Not
5
+
6
+
7
+ def is_satisfiable(formula: Node, timeout: int = 10000) -> bool:
8
+ """Return True if the formula has a model (Z3 reports sat).
9
+
10
+ A Z3 ``unknown`` result (e.g. on hard quantified formulas hitting the
11
+ timeout) is treated as not-known-satisfiable and returns False.
12
+ """
13
+ solver = Solver()
14
+ solver.set("timeout", timeout)
15
+ solver.set("random_seed", 42)
16
+ solver.add(formula.to_z3())
17
+ return solver.check() == sat
18
+
19
+
20
+ def is_valid(formula: Node, timeout: int = 10000) -> bool:
21
+ """Return True if the formula is valid, i.e. its negation is unsatisfiable."""
22
+ solver = Solver()
23
+ solver.set("timeout", timeout)
24
+ solver.set("random_seed", 42)
25
+ solver.add(Not(formula.to_z3()))
26
+ return solver.check() == unsat
27
+
28
+
29
+ def get_model(formula: Node, timeout: int = 10000):
30
+ """Return a satisfying assignment as a dict, or None if unsat/unknown.
31
+
32
+ The dict maps each Z3 declaration name (constants, uninterpreted
33
+ functions/predicates) to the string form of its interpretation. For an
34
+ invalid equivalence or entailment, ``get_model(Not(...))`` yields the
35
+ concrete counterexample. Returns None when the formula is unsatisfiable or
36
+ Z3 cannot decide it within the timeout.
37
+ """
38
+ solver = Solver()
39
+ solver.set("timeout", timeout)
40
+ solver.set("random_seed", 42)
41
+ solver.add(formula.to_z3())
42
+ if solver.check() != sat:
43
+ return None
44
+ model = solver.model()
45
+ return {str(decl.name()): str(model[decl]) for decl in model.decls()}
@@ -14,6 +14,7 @@ from .nodes import (
14
14
  resolve_lambda_scope,
15
15
  to_fol,
16
16
  )
17
+ from .normalforms import to_nnf, to_pnf, to_cnf, skolemize, is_horn
17
18
  from .naming import NamingError, ParsingError
18
19
 
19
20
  __all__ = [
@@ -32,4 +33,5 @@ __all__ = [
32
33
  "eta_reduce", "beta_eta_normalize",
33
34
  "resolve_lambda_scope",
34
35
  "to_fol",
36
+ "to_nnf", "to_pnf", "to_cnf", "skolemize", "is_horn",
35
37
  ]
@@ -108,6 +108,28 @@ class Node:
108
108
  children.extend(c for c in value if isinstance(c, Node))
109
109
  return label, children
110
110
 
111
+ def to_unicode_str(self) -> str:
112
+ """Render this node back to a parseable Unicode formula string.
113
+
114
+ The result, re-parsed in the matching MSFLParser mode, yields a
115
+ structurally equal AST (parser round-trip). The renderer lives in
116
+ _msfl_nodes.py (imported lazily to avoid a circular import) because it
117
+ dispatches over both the FOL nodes here and the MSFL/lambda nodes there.
118
+ """
119
+ from ._msfl_nodes import _uni
120
+ return _uni(self)
121
+
122
+ def to_latex(self) -> str:
123
+ """Render this node as a LaTeX math-mode string (no surrounding $…$).
124
+
125
+ Uses the same precedence-driven parenthesisation as to_unicode_str.
126
+ Symbol/function/predicate names are emitted verbatim (no \\mathrm
127
+ wrapping). The renderer lives in _msfl_nodes.py (imported lazily) so it
128
+ can dispatch over both FOL and MSFL/lambda nodes.
129
+ """
130
+ from ._msfl_nodes import _latex
131
+ return _latex(self)
132
+
111
133
  def tree_str(self) -> str:
112
134
  """Render the AST as a multi-line ASCII tree using ├──/└── connectors."""
113
135
  label, children = self._tree_parts()
@@ -158,6 +180,88 @@ class Node:
158
180
  new_kwargs[f.name] = val
159
181
  return type(self)(**new_kwargs)
160
182
 
183
+ # ---------------------------------------------------------------
184
+ # Traversal / inspection API
185
+ # ---------------------------------------------------------------
186
+
187
+ def _child_nodes(self) -> List["Node"]:
188
+ """Return the immediate Node-valued children, in declaration order.
189
+
190
+ Covers both single Node fields and lists of Nodes. Quantifier exposes
191
+ its bound variable here (it is a Node); for a rendering-oriented child
192
+ view see _tree_parts.
193
+ """
194
+ result: List["Node"] = []
195
+ for f in fields(self):
196
+ val = getattr(self, f.name)
197
+ if isinstance(val, Node):
198
+ result.append(val)
199
+ elif isinstance(val, list):
200
+ result.extend(c for c in val if isinstance(c, Node))
201
+ return result
202
+
203
+ def walk(self):
204
+ """Yield this node and every descendant in pre-order (depth-first)."""
205
+ yield self
206
+ for child in self._child_nodes():
207
+ yield from child.walk()
208
+
209
+ def subformulas(self):
210
+ """Yield every sub-node that is a formula (i.e. not an atomic term).
211
+
212
+ Terms (Variable, Constant, Number, Function, SortedConstant, LambdaVar)
213
+ are excluded; everything else reachable is returned in pre-order.
214
+ """
215
+ return [n for n in self.walk() if type(n).__name__ not in _TERM_NAMES]
216
+
217
+ def atoms(self):
218
+ """Return all Atom nodes in pre-order (duplicates kept; comparisons included)."""
219
+ return [n for n in self.walk() if isinstance(n, Atom)]
220
+
221
+ def variables(self):
222
+ """Return the set of logical Variable nodes occurring anywhere (free and bound)."""
223
+ return {n for n in self.walk() if isinstance(n, Variable)}
224
+
225
+ def count(self, cls=None) -> int:
226
+ """Count nodes in the tree; if cls is given, only nodes of that type."""
227
+ return sum(1 for n in self.walk() if cls is None or isinstance(n, cls))
228
+
229
+ def depth(self) -> int:
230
+ """Return the height of the tree; a leaf node has depth 1."""
231
+ children = self._child_nodes()
232
+ return 1 + max((c.depth() for c in children), default=0)
233
+
234
+ def to_dot(self) -> str:
235
+ """Render the AST as a Graphviz DOT digraph string.
236
+
237
+ Uses the same label/child view as tree_str (the bound variable of a
238
+ quantifier is folded into its node label, not shown as a child), so the
239
+ graph mirrors the ASCII tree. No external dependency: returns the source.
240
+ """
241
+ lines = ["digraph AST {", " node [shape=box];"]
242
+ counter = [0]
243
+
244
+ def emit(node: "Node") -> int:
245
+ my_id = counter[0]
246
+ counter[0] += 1
247
+ label, children = node._tree_parts()
248
+ safe = label.replace("\\", "\\\\").replace('"', '\\"')
249
+ lines.append(f' n{my_id} [label="{safe}"];')
250
+ for child in children:
251
+ child_id = emit(child)
252
+ lines.append(f" n{my_id} -> n{child_id};")
253
+ return my_id
254
+
255
+ emit(self)
256
+ lines.append("}")
257
+ return "\n".join(lines)
258
+
259
+
260
+ # Term node class names — used by Node.subformulas to exclude atomic terms.
261
+ _TERM_NAMES = frozenset({
262
+ "Variable", "Constant", "Number", "Function", "SortedConstant", "LambdaVar",
263
+ })
264
+
161
265
 
162
266
  # =========================
163
267
  # Term Nodes
@@ -893,6 +893,296 @@ def resolve_lambda_scope(node: Node) -> Node:
893
893
  return _resolve(node, frozenset())
894
894
 
895
895
 
896
+ # =========================
897
+ # Unicode rendering (parser round-trip)
898
+ # =========================
899
+ #
900
+ # These functions render any node back to a Unicode formula string that, when
901
+ # re-parsed in the matching MSFLParser mode, yields a structurally equal AST.
902
+ # Dispatch is by class name so this single block covers both the FOL nodes
903
+ # (from _fol_nodes.py) and the MSFL/lambda nodes defined above.
904
+ #
905
+ # Formula precedence — higher binds tighter — mirrors the grammar layering
906
+ # (biimplication < implication < same-level binary < prefix < atomic):
907
+ _UNI_FORMULA_PREC = {
908
+ "Lambda": 0, "Application": 0,
909
+ "Iff": 1, "LukEquivalence": 1,
910
+ "Implies": 2, "LukImplication": 2,
911
+ "And": 3, "Or": 3, "Xor": 3,
912
+ "WeakConjunction": 3, "WeakDisjunction": 3,
913
+ "StrongConjunction": 3, "StrongDisjunction": 3,
914
+ "Not": 4, "LukNegation": 4,
915
+ "Quantifier": 4, "SortedQuantifier": 4,
916
+ }
917
+
918
+ # Binary connective glyphs. Xor and StrongDisjunction share ⊕ (disjoint modes);
919
+ # the weak/strong and classical operators reuse ∧ ∨ → ↔ by class identity.
920
+ _UNI_BINSYM = {
921
+ "And": "∧", "Or": "∨", "Xor": "⊕",
922
+ "Implies": "→", "Iff": "↔",
923
+ "WeakConjunction": "∧", "WeakDisjunction": "∨",
924
+ "StrongConjunction": "⊗", "StrongDisjunction": "⊕",
925
+ "LukImplication": "→", "LukEquivalence": "↔",
926
+ }
927
+
928
+ # The same-level binary group (grammar precedence 2): cannot be mixed without
929
+ # parentheses, and chains are left-folded.
930
+ _UNI_LEVEL2 = frozenset({
931
+ "And", "Or", "Xor",
932
+ "WeakConjunction", "WeakDisjunction",
933
+ "StrongConjunction", "StrongDisjunction",
934
+ })
935
+
936
+ _UNI_INFIX_COMPARE = frozenset({"=", "≠", "<", ">", "≤", "≥"})
937
+ _UNI_ARITH_OPS = frozenset({"+", "-", "*", "/"})
938
+
939
+
940
+ def _uni_prec(node) -> int:
941
+ """Formula precedence of a node; atomic nodes (atoms, terms) default to 5."""
942
+ return _UNI_FORMULA_PREC.get(type(node).__name__, 5)
943
+
944
+
945
+ def _uni_wrap(node, min_prec: int) -> str:
946
+ """Render node, parenthesising it when it binds looser than the slot allows."""
947
+ s = _uni(node)
948
+ return f"({s})" if _uni_prec(node) < min_prec else s
949
+
950
+
951
+ def _uni_level2_child(node, parent_cls: str, side: str) -> str:
952
+ """Render a same-level (∧ ∨ ⊗ ⊕) operand with no-mixing / left-assoc parens.
953
+
954
+ Left operand: a same-class chain stays flat (a ∧ b ∧ c); a different
955
+ same-level operator is parenthesised (no silent mixing). Right operand:
956
+ any same-level node is parenthesised, since the parser left-folds chains.
957
+ """
958
+ s = _uni(node)
959
+ p = _uni_prec(node)
960
+ if side == "left":
961
+ need = p < 3 or (p == 3 and type(node).__name__ != parent_cls)
962
+ else:
963
+ need = p < 4
964
+ return f"({s})" if need else s
965
+
966
+
967
+ def _uni_atom(node) -> str:
968
+ """Render an Atom: infix comparison, nullary predicate, or applied predicate."""
969
+ if node.predicate in _UNI_INFIX_COMPARE and len(node.args) == 2:
970
+ return f"{_uni_term(node.args[0])} {node.predicate} {_uni_term(node.args[1])}"
971
+ if not node.args:
972
+ return node.predicate
973
+ return f"{node.predicate}(" + ", ".join(_uni_term(a) for a in node.args) + ")"
974
+
975
+
976
+ def _uni_term_prec(node) -> int:
977
+ """Arithmetic term precedence: + - → 1, * / → 2, everything atomic → 3."""
978
+ if (type(node).__name__ == "Function"
979
+ and node.name in _UNI_ARITH_OPS and len(node.args) == 2):
980
+ return 2 if node.name in ("*", "/") else 1
981
+ return 3
982
+
983
+
984
+ def _uni_term_wrap(node, parent_prec: int, is_right: bool) -> str:
985
+ """Render an arithmetic operand, parenthesising per left-associative precedence."""
986
+ s = _uni_term(node)
987
+ p = _uni_term_prec(node)
988
+ need = p < parent_prec or (p == parent_prec and is_right)
989
+ return f"({s})" if need else s
990
+
991
+
992
+ def _uni_spine(node):
993
+ """Uncurry a left-nested Application into (head, [arg0, arg1, …])."""
994
+ args = []
995
+ n = node
996
+ while isinstance(n, Application):
997
+ args.append(n.arg)
998
+ n = n.func
999
+ args.reverse()
1000
+ return n, args
1001
+
1002
+
1003
+ def _uni_term(node) -> str:
1004
+ """Render a node occurring in term (argument) position.
1005
+
1006
+ Higher-order applications produced by scope resolution (e.g. foo(x) under
1007
+ λfoo, parsed as a Function then rewritten to Application(LambdaVar, …)) are
1008
+ rendered back as function-call syntax so they re-parse and re-resolve to the
1009
+ same node.
1010
+ """
1011
+ cls = type(node).__name__
1012
+ if cls in ("Variable", "LambdaVar", "Constant"):
1013
+ return node.name
1014
+ if cls == "Number":
1015
+ return str(node.value)
1016
+ if cls == "SortedConstant":
1017
+ return f"{node.name}:{node.sort}"
1018
+ if cls == "Function":
1019
+ if node.name in _UNI_ARITH_OPS and len(node.args) == 2:
1020
+ p = _uni_term_prec(node)
1021
+ left = _uni_term_wrap(node.args[0], p, is_right=False)
1022
+ right = _uni_term_wrap(node.args[1], p, is_right=True)
1023
+ return f"{left} {node.name} {right}"
1024
+ return f"{node.name}(" + ", ".join(_uni_term(a) for a in node.args) + ")"
1025
+ if cls == "Application":
1026
+ head, args = _uni_spine(node)
1027
+ if isinstance(head, (LambdaVar, Variable, Constant)) and args:
1028
+ return f"{head.name}(" + ", ".join(_uni_term(a) for a in args) + ")"
1029
+ return f"({_uni(node.func)})({_uni(node.arg)})"
1030
+ # Atoms / other formula nodes are not valid terms; best-effort fall-through.
1031
+ return _uni(node)
1032
+
1033
+
1034
+ def _uni(node) -> str:
1035
+ """Render node as a formula-level Unicode string (no surrounding parens)."""
1036
+ cls = type(node).__name__
1037
+
1038
+ if cls in ("Variable", "LambdaVar", "Constant", "Number", "SortedConstant", "Function"):
1039
+ return _uni_term(node)
1040
+ if cls == "Atom":
1041
+ return _uni_atom(node)
1042
+
1043
+ if cls in ("Not", "LukNegation"):
1044
+ return "¬" + _uni_wrap(node.formula, 4)
1045
+
1046
+ if cls == "Quantifier":
1047
+ return f"{node.type}{node.variable.name} " + _uni_wrap(node.formula, 4)
1048
+ if cls == "SortedQuantifier":
1049
+ return f"{node.type}{node.variable.name}:{node.sort} " + _uni_wrap(node.formula, 4)
1050
+
1051
+ if cls in ("Iff", "LukEquivalence"):
1052
+ # ↔ right-assoc: left slot is implication (≥2), right slot biimplication (≥1)
1053
+ return f"{_uni_wrap(node.left, 2)} {_UNI_BINSYM[cls]} {_uni_wrap(node.right, 1)}"
1054
+ if cls in ("Implies", "LukImplication"):
1055
+ # → right-assoc: left slot same_level_ops (≥3), right slot implication (≥2)
1056
+ return f"{_uni_wrap(node.left, 3)} {_UNI_BINSYM[cls]} {_uni_wrap(node.right, 2)}"
1057
+ if cls in _UNI_LEVEL2:
1058
+ left = _uni_level2_child(node.left, cls, "left")
1059
+ right = _uni_level2_child(node.right, cls, "right")
1060
+ return f"{left} {_UNI_BINSYM[cls]} {right}"
1061
+
1062
+ if cls == "Lambda":
1063
+ # Body extends rightward through the whole formula; never wrapped here.
1064
+ return f"λ{node.param.name}. " + _uni(node.body)
1065
+ if cls == "Application":
1066
+ # (func)(arg): both sides are delimited by parens in the grammar.
1067
+ return f"({_uni(node.func)})({_uni(node.arg)})"
1068
+
1069
+ raise TypeError(f"to_unicode_str: unknown node type {cls}")
1070
+
1071
+
1072
+ # =========================
1073
+ # LaTeX rendering
1074
+ # =========================
1075
+ #
1076
+ # Mirrors the Unicode renderer above but emits LaTeX math-mode markup. It reuses
1077
+ # the same precedence tables (_UNI_FORMULA_PREC, _UNI_LEVEL2, _uni_prec) so the
1078
+ # parenthesisation is identical; only the operator glyphs and term formatting
1079
+ # differ. Output is not parseable by MSFLParser (so no round-trip), hence tests
1080
+ # assert on exact strings.
1081
+
1082
+ _LATEX_BINSYM = {
1083
+ "And": "\\land", "Or": "\\lor", "Xor": "\\oplus",
1084
+ "Implies": "\\rightarrow", "Iff": "\\leftrightarrow",
1085
+ "WeakConjunction": "\\land", "WeakDisjunction": "\\lor",
1086
+ "StrongConjunction": "\\otimes", "StrongDisjunction": "\\oplus",
1087
+ "LukImplication": "\\rightarrow", "LukEquivalence": "\\leftrightarrow",
1088
+ }
1089
+
1090
+ _LATEX_COMPARE = {
1091
+ "=": "=", "≠": "\\neq", "<": "<", ">": ">", "≤": "\\leq", "≥": "\\geq",
1092
+ }
1093
+
1094
+ _LATEX_ARITH = {"+": "+", "-": "-", "*": "\\cdot", "/": "/"}
1095
+
1096
+ _LATEX_QUANT = {"∀": "\\forall", "forall": "\\forall", "∃": "\\exists", "exists": "\\exists"}
1097
+
1098
+
1099
+ def _latex_wrap(node, min_prec: int) -> str:
1100
+ s = _latex(node)
1101
+ return f"({s})" if _uni_prec(node) < min_prec else s
1102
+
1103
+
1104
+ def _latex_level2_child(node, parent_cls: str, side: str) -> str:
1105
+ s = _latex(node)
1106
+ p = _uni_prec(node)
1107
+ if side == "left":
1108
+ need = p < 3 or (p == 3 and type(node).__name__ != parent_cls)
1109
+ else:
1110
+ need = p < 4
1111
+ return f"({s})" if need else s
1112
+
1113
+
1114
+ def _latex_term(node) -> str:
1115
+ cls = type(node).__name__
1116
+ if cls in ("Variable", "LambdaVar", "Constant"):
1117
+ return node.name
1118
+ if cls == "Number":
1119
+ return str(node.value)
1120
+ if cls == "SortedConstant":
1121
+ return f"{node.name}{{:}}\\mathrm{{{node.sort}}}"
1122
+ if cls == "Function":
1123
+ if node.name in _UNI_ARITH_OPS and len(node.args) == 2:
1124
+ p = _uni_term_prec(node)
1125
+ left = node.args[0]
1126
+ right = node.args[1]
1127
+ ls = _latex_term(left)
1128
+ rs = _latex_term(right)
1129
+ if _uni_term_prec(left) < p:
1130
+ ls = f"({ls})"
1131
+ if _uni_term_prec(right) < p or (_uni_term_prec(right) == p):
1132
+ rs = f"({rs})"
1133
+ return f"{ls} {_LATEX_ARITH[node.name]} {rs}"
1134
+ return f"{node.name}(" + ", ".join(_latex_term(a) for a in node.args) + ")"
1135
+ if cls == "Application":
1136
+ head, args = _uni_spine(node)
1137
+ if isinstance(head, (LambdaVar, Variable, Constant)) and args:
1138
+ return f"{head.name}(" + ", ".join(_latex_term(a) for a in args) + ")"
1139
+ return f"({_latex(node.func)})({_latex(node.arg)})"
1140
+ return _latex(node)
1141
+
1142
+
1143
+ def _latex_atom(node) -> str:
1144
+ if node.predicate in _UNI_INFIX_COMPARE and len(node.args) == 2:
1145
+ return f"{_latex_term(node.args[0])} {_LATEX_COMPARE[node.predicate]} {_latex_term(node.args[1])}"
1146
+ if not node.args:
1147
+ return node.predicate
1148
+ return f"{node.predicate}(" + ", ".join(_latex_term(a) for a in node.args) + ")"
1149
+
1150
+
1151
+ def _latex(node) -> str:
1152
+ """Render node as a LaTeX math-mode string (no surrounding parens)."""
1153
+ cls = type(node).__name__
1154
+
1155
+ if cls in ("Variable", "LambdaVar", "Constant", "Number", "SortedConstant", "Function"):
1156
+ return _latex_term(node)
1157
+ if cls == "Atom":
1158
+ return _latex_atom(node)
1159
+
1160
+ if cls in ("Not", "LukNegation"):
1161
+ return "\\lnot " + _latex_wrap(node.formula, 4)
1162
+
1163
+ if cls == "Quantifier":
1164
+ return f"{_LATEX_QUANT[node.type]} {node.variable.name}\\, " + _latex_wrap(node.formula, 4)
1165
+ if cls == "SortedQuantifier":
1166
+ return (f"{_LATEX_QUANT[node.type]} {node.variable.name}{{:}}\\mathrm{{{node.sort}}}\\, "
1167
+ + _latex_wrap(node.formula, 4))
1168
+
1169
+ if cls in ("Iff", "LukEquivalence"):
1170
+ return f"{_latex_wrap(node.left, 2)} {_LATEX_BINSYM[cls]} {_latex_wrap(node.right, 1)}"
1171
+ if cls in ("Implies", "LukImplication"):
1172
+ return f"{_latex_wrap(node.left, 3)} {_LATEX_BINSYM[cls]} {_latex_wrap(node.right, 2)}"
1173
+ if cls in _UNI_LEVEL2:
1174
+ left = _latex_level2_child(node.left, cls, "left")
1175
+ right = _latex_level2_child(node.right, cls, "right")
1176
+ return f"{left} {_LATEX_BINSYM[cls]} {right}"
1177
+
1178
+ if cls == "Lambda":
1179
+ return f"\\lambda {node.param.name}.\\, " + _latex(node.body)
1180
+ if cls == "Application":
1181
+ return f"({_latex(node.func)})({_latex(node.arg)})"
1182
+
1183
+ raise TypeError(f"to_latex: unknown node type {cls}")
1184
+
1185
+
896
1186
  # =========================
897
1187
  # Registry extension
898
1188
  # =========================
@@ -0,0 +1,282 @@
1
+ """Classical-FOL normal forms: NNF, PNF, CNF, Skolemization, and a Horn check.
2
+
3
+ All entry points first call ``to_fol`` to eliminate sort annotations and
4
+ Łukasiewicz operators, so they accept FOL, MSFOL, MSFL, and FL inputs. Lambda
5
+ terms must be beta-reduced and lambda-eliminated first (``to_fol`` will raise
6
+ otherwise).
7
+
8
+ - ``to_nnf`` — negation normal form (eliminate → ↔ ⊕, push ¬ to atoms).
9
+ - ``to_pnf`` — prenex normal form (quantifier prefix + quantifier-free NNF
10
+ matrix), with bound variables standardised apart. Equivalence-preserving.
11
+ - ``to_cnf`` — prenex form whose matrix is a conjunction of clauses.
12
+ Equivalence-preserving.
13
+ - ``skolemize`` — prenex NNF with existentials replaced by Skolem terms over
14
+ the universals in scope; universal prefix retained. Satisfiability-preserving
15
+ (NOT equivalence-preserving).
16
+ - ``is_horn`` — True iff the clausal form (skolemise → drop ∀ → CNF) consists of
17
+ Horn clauses (each clause has at most one positive literal).
18
+ """
19
+
20
+ from .nodes import (
21
+ Node, Atom, Not, And, Or, Xor, Implies, Iff, Quantifier,
22
+ Variable, Constant, Number, Function, to_fol,
23
+ )
24
+ from ._msfl_nodes import _rename
25
+
26
+ _FORALL = ("∀", "forall")
27
+ _EXISTS = ("∃", "exists")
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Negation normal form
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def to_nnf(node: Node) -> Node:
35
+ """Return the negation normal form of node (after reducing to classical FOL)."""
36
+ return _nnf(to_fol(node))
37
+
38
+
39
+ def _nnf(n: Node) -> Node:
40
+ if isinstance(n, Atom):
41
+ return n
42
+ if isinstance(n, Not):
43
+ return _nnf_neg(n.formula)
44
+ if isinstance(n, And):
45
+ return And(_nnf(n.left), _nnf(n.right))
46
+ if isinstance(n, Or):
47
+ return Or(_nnf(n.left), _nnf(n.right))
48
+ if isinstance(n, Implies):
49
+ return Or(_nnf(Not(n.left)), _nnf(n.right))
50
+ if isinstance(n, Iff):
51
+ return And(_nnf(Implies(n.left, n.right)), _nnf(Implies(n.right, n.left)))
52
+ if isinstance(n, Xor):
53
+ return Or(And(_nnf(n.left), _nnf(Not(n.right))),
54
+ And(_nnf(Not(n.left)), _nnf(n.right)))
55
+ if isinstance(n, Quantifier):
56
+ return Quantifier(n.type, n.variable, _nnf(n.formula))
57
+ raise TypeError(f"to_nnf: unexpected node type {type(n).__name__}")
58
+
59
+
60
+ def _nnf_neg(n: Node) -> Node:
61
+ """Return the NNF of ¬n."""
62
+ if isinstance(n, Atom):
63
+ return Not(n)
64
+ if isinstance(n, Not):
65
+ return _nnf(n.formula) # ¬¬a → a
66
+ if isinstance(n, And):
67
+ return Or(_nnf_neg(n.left), _nnf_neg(n.right))
68
+ if isinstance(n, Or):
69
+ return And(_nnf_neg(n.left), _nnf_neg(n.right))
70
+ if isinstance(n, Implies): # ¬(a → b) ≡ a ∧ ¬b
71
+ return And(_nnf(n.left), _nnf_neg(n.right))
72
+ if isinstance(n, Iff): # ¬(a ↔ b) ≡ (a ∧ ¬b) ∨ (¬a ∧ b)
73
+ return Or(And(_nnf(n.left), _nnf_neg(n.right)),
74
+ And(_nnf_neg(n.left), _nnf(n.right)))
75
+ if isinstance(n, Xor): # ¬(a ⊕ b) ≡ a ↔ b
76
+ return _nnf(Iff(n.left, n.right))
77
+ if isinstance(n, Quantifier):
78
+ dual = "∃" if n.type in _FORALL else "∀"
79
+ return Quantifier(dual, n.variable, _nnf_neg(n.formula))
80
+ raise TypeError(f"to_nnf: unexpected node type {type(n).__name__}")
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Fresh-name plumbing
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _all_names(node: Node) -> set:
88
+ """Collect every predicate, function, constant, and variable name in node."""
89
+ names = set()
90
+ for n in node.walk():
91
+ cls = type(n).__name__
92
+ if cls == "Atom":
93
+ names.add(n.predicate)
94
+ elif cls == "Function":
95
+ names.add(n.name)
96
+ elif cls in ("Variable", "Constant"):
97
+ names.add(n.name)
98
+ return names
99
+
100
+
101
+ def _gensym(base: str, counter: list, used: set) -> str:
102
+ """Return a fresh name base+N not already in used; record it in used."""
103
+ while True:
104
+ name = f"{base}{counter[0]}"
105
+ counter[0] += 1
106
+ if name not in used:
107
+ used.add(name)
108
+ return name
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Prenex normal form
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def to_pnf(node: Node) -> Node:
116
+ """Return the prenex normal form: quantifier prefix over a quantifier-free
117
+ NNF matrix, with all bound variables standardised apart."""
118
+ nnf = _nnf(to_fol(node))
119
+ std = _standardize(nnf, [0], _all_names(nnf))
120
+ prefix, matrix = _prenex_split(std)
121
+ for qtype, qvar in reversed(prefix):
122
+ matrix = Quantifier(qtype, qvar, matrix)
123
+ return matrix
124
+
125
+
126
+ def _standardize(n: Node, counter: list, used: set) -> Node:
127
+ """Rename every bound variable to a globally unique fresh name."""
128
+ if isinstance(n, Quantifier):
129
+ fresh = Variable(_gensym("v", counter, used))
130
+ body = _rename(n.formula, n.variable, fresh)
131
+ return Quantifier(n.type, fresh, _standardize(body, counter, used))
132
+ if isinstance(n, (And, Or)):
133
+ return type(n)(_standardize(n.left, counter, used),
134
+ _standardize(n.right, counter, used))
135
+ if isinstance(n, Not):
136
+ return Not(_standardize(n.formula, counter, used))
137
+ return n # Atom
138
+
139
+
140
+ def _prenex_split(n: Node):
141
+ """Split a standardised-apart NNF formula into (prefix, quantifier-free matrix).
142
+
143
+ prefix is a list of (quantifier_type, Variable) in outer-to-inner order.
144
+ Sound because bound variables are disjoint, so quantifiers hoist freely.
145
+ """
146
+ if isinstance(n, Quantifier):
147
+ prefix, matrix = _prenex_split(n.formula)
148
+ return [(n.type, n.variable)] + prefix, matrix
149
+ if isinstance(n, (And, Or)):
150
+ pl, ml = _prenex_split(n.left)
151
+ pr, mr = _prenex_split(n.right)
152
+ return pl + pr, type(n)(ml, mr)
153
+ return [], n # Not(atom) or Atom
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Conjunctive normal form
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def to_cnf(node: Node) -> Node:
161
+ """Return prenex form whose matrix is a conjunction of clauses. Equivalence-preserving."""
162
+ pnf = to_pnf(node)
163
+ prefix, matrix = _prenex_split(pnf)
164
+ cnf_matrix = _cnf(matrix)
165
+ for qtype, qvar in reversed(prefix):
166
+ cnf_matrix = Quantifier(qtype, qvar, cnf_matrix)
167
+ return cnf_matrix
168
+
169
+
170
+ def _cnf(n: Node) -> Node:
171
+ """Distribute ∨ over ∧ in a quantifier-free NNF formula."""
172
+ if isinstance(n, (Atom, Not)):
173
+ return n
174
+ if isinstance(n, And):
175
+ return And(_cnf(n.left), _cnf(n.right))
176
+ if isinstance(n, Or):
177
+ return _distribute(_cnf(n.left), _cnf(n.right))
178
+ raise TypeError(f"to_cnf: unexpected node type {type(n).__name__}")
179
+
180
+
181
+ def _distribute(left: Node, right: Node) -> Node:
182
+ if isinstance(left, And):
183
+ return And(_distribute(left.left, right), _distribute(left.right, right))
184
+ if isinstance(right, And):
185
+ return And(_distribute(left, right.left), _distribute(left, right.right))
186
+ return Or(left, right)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Skolemization
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def skolemize(node: Node) -> Node:
194
+ """Return prenex NNF with existentials replaced by Skolem terms.
195
+
196
+ Each ∃-bound variable becomes a Skolem function of the universals in scope
197
+ (a Skolem constant if there are none); the existential quantifiers are
198
+ dropped and the universal prefix is retained. Satisfiability-preserving.
199
+ """
200
+ pnf = to_pnf(node)
201
+ prefix, matrix = _prenex_split(pnf)
202
+ used = _all_names(pnf)
203
+ sk_counter = [0]
204
+
205
+ universals = [] # Variables of the ∀ seen so far, in order
206
+ kept_prefix = [] # the ∀ Variables to re-wrap
207
+ subst = {} # existential var name -> Skolem term
208
+
209
+ for qtype, qvar in prefix:
210
+ if qtype in _FORALL:
211
+ universals.append(qvar)
212
+ kept_prefix.append(qvar)
213
+ else:
214
+ fname = _gensym("sk", sk_counter, used)
215
+ term = Function(fname, list(universals)) if universals else Constant(fname)
216
+ subst[qvar.name] = term
217
+
218
+ result = matrix
219
+ for name, term in subst.items():
220
+ result = _subst_term(result, name, term)
221
+ for qvar in reversed(kept_prefix):
222
+ result = Quantifier("∀", qvar, result)
223
+ return result
224
+
225
+
226
+ def _subst_term(n: Node, varname: str, term: Node) -> Node:
227
+ """Replace every free Variable(varname) with term inside a formula/term."""
228
+ if isinstance(n, Variable):
229
+ return term if n.name == varname else n
230
+ if isinstance(n, (Constant, Number)):
231
+ return n
232
+ if isinstance(n, Function):
233
+ return Function(n.name, [_subst_term(a, varname, term) for a in n.args])
234
+ if isinstance(n, Atom):
235
+ return Atom(n.predicate, [_subst_term(a, varname, term) for a in n.args])
236
+ if isinstance(n, Not):
237
+ return Not(_subst_term(n.formula, varname, term))
238
+ if isinstance(n, (And, Or)):
239
+ return type(n)(_subst_term(n.left, varname, term),
240
+ _subst_term(n.right, varname, term))
241
+ if isinstance(n, Quantifier):
242
+ if n.variable.name == varname:
243
+ return n # shadowed (cannot happen after standardising apart, but safe)
244
+ return Quantifier(n.type, n.variable, _subst_term(n.formula, varname, term))
245
+ return n
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Horn check
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def is_horn(node: Node) -> bool:
253
+ """Return True iff node's clausal form consists of Horn clauses.
254
+
255
+ Syntactic/clausal definition: skolemise, drop the universal prefix, put the
256
+ matrix into CNF, split into clauses, and check that every clause has at most
257
+ one positive literal.
258
+ """
259
+ sk = skolemize(node)
260
+ _, matrix = _prenex_split(sk)
261
+ cnf = _cnf(matrix)
262
+ for clause in _clauses(cnf):
263
+ if sum(1 for lit in clause if isinstance(lit, Atom)) > 1:
264
+ return False
265
+ return True
266
+
267
+
268
+ def _conjuncts(n: Node) -> list:
269
+ if isinstance(n, And):
270
+ return _conjuncts(n.left) + _conjuncts(n.right)
271
+ return [n]
272
+
273
+
274
+ def _disjuncts(n: Node) -> list:
275
+ if isinstance(n, Or):
276
+ return _disjuncts(n.left) + _disjuncts(n.right)
277
+ return [n]
278
+
279
+
280
+ def _clauses(cnf: Node) -> list:
281
+ """Split a CNF matrix into a list of clauses, each a list of literals."""
282
+ return [_disjuncts(c) for c in _conjuncts(cnf)]
@@ -1,4 +0,0 @@
1
- from .z3_equivalence import formulas_are_equivalent
2
- from .prover9_entailment import check_logical_entailment
3
-
4
- __all__ = ["formulas_are_equivalent", "check_logical_entailment"]
File without changes