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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/PKG-INFO +25 -4
  2. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/README.md +24 -3
  3. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/pyproject.toml +1 -1
  4. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/__init__.py +1 -1
  5. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/_fol_nodes.py +11 -0
  6. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/_msfl_nodes.py +176 -0
  7. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/.gitignore +0 -0
  8. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/LICENSE +0 -0
  9. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/atp/__init__.py +0 -0
  10. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/atp/prover9_entailment.py +0 -0
  11. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/atp/z3_equivalence.py +0 -0
  12. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/__init__.py +0 -0
  13. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/grammars/fl.lark +0 -0
  14. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/grammars/fol.lark +0 -0
  15. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/grammars/msfl.lark +0 -0
  16. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/grammars/msfol.lark +0 -0
  17. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/grammars/terminals.lark +0 -0
  18. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/msflparser.py +0 -0
  19. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/naming.py +0 -0
  20. {unicode_fol_kit-0.2.1 → unicode_fol_kit-0.3.0}/unicode_fol_kit/fol/nodes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unicode-fol-kit
3
- Version: 0.2.1
3
+ Version: 0.3.0
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,6 +36,7 @@ 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
39
40
  - **Z3 export** — translate formulas to Z3 expressions for SMT solving
40
41
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
41
42
  - **TPTP export** — translate formulas to TPTP syntax
@@ -154,6 +155,26 @@ print(formula.tree_str())
154
155
  # └── Variable: x
155
156
  ```
156
157
 
158
+ ### Round-trip to Unicode
159
+
160
+ `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.
161
+
162
+ ```python
163
+ parser = MSFLParser()
164
+
165
+ ast = parser.parse("∀x P(x) ∧ Q(x)")
166
+ ast.to_unicode_str() # '∀x P(x) ∧ Q(x)'
167
+ parser.parse(ast.to_unicode_str()) == ast # True
168
+
169
+ # Precedence-driven parentheses are reconstructed, not the original spelling:
170
+ parser.parse("((P(x) ∧ Q(x)))").to_unicode_str() # 'P(x) ∧ Q(x)'
171
+ parser.parse("P(x) ∧ (Q(x) ∨ R(x))").to_unicode_str() # 'P(x) ∧ (Q(x) ∨ R(x))'
172
+ ```
173
+
174
+ Available on every node, so subformulas render too. The output targets parseable
175
+ ASTs; alpha-renamed variables introduced by `beta_reduce` (e.g. `x_0`) are not
176
+ valid surface tokens and will not round-trip.
177
+
157
178
  ### Exporting to other formats
158
179
 
159
180
  ```python
@@ -560,15 +581,15 @@ parser.parse("(λP. P(x))(Q)")
560
581
  ### A complete MSFOL example
561
582
 
562
583
  ```text
563
- ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y:Person)) → Shares(x, y)
584
+ ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y)) → Shares(x, y)
564
585
  ```
565
586
 
566
587
  ### A complete MSFL example
567
588
 
568
589
  ```text
569
590
  ∀x:Patient ∀y:Treatment
570
- (Effective(y:Treatment) ⊗ Tolerable(x:Patient, y:Treatment))
571
- → Recommended(x:Patient, y:Treatment)
591
+ (Effective(y) ⊗ Tolerable(x, y))
592
+ → Recommended(x, y)
572
593
  ```
573
594
 
574
595
  ### A complete FL example
@@ -12,6 +12,7 @@ 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
15
16
  - **Z3 export** — translate formulas to Z3 expressions for SMT solving
16
17
  - **Prover9 export** — translate formulas to Prover9 syntax for automated theorem proving
17
18
  - **TPTP export** — translate formulas to TPTP syntax
@@ -130,6 +131,26 @@ print(formula.tree_str())
130
131
  # └── Variable: x
131
132
  ```
132
133
 
134
+ ### Round-trip to Unicode
135
+
136
+ `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.
137
+
138
+ ```python
139
+ parser = MSFLParser()
140
+
141
+ ast = parser.parse("∀x P(x) ∧ Q(x)")
142
+ ast.to_unicode_str() # '∀x P(x) ∧ Q(x)'
143
+ parser.parse(ast.to_unicode_str()) == ast # True
144
+
145
+ # Precedence-driven parentheses are reconstructed, not the original spelling:
146
+ parser.parse("((P(x) ∧ Q(x)))").to_unicode_str() # 'P(x) ∧ Q(x)'
147
+ parser.parse("P(x) ∧ (Q(x) ∨ R(x))").to_unicode_str() # 'P(x) ∧ (Q(x) ∨ R(x))'
148
+ ```
149
+
150
+ Available on every node, so subformulas render too. The output targets parseable
151
+ ASTs; alpha-renamed variables introduced by `beta_reduce` (e.g. `x_0`) are not
152
+ valid surface tokens and will not round-trip.
153
+
133
154
  ### Exporting to other formats
134
155
 
135
156
  ```python
@@ -536,15 +557,15 @@ parser.parse("(λP. P(x))(Q)")
536
557
  ### A complete MSFOL example
537
558
 
538
559
  ```text
539
- ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y:Person)) → Shares(x, y)
560
+ ∀x:Person ∀y:Person (Knows(x, y) ∧ Trusted(y)) → Shares(x, y)
540
561
  ```
541
562
 
542
563
  ### A complete MSFL example
543
564
 
544
565
  ```text
545
566
  ∀x:Patient ∀y:Treatment
546
- (Effective(y:Treatment) ⊗ Tolerable(x:Patient, y:Treatment))
547
- → Recommended(x:Patient, y:Treatment)
567
+ (Effective(y) ⊗ Tolerable(x, y))
568
+ → Recommended(x, y)
548
569
  ```
549
570
 
550
571
  ### A complete FL example
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unicode-fol-kit"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Parser and toolkit for first-order logic formulas using Unicode operators"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -17,7 +17,7 @@ from .fol import (
17
17
  )
18
18
  from .atp import formulas_are_equivalent, check_logical_entailment
19
19
 
20
- __version__ = "0.2.1"
20
+ __version__ = "0.3.0"
21
21
 
22
22
  __all__ = [
23
23
  "MSFLParser",
@@ -108,6 +108,17 @@ 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
+
111
122
  def tree_str(self) -> str:
112
123
  """Render the AST as a multi-line ASCII tree using ├──/└── connectors."""
113
124
  label, children = self._tree_parts()
@@ -893,6 +893,182 @@ 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
+
896
1072
  # =========================
897
1073
  # Registry extension
898
1074
  # =========================
File without changes