unicode-fol-kit 0.3.0__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.3.0 → unicode_fol_kit-0.3.1}/PKG-INFO +92 -1
  2. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/README.md +91 -0
  3. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/pyproject.toml +1 -1
  4. {unicode_fol_kit-0.3.0 → 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.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/__init__.py +2 -0
  8. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_fol_nodes.py +93 -0
  9. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_msfl_nodes.py +114 -0
  10. unicode_fol_kit-0.3.1/unicode_fol_kit/fol/normalforms.py +282 -0
  11. unicode_fol_kit-0.3.0/unicode_fol_kit/atp/__init__.py +0 -4
  12. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/.gitignore +0 -0
  13. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/LICENSE +0 -0
  14. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/prover9_entailment.py +0 -0
  15. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/z3_equivalence.py +0 -0
  16. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fl.lark +0 -0
  17. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fol.lark +0 -0
  18. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfl.lark +0 -0
  19. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfol.lark +0 -0
  20. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/terminals.lark +0 -0
  21. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/msflparser.py +0 -0
  22. {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/naming.py +0 -0
  23. {unicode_fol_kit-0.3.0 → 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.3.0
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
@@ -37,7 +37,13 @@ A Python toolkit for parsing and working with first-order logic (FOL) formulas w
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
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
40
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
41
47
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
42
48
  - **TPTP export** — translate formulas to TPTP syntax
43
49
  - **Equivalence checking** — check if two formulas are logically equivalent via Z3
@@ -180,9 +186,42 @@ valid surface tokens and will not round-trip.
180
186
  ```python
181
187
  formula.to_prover9() # '(all x (Human(x) -> Mortal(x)))'
182
188
  formula.to_tptp() # '(![X]: (human(X) => mortal(X)))'
189
+ formula.to_latex() # '\\forall x\\, (Human(x) \\rightarrow Mortal(x))'
183
190
  formula.to_dict() # JSON-serialisable dict
184
191
  ```
185
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
+
186
225
  ### Serialisation
187
226
 
188
227
  ```python
@@ -292,6 +331,38 @@ classical = to_fol(formula)
292
331
  classical_with_facts = to_fol(formula, include_sort_facts=True)
293
332
  ```
294
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
+
295
366
  ### Equivalence checking (Z3)
296
367
 
297
368
  ```python
@@ -304,6 +375,26 @@ f2 = parser.parse("¬P(x) ∨ ¬Q(x)")
304
375
  formulas_are_equivalent(f1, f2) # True
305
376
  ```
306
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
+
307
398
  ### Entailment checking (Prover9)
308
399
 
309
400
  ```python
@@ -13,7 +13,13 @@ A Python toolkit for parsing and working with first-order logic (FOL) formulas w
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
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
16
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
17
23
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
18
24
  - **TPTP export** — translate formulas to TPTP syntax
19
25
  - **Equivalence checking** — check if two formulas are logically equivalent via Z3
@@ -156,9 +162,42 @@ valid surface tokens and will not round-trip.
156
162
  ```python
157
163
  formula.to_prover9() # '(all x (Human(x) -> Mortal(x)))'
158
164
  formula.to_tptp() # '(![X]: (human(X) => mortal(X)))'
165
+ formula.to_latex() # '\\forall x\\, (Human(x) \\rightarrow Mortal(x))'
159
166
  formula.to_dict() # JSON-serialisable dict
160
167
  ```
161
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
+
162
201
  ### Serialisation
163
202
 
164
203
  ```python
@@ -268,6 +307,38 @@ classical = to_fol(formula)
268
307
  classical_with_facts = to_fol(formula, include_sort_facts=True)
269
308
  ```
270
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
+
271
342
  ### Equivalence checking (Z3)
272
343
 
273
344
  ```python
@@ -280,6 +351,26 @@ f2 = parser.parse("¬P(x) ∨ ¬Q(x)")
280
351
  formulas_are_equivalent(f1, f2) # True
281
352
  ```
282
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
+
283
374
  ### Entailment checking (Prover9)
284
375
 
285
376
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unicode-fol-kit"
7
- version = "0.3.0"
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.3.0"
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
  ]
@@ -119,6 +119,17 @@ class Node:
119
119
  from ._msfl_nodes import _uni
120
120
  return _uni(self)
121
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
+
122
133
  def tree_str(self) -> str:
123
134
  """Render the AST as a multi-line ASCII tree using ├──/└── connectors."""
124
135
  label, children = self._tree_parts()
@@ -169,6 +180,88 @@ class Node:
169
180
  new_kwargs[f.name] = val
170
181
  return type(self)(**new_kwargs)
171
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
+
172
265
 
173
266
  # =========================
174
267
  # Term Nodes
@@ -1069,6 +1069,120 @@ def _uni(node) -> str:
1069
1069
  raise TypeError(f"to_unicode_str: unknown node type {cls}")
1070
1070
 
1071
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
+
1072
1186
  # =========================
1073
1187
  # Registry extension
1074
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