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.
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/PKG-INFO +116 -4
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/README.md +115 -3
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/pyproject.toml +1 -1
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/__init__.py +8 -2
- unicode_fol_kit-0.3.1/unicode_fol_kit/atp/__init__.py +9 -0
- unicode_fol_kit-0.3.1/unicode_fol_kit/atp/z3_models.py +45 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/__init__.py +2 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_fol_nodes.py +104 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_msfl_nodes.py +290 -0
- unicode_fol_kit-0.3.1/unicode_fol_kit/fol/normalforms.py +282 -0
- unicode_fol_kit-0.2.1/unicode_fol_kit/atp/__init__.py +0 -4
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/.gitignore +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/LICENSE +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/prover9_entailment.py +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/z3_equivalence.py +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fl.lark +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fol.lark +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfl.lark +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfol.lark +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/terminals.lark +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/msflparser.py +0 -0
- {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/naming.py +0 -0
- {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.
|
|
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
|
|
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
|
|
571
|
-
→ Recommended(x
|
|
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
|
|
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
|
|
547
|
-
→ Recommended(x
|
|
658
|
+
(Effective(y) ⊗ Tolerable(x, y))
|
|
659
|
+
→ Recommended(x, y)
|
|
548
660
|
```
|
|
549
661
|
|
|
550
662
|
### A complete FL example
|
|
@@ -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.
|
|
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)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|