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.
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/PKG-INFO +92 -1
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/README.md +91 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/pyproject.toml +1 -1
- {unicode_fol_kit-0.3.0 → 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.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/__init__.py +2 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_fol_nodes.py +93 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/_msfl_nodes.py +114 -0
- unicode_fol_kit-0.3.1/unicode_fol_kit/fol/normalforms.py +282 -0
- unicode_fol_kit-0.3.0/unicode_fol_kit/atp/__init__.py +0 -4
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/.gitignore +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/LICENSE +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/prover9_entailment.py +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/atp/z3_equivalence.py +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fl.lark +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/fol.lark +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfl.lark +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/msfol.lark +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/grammars/terminals.lark +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/msflparser.py +0 -0
- {unicode_fol_kit-0.3.0 → unicode_fol_kit-0.3.1}/unicode_fol_kit/fol/naming.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
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)]
|
|
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
|