unicode-logic-kit 0.31.0__py3-none-any.whl

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 (237) hide show
  1. unicode_logic_kit/__init__.py +385 -0
  2. unicode_logic_kit/__main__.py +520 -0
  3. unicode_logic_kit/_deadline.py +219 -0
  4. unicode_logic_kit/ace/__init__.py +126 -0
  5. unicode_logic_kit/ace/_align.py +135 -0
  6. unicode_logic_kit/ace/chem_lexicon.py +128 -0
  7. unicode_logic_kit/ace/drs_reader.py +570 -0
  8. unicode_logic_kit/ace/mapping.py +666 -0
  9. unicode_logic_kit/ace/reverse_modal.py +138 -0
  10. unicode_logic_kit/ace/runner.py +551 -0
  11. unicode_logic_kit/ace/translate.py +452 -0
  12. unicode_logic_kit/ace/verbalize.py +1070 -0
  13. unicode_logic_kit/api.py +1284 -0
  14. unicode_logic_kit/atp/__init__.py +177 -0
  15. unicode_logic_kit/atp/_ascii_names.py +113 -0
  16. unicode_logic_kit/atp/_html.py +72 -0
  17. unicode_logic_kit/atp/_substructural_input.py +228 -0
  18. unicode_logic_kit/atp/_tff_problem.py +715 -0
  19. unicode_logic_kit/atp/_tptp_problem.py +1111 -0
  20. unicode_logic_kit/atp/_writer_support.py +289 -0
  21. unicode_logic_kit/atp/clingo_backend.py +1180 -0
  22. unicode_logic_kit/atp/cvc5_backend.py +1385 -0
  23. unicode_logic_kit/atp/eprover_backend.py +732 -0
  24. unicode_logic_kit/atp/finite_domain.py +1055 -0
  25. unicode_logic_kit/atp/fitch.py +1547 -0
  26. unicode_logic_kit/atp/fitch_search.py +551 -0
  27. unicode_logic_kit/atp/hets_backend.py +339 -0
  28. unicode_logic_kit/atp/hybrid_down.py +120 -0
  29. unicode_logic_kit/atp/incremental.py +250 -0
  30. unicode_logic_kit/atp/kripke_enum.py +741 -0
  31. unicode_logic_kit/atp/lambek.py +436 -0
  32. unicode_logic_kit/atp/leo3_backend.py +332 -0
  33. unicode_logic_kit/atp/linear.py +738 -0
  34. unicode_logic_kit/atp/lj.py +705 -0
  35. unicode_logic_kit/atp/logic_backends.py +566 -0
  36. unicode_logic_kit/atp/ltl_tableau.py +1084 -0
  37. unicode_logic_kit/atp/minizinc_backend.py +1402 -0
  38. unicode_logic_kit/atp/modal_tableau.py +1382 -0
  39. unicode_logic_kit/atp/nanocop_backend.py +410 -0
  40. unicode_logic_kit/atp/portfolio.py +489 -0
  41. unicode_logic_kit/atp/protocol.py +1803 -0
  42. unicode_logic_kit/atp/prover9_entailment.py +1153 -0
  43. unicode_logic_kit/atp/resolution.py +1376 -0
  44. unicode_logic_kit/atp/resolution_check.py +1114 -0
  45. unicode_logic_kit/atp/sequent.py +1050 -0
  46. unicode_logic_kit/atp/tableau.py +921 -0
  47. unicode_logic_kit/atp/tableau_check.py +543 -0
  48. unicode_logic_kit/atp/tptp_ncl.py +811 -0
  49. unicode_logic_kit/atp/tptp_tff.py +1546 -0
  50. unicode_logic_kit/atp/tstp.py +1333 -0
  51. unicode_logic_kit/atp/tstp_check.py +1096 -0
  52. unicode_logic_kit/atp/twee_backend.py +236 -0
  53. unicode_logic_kit/atp/twee_check.py +711 -0
  54. unicode_logic_kit/atp/twee_entailment.py +953 -0
  55. unicode_logic_kit/atp/vampire_entailment.py +540 -0
  56. unicode_logic_kit/atp/z3_arith.py +470 -0
  57. unicode_logic_kit/atp/z3_equivalence.py +36 -0
  58. unicode_logic_kit/atp/z3_fuzzy.py +362 -0
  59. unicode_logic_kit/atp/z3_input.py +500 -0
  60. unicode_logic_kit/atp/z3_models.py +208 -0
  61. unicode_logic_kit/chem/__init__.py +88 -0
  62. unicode_logic_kit/chem/_naming.py +284 -0
  63. unicode_logic_kit/chem/cache.py +185 -0
  64. unicode_logic_kit/chem/interop.py +244 -0
  65. unicode_logic_kit/chem/mol.py +525 -0
  66. unicode_logic_kit/chem/signature.py +112 -0
  67. unicode_logic_kit/comorphism.py +497 -0
  68. unicode_logic_kit/dl/__init__.py +384 -0
  69. unicode_logic_kit/dl/classification.py +227 -0
  70. unicode_logic_kit/dl/concepts.py +632 -0
  71. unicode_logic_kit/dl/datatypes.py +818 -0
  72. unicode_logic_kit/dl/owl_functional.py +2433 -0
  73. unicode_logic_kit/dl/owl_manchester.py +1637 -0
  74. unicode_logic_kit/dl/owl_reasoner.py +790 -0
  75. unicode_logic_kit/dl/parser.py +391 -0
  76. unicode_logic_kit/dl/tableau.py +4048 -0
  77. unicode_logic_kit/dl/translate.py +2704 -0
  78. unicode_logic_kit/drt/__init__.py +94 -0
  79. unicode_logic_kit/drt/export.py +179 -0
  80. unicode_logic_kit/drt/nodes.py +506 -0
  81. unicode_logic_kit/drt/parser.py +965 -0
  82. unicode_logic_kit/drt/resolve.py +195 -0
  83. unicode_logic_kit/drt/reverse.py +175 -0
  84. unicode_logic_kit/eval/__init__.py +106 -0
  85. unicode_logic_kit/eval/batch.py +382 -0
  86. unicode_logic_kit/eval/canonical.py +663 -0
  87. unicode_logic_kit/eval/chem_batch.py +606 -0
  88. unicode_logic_kit/eval/converses.py +200 -0
  89. unicode_logic_kit/eval/datasets/__init__.py +136 -0
  90. unicode_logic_kit/eval/datasets/_base.py +263 -0
  91. unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
  92. unicode_logic_kit/eval/datasets/c3po.py +678 -0
  93. unicode_logic_kit/eval/datasets/folio.py +158 -0
  94. unicode_logic_kit/eval/datasets/fracas.py +418 -0
  95. unicode_logic_kit/eval/datasets/groves.py +191 -0
  96. unicode_logic_kit/eval/datasets/logicbench.py +467 -0
  97. unicode_logic_kit/eval/datasets/logicnli.py +303 -0
  98. unicode_logic_kit/eval/datasets/malls.py +133 -0
  99. unicode_logic_kit/eval/datasets/pfolio.py +594 -0
  100. unicode_logic_kit/eval/datasets/pmb.py +242 -0
  101. unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
  102. unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
  103. unicode_logic_kit/eval/datasets/proverqa.py +674 -0
  104. unicode_logic_kit/eval/datasets/willow.py +478 -0
  105. unicode_logic_kit/eval/equivalence.py +466 -0
  106. unicode_logic_kit/eval/exercise_gen.py +533 -0
  107. unicode_logic_kit/eval/explain.py +791 -0
  108. unicode_logic_kit/eval/generality.py +750 -0
  109. unicode_logic_kit/eval/metric_hf.py +458 -0
  110. unicode_logic_kit/eval/predicate_match.py +343 -0
  111. unicode_logic_kit/eval/theory_check.py +1170 -0
  112. unicode_logic_kit/eval/validate.py +306 -0
  113. unicode_logic_kit/fol/__init__.py +177 -0
  114. unicode_logic_kit/fol/_atom_keys.py +510 -0
  115. unicode_logic_kit/fol/_fol_nodes.py +3586 -0
  116. unicode_logic_kit/fol/_free_parameters.py +105 -0
  117. unicode_logic_kit/fol/_ho_nodes.py +448 -0
  118. unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
  119. unicode_logic_kit/fol/_identifiers.py +1091 -0
  120. unicode_logic_kit/fol/_lambek_nodes.py +112 -0
  121. unicode_logic_kit/fol/_linear_nodes.py +352 -0
  122. unicode_logic_kit/fol/_modal_nodes.py +1467 -0
  123. unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
  124. unicode_logic_kit/fol/_numeral_symbols.py +231 -0
  125. unicode_logic_kit/fol/_so_nodes.py +200 -0
  126. unicode_logic_kit/fol/_symbol_names.py +81 -0
  127. unicode_logic_kit/fol/_team_nodes.py +181 -0
  128. unicode_logic_kit/fol/_tptp_symbols.py +551 -0
  129. unicode_logic_kit/fol/_truth_constants.py +117 -0
  130. unicode_logic_kit/fol/casl_export.py +1135 -0
  131. unicode_logic_kit/fol/casl_import.py +929 -0
  132. unicode_logic_kit/fol/derivation.py +367 -0
  133. unicode_logic_kit/fol/dialect_detect.py +70 -0
  134. unicode_logic_kit/fol/dialect_repair.py +537 -0
  135. unicode_logic_kit/fol/frames.py +637 -0
  136. unicode_logic_kit/fol/grammars/terminals.lark +31 -0
  137. unicode_logic_kit/fol/lambda_tools.py +297 -0
  138. unicode_logic_kit/fol/latex_input.py +429 -0
  139. unicode_logic_kit/fol/modal_translation.py +944 -0
  140. unicode_logic_kit/fol/msflparser.py +1033 -0
  141. unicode_logic_kit/fol/naming.py +422 -0
  142. unicode_logic_kit/fol/nodes.py +241 -0
  143. unicode_logic_kit/fol/normalforms.py +492 -0
  144. unicode_logic_kit/fol/pal.py +287 -0
  145. unicode_logic_kit/fol/prolog_export.py +566 -0
  146. unicode_logic_kit/fol/prolog_input.py +505 -0
  147. unicode_logic_kit/fol/prover9_input.py +1325 -0
  148. unicode_logic_kit/fol/qml.py +1760 -0
  149. unicode_logic_kit/fol/qmltp_input.py +525 -0
  150. unicode_logic_kit/fol/sanitize.py +221 -0
  151. unicode_logic_kit/fol/serialize.py +79 -0
  152. unicode_logic_kit/fol/signature.py +1290 -0
  153. unicode_logic_kit/fol/simplify_check.py +544 -0
  154. unicode_logic_kit/fol/spans.py +594 -0
  155. unicode_logic_kit/fol/tptp_input.py +1503 -0
  156. unicode_logic_kit/fol/tptp_repair.py +941 -0
  157. unicode_logic_kit/fol/unification.py +157 -0
  158. unicode_logic_kit/fol/verbalize.py +263 -0
  159. unicode_logic_kit/hets/__init__.py +163 -0
  160. unicode_logic_kit/hets/bridge.py +142 -0
  161. unicode_logic_kit/hets/client.py +748 -0
  162. unicode_logic_kit/hets/docker.py +420 -0
  163. unicode_logic_kit/hets/dol.py +712 -0
  164. unicode_logic_kit/hets/haskell_json.py +355 -0
  165. unicode_logic_kit/hets/owl_backend.py +794 -0
  166. unicode_logic_kit/hets/owl_cli.py +598 -0
  167. unicode_logic_kit/hets/symbols.py +512 -0
  168. unicode_logic_kit/hol/__init__.py +140 -0
  169. unicode_logic_kit/hol/_ho_common.py +323 -0
  170. unicode_logic_kit/hol/_isabelle_binders.py +125 -0
  171. unicode_logic_kit/hol/classical.py +812 -0
  172. unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
  173. unicode_logic_kit/hol/deepshallow/_common.py +177 -0
  174. unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
  175. unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
  176. unicode_logic_kit/hol/deepshallow/modal.py +217 -0
  177. unicode_logic_kit/hol/deepshallow/qml.py +406 -0
  178. unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
  179. unicode_logic_kit/hol/free.py +753 -0
  180. unicode_logic_kit/hol/goedel.py +336 -0
  181. unicode_logic_kit/hol/ho_modal.py +1743 -0
  182. unicode_logic_kit/hol/intuitionistic.py +403 -0
  183. unicode_logic_kit/hol/isabelle_conditional.py +593 -0
  184. unicode_logic_kit/hol/isabelle_modal.py +1908 -0
  185. unicode_logic_kit/hol/isabelle_relevant.py +412 -0
  186. unicode_logic_kit/hol/isabelle_runner.py +1147 -0
  187. unicode_logic_kit/hol/isabelle_substructural.py +884 -0
  188. unicode_logic_kit/hol/lean.py +1018 -0
  189. unicode_logic_kit/hol/manyvalued.py +921 -0
  190. unicode_logic_kit/hol/secondorder.py +687 -0
  191. unicode_logic_kit/hol/thf_modal.py +941 -0
  192. unicode_logic_kit/hol/thirdorder.py +397 -0
  193. unicode_logic_kit/ilp/__init__.py +89 -0
  194. unicode_logic_kit/ilp/readback.py +389 -0
  195. unicode_logic_kit/ilp/separation.py +153 -0
  196. unicode_logic_kit/ilp/task.py +730 -0
  197. unicode_logic_kit/logic.py +163 -0
  198. unicode_logic_kit/mcp/__init__.py +28 -0
  199. unicode_logic_kit/mcp/__main__.py +5 -0
  200. unicode_logic_kit/mcp/chem_tools.py +1031 -0
  201. unicode_logic_kit/mcp/server.py +2453 -0
  202. unicode_logic_kit/mcp/syntax_spec.py +681 -0
  203. unicode_logic_kit/prob/__init__.py +53 -0
  204. unicode_logic_kit/prob/_bdd.py +225 -0
  205. unicode_logic_kit/prob/_column_gen.py +668 -0
  206. unicode_logic_kit/prob/distribution.py +686 -0
  207. unicode_logic_kit/prob/nilsson.py +470 -0
  208. unicode_logic_kit/py.typed +0 -0
  209. unicode_logic_kit/semantics/__init__.py +137 -0
  210. unicode_logic_kit/semantics/_modal_reject.py +156 -0
  211. unicode_logic_kit/semantics/action_models.py +466 -0
  212. unicode_logic_kit/semantics/asp_models.py +1200 -0
  213. unicode_logic_kit/semantics/conditional.py +580 -0
  214. unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
  215. unicode_logic_kit/semantics/free_logic.py +913 -0
  216. unicode_logic_kit/semantics/fuzzy.py +384 -0
  217. unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
  218. unicode_logic_kit/semantics/intuitionistic.py +581 -0
  219. unicode_logic_kit/semantics/kripke.py +1139 -0
  220. unicode_logic_kit/semantics/manyvalued.py +580 -0
  221. unicode_logic_kit/semantics/matrix.py +342 -0
  222. unicode_logic_kit/semantics/model_eval.py +1135 -0
  223. unicode_logic_kit/semantics/modelfinder.py +1036 -0
  224. unicode_logic_kit/semantics/nonmonotonic.py +372 -0
  225. unicode_logic_kit/semantics/relevant.py +331 -0
  226. unicode_logic_kit/semantics/secondorder.py +657 -0
  227. unicode_logic_kit/semantics/structures.py +352 -0
  228. unicode_logic_kit/semantics/tarski.py +975 -0
  229. unicode_logic_kit/semantics/team.py +315 -0
  230. unicode_logic_kit/semantics/team_translation.py +416 -0
  231. unicode_logic_kit/semantics/thirdorder.py +358 -0
  232. unicode_logic_kit/semantics/tnorm.py +85 -0
  233. unicode_logic_kit/semantics/truthtable.py +201 -0
  234. unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
  235. unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
  236. unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
  237. unicode_logic_kit-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1070 @@
1
+ """Kit DRS → ACE text (``drs_to_ace``), with the round trip as the claim.
2
+
3
+ The reverse of :mod:`unicode_logic_kit.ace.mapping`: a DRS built from the
4
+ kit's condition inventory becomes an ACE text plus the user-lexicon entries
5
+ (APE ``-ulextext`` clauses) that make every content word parseable. The
6
+ correctness bar is NOT "natural English" — it is the machine-checked round
7
+ trip :func:`ace_round_trip`: the produced text, fed back through APE and
8
+ :func:`~unicode_logic_kit.ace.mapping.map_ace_drs`, must yield a DRS whose
9
+ :func:`~unicode_logic_kit.drt.export.drs_to_fol` is Z3-equivalent to the
10
+ input's. Everything below is in service of closing that loop, and the whole
11
+ mappable corpus closes it (pinned in ``tests/test_ace_verbalize.py``).
12
+
13
+ Design decisions, each probed against the live APE before being written:
14
+
15
+ - **Referent roles come from the kit's naming convention** — ``e*`` is an
16
+ event, ``g*`` a group, everything else an individual. The mapping names
17
+ referents this way by construction; hand-built DRSs must follow the same
18
+ convention (an "event" named ``x1`` is refused for having no noun, not
19
+ guessed at).
20
+ - **Every individual is introduced by a noun with a variable apposition**
21
+ ("a man X1", "there is a man X1") and referenced as the bare variable
22
+ afterwards. Appositions survive every embedding APE was probed with,
23
+ while "there is … and …" under ``It is false that`` LOSES the second
24
+ conjunct from the negation scope — which is why a multi-clause negated
25
+ box is rewritten (below) rather than rendered as a conjunction.
26
+ - **The first unary condition over a referent is its noun; every further
27
+ unary becomes a predicative clause** ("X1 is a rich."). Predicative
28
+ indefinites come back as a fresh referent plus an equality — Z3-equal to
29
+ the plain unary atom — and using ONE lexical category (noun) for every
30
+ unary predicate avoids noun/adjective ambiguity in the generated
31
+ lexicon. The only adjectives the generator emits are the comparatives
32
+ (``Tall_comp_than`` → "taller than"), which have no noun form.
33
+ - **A negated box that needs more than one clause is rewritten**:
34
+ ``¬∃R(C₁ ∧ … ∧ Cₙ)`` becomes ``∀R_front(C₁ ∧ … ∧ Cₙ₋₁ → ¬∃R_back Cₙ)``
35
+ — an if-then whose consequent is a one-clause negation. The rewrite is a
36
+ classical equivalence, and the round trip re-checks it per DRS.
37
+ - **Surface forms are mechanical and lexicon-defined, not English.** The
38
+ third-person singular and the plural add ``s``/``es``/``ies``, the
39
+ comparative adds ``er``/``r``/``ier``, underscores become hyphens
40
+ (``Bond_to`` → the verb ``bond-to``). "3 mans" is deliberate: the
41
+ emitted ``noun_pl(mans, man, neutr)`` entry DEFINES that surface, APE
42
+ parses it, and the logical symbol underneath is exactly ``man``. No
43
+ morphology is guessed; what the lexicon says is what the text uses.
44
+ - **Strict lower bounds only**: ``Card(g, <=, n)`` and ``Card(g, <, n)``
45
+ are refused — probed: APE reads "at most"/"less than" as the maximality
46
+ LIST condition (the distributive counting reading of the formula route),
47
+ so no ACE surface maps back to a plain upper-bound ``Card`` and the DRS
48
+ round trip cannot close. ``=``/``>=``/``>`` all close.
49
+
50
+ What is refused, by name (:class:`AceVerbalizationError`): a referent with
51
+ no noun, a value constant outside the right-hand side of an equality, a
52
+ constant or predicate whose name is not invertible (the surface must map
53
+ back to the SAME kit symbol through the mapping's own name rules — checked
54
+ programmatically per name, never assumed), binary predicates over
55
+ individuals other than ``Of``/``*_comp_than`` (an ACE verb always carries
56
+ an event, so a 2-place kit atom cannot survive the trip as a 2-place
57
+ atom), events used as participants, groups outside the three probed shapes
58
+ (counted plural, coordination, "each of"), and complex conditions nested
59
+ deeper than the probed fragment.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import re
65
+ from dataclasses import dataclass, field
66
+ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
67
+
68
+ from ..drt.export import drs_to_fol
69
+ from ..drt.nodes import DRS, Card, Condition, Eq, Impl, Neg, Or, Part, Pred
70
+ from .mapping import _kit_predicate, _named_constant, ace_to_drs
71
+ from .reverse_modal import ModalBox, ModalImpl
72
+ from .runner import AceError
73
+ from ..fol._truth_constants import truth_constants_in
74
+
75
+ if TYPE_CHECKING: # pragma: no cover - typing only
76
+ from .translate import AceFormula
77
+
78
+ __all__ = ["drs_to_ace", "formula_to_ace", "ace_round_trip", "AceText",
79
+ "AceRoundTrip", "AceVerbalizationError",
80
+ "modal_drs_to_ace", "modal_formula_to_ace", "modal_ace_round_trip",
81
+ "ModalAceRoundTrip"]
82
+
83
+
84
+ class AceVerbalizationError(AceError):
85
+ """The DRS contains something no probed ACE surface maps back to."""
86
+
87
+
88
+ def _refuse_truth_constants(formula, route: str) -> None:
89
+ """Refuse, by name, a formula that holds a truth constant (``$true`` / ``⊤``,
90
+ ``$false`` / ``⊥``).
91
+
92
+ ACE has no sentence that is true or false of itself: every ACE sentence says
93
+ something about the individuals a noun phrase introduces, and a DRS has no
94
+ condition that is a constant. The refusal is the one the formula routes already
95
+ give for anything outside the standard translation's image
96
+ (:class:`~unicode_logic_kit.drt.reverse.FolToDrsError`), with the reason that is
97
+ the constant's own rather than a predicate-naming rule.
98
+ """
99
+ found = truth_constants_in([formula])
100
+ if not found:
101
+ return
102
+ from ..drt.reverse import FolToDrsError
103
+
104
+ names = " and ".join(atom.predicate for atom in found)
105
+ raise FolToDrsError(
106
+ f"{route}: the truth constant {names} has no ACE sentence: Attempto Controlled "
107
+ "English has no sentence that is true or false by itself (every ACE sentence "
108
+ "says something about the individuals a noun phrase introduces), so no DRS "
109
+ "condition stands for a constant. Write the formula without the constant, or "
110
+ "verbalise it with unicode_logic_kit.fol.to_english, which reads it as "
111
+ "'truth' / 'falsity'.")
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class AceText:
116
+ """The verbalization: ``text`` is the ACE sentences, ``ulex`` the APE
117
+ user-lexicon clauses (``-ulextext`` format, one per line). The
118
+ generator always supplies its content words rather than relying on
119
+ APE's built-in lexicon; duplicates with built-ins are harmless
120
+ (probed)."""
121
+
122
+ text: str
123
+ ulex: str
124
+
125
+ def __str__(self) -> str: # pragma: no cover — convenience
126
+ return self.text
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class AceRoundTrip:
131
+ """One closed (or failed) loop: DRS → ACE → APE → DRS, judged by Z3."""
132
+
133
+ verbalization: AceText
134
+ back: DRS
135
+ equivalent: bool
136
+ detail: str = ""
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Mechanical morphology — lexicon-defined, never guessed (module docstring)
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _s_form(base: str) -> str:
144
+ if re.search(r"(?:[sxz]|ch|sh)$", base):
145
+ return base + "es"
146
+ if re.search(r"[^aeiou]y$", base):
147
+ return base[:-1] + "ies"
148
+ return base + "s"
149
+
150
+
151
+ def _er_form(base: str) -> str:
152
+ if base.endswith("e"):
153
+ return base + "r"
154
+ if re.search(r"[^aeiou]y$", base):
155
+ return base[:-1] + "ier"
156
+ return base + "er"
157
+
158
+
159
+ _PLAIN_ATOM = re.compile(r"^[a-z][a-z0-9_]*$")
160
+
161
+
162
+ def _atom(s: str) -> str:
163
+ """Quote a Prolog atom for a ulex clause when needed."""
164
+ return s if _PLAIN_ATOM.match(s) else f"'{s}'"
165
+
166
+
167
+ def _vowel_start(word: str) -> bool:
168
+ return bool(word) and word[0].lower() in "aeiou"
169
+
170
+
171
+ _COMP_SUFFIX = "_comp_than"
172
+ _REFERENT_RE = re.compile(r"^[a-z][0-9]*$")
173
+ _INT_CONST = re.compile(r"^c_(\d+)$")
174
+
175
+
176
+ def _is_event(name: str) -> bool:
177
+ return bool(re.match(r"^e[0-9]*$", name))
178
+
179
+
180
+ def _is_group(name: str) -> bool:
181
+ return bool(re.match(r"^g[0-9]*$", name))
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Lexicon collector
186
+ # ---------------------------------------------------------------------------
187
+
188
+ class _Lexicon:
189
+ def __init__(self) -> None:
190
+ self.entries: Set[str] = set()
191
+
192
+ @staticmethod
193
+ def _logical(kit_name: str) -> str:
194
+ logical = kit_name[0].lower() + kit_name[1:]
195
+ if _kit_predicate(logical) != kit_name:
196
+ raise AceVerbalizationError(
197
+ f"drs_to_ace: predicate {kit_name!r} is not invertible — "
198
+ f"the round trip would rename it to "
199
+ f"{_kit_predicate(logical)!r}")
200
+ return logical
201
+
202
+ @staticmethod
203
+ def _surface(logical: str) -> str:
204
+ return logical.replace("_", "-")
205
+
206
+ def noun(self, kit_name: str, plural: bool = False) -> str:
207
+ logical = self._logical(kit_name)
208
+ base = self._surface(logical)
209
+ if plural:
210
+ form = _s_form(base)
211
+ self.entries.add(
212
+ f"noun_pl({_atom(form)}, {_atom(logical)}, neutr).")
213
+ return form
214
+ self.entries.add(f"noun_sg({_atom(base)}, {_atom(logical)}, neutr).")
215
+ return base
216
+
217
+ def verb(self, kit_name: str, participants: int, plural: bool) -> str:
218
+ logical = self._logical(kit_name)
219
+ base = self._surface(logical)
220
+ kind = {1: "iv", 2: "tv", 3: "dv"}.get(participants)
221
+ if kind is None:
222
+ raise AceVerbalizationError(
223
+ f"drs_to_ace: verb {kit_name!r} with {participants} "
224
+ "participants — ACE verbs take one to three")
225
+ prep = ", to" if kind == "dv" else ""
226
+ form = base if plural else _s_form(base)
227
+ mode = "infpl" if plural else "finsg"
228
+ self.entries.add(
229
+ f"{kind}_{mode}({_atom(form)}, {_atom(logical)}{prep}).")
230
+ return form
231
+
232
+ def comparative(self, kit_name: str) -> str:
233
+ logical = self._logical(kit_name[:-len(_COMP_SUFFIX)])
234
+ base = self._surface(logical)
235
+ form = _er_form(base)
236
+ self.entries.add(f"adj_itr({_atom(base)}, {_atom(logical)}).")
237
+ self.entries.add(f"adj_itr_comp({_atom(form)}, {_atom(logical)}).")
238
+ return form
239
+
240
+ def adverb(self, kit_name: str) -> str:
241
+ logical = self._logical(kit_name)
242
+ base = self._surface(logical)
243
+ self.entries.add(f"adv({_atom(base)}, {_atom(logical)}).")
244
+ return base
245
+
246
+ def preposition(self, kit_name: str) -> str:
247
+ logical = self._logical(kit_name)
248
+ base = self._surface(logical)
249
+ self.entries.add(f"prep({_atom(base)}, {_atom(logical)}).")
250
+ return base
251
+
252
+ def proper_name(self, constant: str) -> str:
253
+ surface = constant[0].upper() + constant[1:]
254
+ if _named_constant(surface) != constant:
255
+ raise AceVerbalizationError(
256
+ f"drs_to_ace: constant {constant!r} is not invertible — "
257
+ f"the round trip would rename it to "
258
+ f"{_named_constant(surface)!r}")
259
+ self.entries.add(
260
+ f"pn_sg({_atom(surface)}, {_atom(surface)}, neutr).")
261
+ return surface
262
+
263
+ def text(self) -> str:
264
+ return "\n".join(sorted(self.entries))
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Structure helpers
269
+ # ---------------------------------------------------------------------------
270
+
271
+ def _sub_boxes(cond: Condition) -> Tuple[DRS, ...]:
272
+ if isinstance(cond, Neg):
273
+ return (cond.drs,)
274
+ if isinstance(cond, Impl):
275
+ return (cond.antecedent, cond.consequent)
276
+ if isinstance(cond, Or):
277
+ return (cond.left, cond.right)
278
+ return ()
279
+
280
+
281
+ def _cond_names(cond: Condition) -> Set[str]:
282
+ """Every name (referent or constant) a condition touches, sub-boxes'
283
+ FREE names included."""
284
+ if isinstance(cond, Pred):
285
+ return set(cond.args)
286
+ if isinstance(cond, Eq):
287
+ return {cond.a, cond.b}
288
+ if isinstance(cond, Card):
289
+ return {cond.ref}
290
+ if isinstance(cond, Part):
291
+ return {cond.member, cond.group}
292
+ names: Set[str] = set()
293
+ for box in _sub_boxes(cond):
294
+ names |= _box_free_names(box)
295
+ return names
296
+
297
+
298
+ def _box_free_names(box: DRS) -> Set[str]:
299
+ names: Set[str] = set()
300
+ for cond in box.conditions:
301
+ names |= _cond_names(cond)
302
+ return names - set(box.referents)
303
+
304
+
305
+ def _all_referents(box: DRS) -> Set[str]:
306
+ refs = set(box.referents)
307
+ for cond in box.conditions:
308
+ for sub in _sub_boxes(cond):
309
+ refs |= _all_referents(sub)
310
+ return refs
311
+
312
+
313
+ def _membership_duplex(cond: Condition, group: str) -> Optional[str]:
314
+ """The duplex variable if ``cond`` is ``[v | Part_of(v, g)] => …``."""
315
+ if not isinstance(cond, Impl):
316
+ return None
317
+ ante = cond.antecedent
318
+ if (len(ante.referents) == 1 and len(ante.conditions) == 1
319
+ and isinstance(ante.conditions[0], Part)
320
+ and ante.conditions[0].member == ante.referents[0]
321
+ and ante.conditions[0].group == group):
322
+ return ante.referents[0]
323
+ return None
324
+
325
+
326
+ def _card_words(card: Card) -> str:
327
+ if card.op == "=":
328
+ return str(card.n)
329
+ if card.op == ">=":
330
+ return f"at least {card.n}"
331
+ if card.op == ">":
332
+ return f"more than {card.n}"
333
+ # <=, <: probed — APE reads "at most"/"less than" as the maximality
334
+ # list condition, so no surface maps back to a plain upper-bound Card.
335
+ raise AceVerbalizationError(
336
+ f"drs_to_ace: Card op {card.op!r} has no ACE surface that survives "
337
+ "the DRS round trip — APE reads upper bounds as the maximality "
338
+ "list, which is the formula route's counting reading")
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Per-referent state
343
+ # ---------------------------------------------------------------------------
344
+
345
+ @dataclass
346
+ class _Np:
347
+ kind: str # "individual" | "plural" | "coord" | "each"
348
+ noun: Optional[str] = None # kit name of the introducing noun
349
+ extras: List[str] = field(default_factory=list)
350
+ of_target: Optional[str] = None
351
+ card: Optional[Card] = None
352
+ members: List[str] = field(default_factory=list) # kit constants
353
+ duplex: Optional[Impl] = None
354
+ var: Optional[str] = None # "X1" once realized
355
+ introduced: bool = False
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # The generator
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def drs_to_ace(drs: DRS) -> AceText:
363
+ """Verbalize a kit DRS as ACE text plus the lexicon that carries it.
364
+
365
+ See the module docstring for the covered fragment and the refusals;
366
+ :func:`ace_round_trip` is the live self-check that the text means what
367
+ the DRS means.
368
+ """
369
+ drs.validate()
370
+ v = _Verbalizer(drs)
371
+ clauses = _box_clauses(drs, v, env={}, depth=0)
372
+ sentences = [c[0].upper() + c[1:] + "." for c in clauses]
373
+ return AceText(text=" ".join(sentences), ulex=v.lexicon.text())
374
+
375
+
376
+ class _Verbalizer:
377
+ """Shared state across the recursion: lexicon, variable counter, the
378
+ set of declared referent names (for the referent/constant split)."""
379
+
380
+ def __init__(self, root: DRS):
381
+ self.lexicon = _Lexicon()
382
+ self._var_n = 0
383
+ self.declared = {r for r in _all_referents(root)
384
+ if _REFERENT_RE.match(r)}
385
+
386
+ def fresh_var(self) -> str:
387
+ self._var_n += 1
388
+ return f"X{self._var_n}"
389
+
390
+ def is_ref(self, name: str) -> bool:
391
+ return name in self.declared
392
+
393
+ def constant_np(self, name: str) -> str:
394
+ """A constant in NP (participant) position: proper names only."""
395
+ if name.startswith("c_"):
396
+ raise AceVerbalizationError(
397
+ f"drs_to_ace: value constant {name!r} outside the "
398
+ "right-hand side of an equality — no probed ACE surface "
399
+ "puts a value in a participant position")
400
+ return self.lexicon.proper_name(name)
401
+
402
+ def constant_value(self, name: str) -> str:
403
+ """A constant on the right of an equality: value or proper name."""
404
+ m = _INT_CONST.match(name)
405
+ if m:
406
+ return m.group(1)
407
+ if name.startswith("c_"):
408
+ tail = name[2:]
409
+ if re.match(r"^m\d", tail) or re.match(r"^\d+p\d", tail):
410
+ raise AceVerbalizationError(
411
+ f"drs_to_ace: value constant {name!r} — negative and "
412
+ "real values are outside the measured corpus")
413
+ # The string form: string('Tail') → c_Tail closes the loop
414
+ # even when the constant was born as a protected proper name
415
+ # (c_E1) — the renamer gives both the same kit spelling.
416
+ return f'"{tail}"'
417
+ return self.lexicon.proper_name(name)
418
+
419
+
420
+ def _box_clauses(box: DRS, v: _Verbalizer, env: Dict[str, _Np],
421
+ depth: int, modality: Optional[str] = None) -> List[str]:
422
+ """One box → its clause list. ``env`` is MUTATED (introductions land in
423
+ it); callers that need scope isolation pass a copy — see
424
+ :func:`_complex_clause`, where an if-then shares one env between
425
+ antecedent and consequent while or-branches and negations get copies.
426
+
427
+ ``modality`` (one of "must"/"can"/"should"/"may", ACE-7) is threaded
428
+ through to :func:`verb_clause` only — a modal auxiliary sits INSIDE the
429
+ verb phrase (probed: "John must wait.", not a sentence paraphrase), so
430
+ it never touches NP realization, groups, equalities or nested complexes.
431
+ ``None`` (every EXISTING call site) reproduces the pre-ACE-7 behavior
432
+ exactly. Callers that pass a modality (:mod:`unicode_logic_kit.ace.reverse_modal`'s
433
+ two wrappers only) must hand this function a box that renders to EXACTLY
434
+ ONE clause — the only shape measured under a modal auxiliary; see
435
+ :func:`modal_drs_to_ace`, which enforces that after calling this.
436
+ """
437
+ preds = [c for c in box.conditions if isinstance(c, Pred)]
438
+ eqs = [c for c in box.conditions if isinstance(c, Eq)]
439
+ cards = [c for c in box.conditions if isinstance(c, Card)]
440
+ parts = [c for c in box.conditions if isinstance(c, Part)]
441
+ complexes = [c for c in box.conditions if isinstance(c, (Neg, Impl, Or))]
442
+
443
+ events = [r for r in box.referents if _is_event(r)]
444
+ groups = [r for r in box.referents if _is_group(r)]
445
+ individuals = [r for r in box.referents
446
+ if not _is_event(r) and not _is_group(r)]
447
+
448
+ # -- groups: classify each declared group into one of the three shapes
449
+ consumed: Set[int] = set()
450
+ for g in groups:
451
+ g_cards = [c for c in cards if c.ref == g]
452
+ g_parts = [p for p in parts if p.group == g]
453
+ duplex = next((c for c in complexes
454
+ if _membership_duplex(c, g) is not None), None)
455
+ if len(g_cards) != 1:
456
+ raise AceVerbalizationError(
457
+ f"drs_to_ace: group {g!r} needs exactly one Card condition")
458
+ card = g_cards[0]
459
+ np = _Np(kind="?", card=card)
460
+ if duplex is not None:
461
+ consumed.add(id(duplex))
462
+ np.duplex = duplex
463
+ dup_var = _membership_duplex(duplex, g)
464
+ cons = duplex.consequent
465
+ if (not g_parts and not cons.referents
466
+ and len(cons.conditions) == 1
467
+ and isinstance(cons.conditions[0], Pred)
468
+ and cons.conditions[0].args == (dup_var,)):
469
+ np.kind = "plural"
470
+ np.noun = cons.conditions[0].name
471
+ elif g_parts:
472
+ np.kind = "each"
473
+ np.members = [p.member for p in g_parts]
474
+ else:
475
+ raise AceVerbalizationError(
476
+ f"drs_to_ace: group {g!r}: the membership duplex fits "
477
+ "neither the counted-plural nor the each-of shape")
478
+ elif g_parts:
479
+ np.kind = "coord"
480
+ np.members = [p.member for p in g_parts]
481
+ else:
482
+ raise AceVerbalizationError(
483
+ f"drs_to_ace: group {g!r} has neither members nor a "
484
+ "membership duplex — no noun to verbalize")
485
+ if np.kind in ("coord", "each"):
486
+ if card.op != "=" or card.n != len(np.members):
487
+ raise AceVerbalizationError(
488
+ f"drs_to_ace: group {g!r}: Card({g}, {card.op}, "
489
+ f"{card.n}) does not close over its "
490
+ f"{len(np.members)} listed members")
491
+ if any(v.is_ref(m) for m in np.members):
492
+ raise AceVerbalizationError(
493
+ f"drs_to_ace: group {g!r} lists a referent as member "
494
+ "— only constant members are in the measured fragment")
495
+ env[g] = np
496
+ for p in parts:
497
+ if p.group not in env or env[p.group].kind not in ("coord", "each"):
498
+ raise AceVerbalizationError(
499
+ f"drs_to_ace: {p.to_box_notation()} outside a declared "
500
+ "group's cluster")
501
+ for c in cards:
502
+ if c.ref not in env or env[c.ref].kind == "individual":
503
+ raise AceVerbalizationError(
504
+ f"drs_to_ace: {c.to_box_notation()} outside a declared "
505
+ "group's cluster")
506
+
507
+ # -- individuals: nouns, extras, of-attachments
508
+ for r in individuals:
509
+ unaries = [p for p in preds if p.args == (r,)]
510
+ np = _Np(kind="individual")
511
+ if unaries:
512
+ np.noun = unaries[0].name
513
+ np.extras = [p.name for p in unaries[1:]]
514
+ env[r] = np
515
+ for p in preds:
516
+ if (p.name == "Of" and len(p.args) == 2
517
+ and not _is_event(p.args[0])):
518
+ anchor = p.args[0]
519
+ if anchor not in env or env[anchor].kind != "individual" \
520
+ or env[anchor].introduced:
521
+ raise AceVerbalizationError(
522
+ f"drs_to_ace: Of anchor {anchor!r} is not an "
523
+ "individual declared in the same box")
524
+ if env[anchor].of_target is not None:
525
+ raise AceVerbalizationError(
526
+ f"drs_to_ace: {anchor!r} carries more than one Of "
527
+ "relation — outside the measured fragment")
528
+ env[anchor].of_target = p.args[1]
529
+ # A noun-less individual can still ride on an equality: the mapping's
530
+ # copula rule produces exactly this shape for comparatives —
531
+ # Eq(john, x1) ∧ Tall_comp_than(x1, mary) — where x1 exists only to be
532
+ # John. Alias it to the other side and CONSUME that equality (emitting
533
+ # both the alias and the clause would be redundant but harmless; the
534
+ # alias alone keeps the text one sentence).
535
+ consumed_eqs: Set[int] = set()
536
+ for r in individuals:
537
+ if env[r].noun is not None:
538
+ continue
539
+ alias = None
540
+ for eq in eqs:
541
+ if id(eq) in consumed_eqs:
542
+ continue
543
+ other = eq.b if eq.a == r else eq.a if eq.b == r else None
544
+ if other is None or other == r:
545
+ continue
546
+ if not v.is_ref(other) or env.get(other, _Np("?")).noun is not None:
547
+ alias = other
548
+ consumed_eqs.add(id(eq))
549
+ break
550
+ if alias is None:
551
+ raise AceVerbalizationError(
552
+ f"drs_to_ace: referent {r!r} has no noun (no unary "
553
+ "condition) and no equality to ride on — nothing to "
554
+ "introduce it with. Events must be named e*, groups g*.")
555
+ env[r].kind = "alias"
556
+ env[r].of_target = None
557
+ env[r].noun = alias # stores the aliased NAME, not a noun
558
+ env[r].introduced = True
559
+
560
+ # -- classify the event-anchored and remaining predicates
561
+ verb_preds: Dict[str, List[Pred]] = {e: [] for e in events}
562
+ comparatives: List[Pred] = []
563
+ predicative_consts: List[Pred] = []
564
+ for p in preds:
565
+ if p.args and _is_event(p.args[0]):
566
+ if p.args[0] not in verb_preds:
567
+ raise AceVerbalizationError(
568
+ f"drs_to_ace: event {p.args[0]!r} is not declared in "
569
+ "the box of its verb")
570
+ verb_preds[p.args[0]].append(p)
571
+ continue
572
+ if p.name.endswith(_COMP_SUFFIX) and len(p.args) == 2:
573
+ comparatives.append(p)
574
+ continue
575
+ if p.name == "Of" and len(p.args) == 2:
576
+ continue # attached above
577
+ if len(p.args) == 1 and not v.is_ref(p.args[0]):
578
+ predicative_consts.append(p) # Man(john) → "John is a man."
579
+ continue
580
+ if len(p.args) == 1:
581
+ continue # nouns/extras, consumed above
582
+ raise AceVerbalizationError(
583
+ f"drs_to_ace: {p.to_box_notation()} — a {len(p.args)}-place "
584
+ "predicate over individuals has no event, and an ACE verb "
585
+ "always carries one; only Of and *_comp_than survive the trip")
586
+ for e in events:
587
+ others = [c for c in box.conditions
588
+ if not (isinstance(c, Pred) and c.args
589
+ and c.args[0] == e)]
590
+ if any(e in _cond_names(c) for c in others):
591
+ raise AceVerbalizationError(
592
+ f"drs_to_ace: event {e!r} used outside its own verb "
593
+ "conditions — not in the measured fragment")
594
+ if not any(len(p.args) >= 2 for p in verb_preds[e]):
595
+ raise AceVerbalizationError(
596
+ f"drs_to_ace: event {e!r} has no verb condition")
597
+
598
+ # -- NP realization ----------------------------------------------------
599
+ def np_first(r: str) -> str:
600
+ np = env[r]
601
+ np.var = v.fresh_var()
602
+ np.introduced = True
603
+ if np.kind == "plural":
604
+ noun = v.lexicon.noun(np.noun, plural=True)
605
+ return f"{_card_words(np.card)} {noun} {np.var}"
606
+ noun = v.lexicon.noun(np.noun)
607
+ det = "an" if _vowel_start(noun) else "a"
608
+ words = [det, noun, np.var]
609
+ if np.of_target is not None:
610
+ words += ["of", np_use(np.of_target)]
611
+ return " ".join(words)
612
+
613
+ def np_use(name: str) -> str:
614
+ if not v.is_ref(name):
615
+ return v.constant_np(name)
616
+ np = env.get(name)
617
+ if np is None:
618
+ raise AceVerbalizationError(
619
+ f"drs_to_ace: referent {name!r} used but not accessible")
620
+ if np.kind == "alias":
621
+ return (np_use(np.noun) if v.is_ref(np.noun)
622
+ else v.constant_np(np.noun))
623
+ if np.kind == "coord":
624
+ if np.introduced:
625
+ raise AceVerbalizationError(
626
+ "drs_to_ace: coordination group used more than once — "
627
+ "a repeated 'John and Mary' would denote a NEW group")
628
+ np.introduced = True
629
+ return " and ".join(v.constant_np(m) for m in np.members)
630
+ if not np.introduced:
631
+ return np_first(name)
632
+ return np.var
633
+
634
+ def is_plural_subject(name: str) -> bool:
635
+ np = env.get(name)
636
+ return np is not None and np.kind in ("plural", "coord")
637
+
638
+ def verb_clause(e: str) -> str:
639
+ ordered = verb_preds[e]
640
+ verb = next(p for p in ordered if len(p.args) >= 2)
641
+ participants = verb.args[1:]
642
+ if len(participants) > 3:
643
+ raise AceVerbalizationError(
644
+ f"drs_to_ace: verb {verb.name!r} with {len(participants)} "
645
+ "participants — ACE verbs take one to three")
646
+ # Under a modal auxiliary the verb always takes the bare/infinitive
647
+ # lexicon form — probed live: "must florb"/"must chase" parse only
648
+ # against iv_infpl/tv_infpl, "must florbs" is refused even though
649
+ # plain "florbs" alone is fine (test_ace_modal_verbalize.py).
650
+ plural = True if modality is not None else is_plural_subject(participants[0])
651
+ words = [np_use(participants[0])]
652
+ if modality is not None:
653
+ words.append(modality)
654
+ words.append(v.lexicon.verb(verb.name, len(participants), plural))
655
+ if len(participants) >= 2:
656
+ words.append(np_use(participants[1]))
657
+ if len(participants) == 3:
658
+ words += ["to", np_use(participants[2])]
659
+ for m in ordered:
660
+ if m is verb:
661
+ continue
662
+ if len(m.args) == 1:
663
+ words.append(v.lexicon.adverb(m.name))
664
+ elif len(m.args) == 2:
665
+ words += [v.lexicon.preposition(m.name), np_use(m.args[1])]
666
+ else:
667
+ raise AceVerbalizationError(
668
+ f"drs_to_ace: modifier {m.to_box_notation()} with "
669
+ f"{len(m.args) - 1} objects is not in the measured "
670
+ "fragment")
671
+ return " ".join(words)
672
+
673
+ # -- assemble the clause list -----------------------------------------
674
+ clauses: List[str] = []
675
+
676
+ # 1. plural groups always introduce first ("there are …")
677
+ for g in groups:
678
+ if env[g].kind == "plural":
679
+ clauses.append("there are " + np_first(g))
680
+
681
+ # 2. of-carrying individuals (and their referent targets) introduce
682
+ # before any use, target-first — "a dog X1 of X2" needs X2 known.
683
+ def intro_of(r: str, visiting: Set[str]) -> None:
684
+ np = env[r]
685
+ if np.introduced:
686
+ return
687
+ if r in visiting:
688
+ raise AceVerbalizationError(
689
+ f"drs_to_ace: circular Of chain at {r!r}")
690
+ visiting.add(r)
691
+ target = np.of_target
692
+ if target is not None and v.is_ref(target) \
693
+ and env[target].kind == "individual":
694
+ intro_of(target, visiting)
695
+ clauses.append("there is " + np_first(r))
696
+
697
+ for r in individuals:
698
+ if env[r].of_target is not None:
699
+ intro_of(r, set())
700
+
701
+ # 3. verb clauses; an each-of group renders as one distributed clause
702
+ for g in groups:
703
+ if env[g].kind == "each":
704
+ clauses.append(_each_of_clause(g, env[g], v))
705
+ others = [c for c in box.conditions
706
+ if id(c) != id(env[g].duplex)
707
+ and not (isinstance(c, (Card, Part))
708
+ and g in _cond_names(c))]
709
+ if any(g in _cond_names(c) for c in others):
710
+ raise AceVerbalizationError(
711
+ f"drs_to_ace: each-of group {g!r} used outside its "
712
+ "own cluster — not in the measured fragment")
713
+ for e in events:
714
+ clauses.append(verb_clause(e))
715
+
716
+ # 4. remaining introductions (referents used only in equalities,
717
+ # comparatives, sub-boxes — or not at all)
718
+ for r in individuals:
719
+ if not env[r].introduced:
720
+ clauses.append("there is " + np_first(r))
721
+ for g in groups:
722
+ if env[g].kind == "coord" and not env[g].introduced:
723
+ raise AceVerbalizationError(
724
+ f"drs_to_ace: coordination group {g!r} is not the "
725
+ "participant of any verb — nothing carries it")
726
+
727
+ # 5. predicative clauses for extra unaries and constant facts
728
+ for r in individuals:
729
+ for extra in env[r].extras:
730
+ noun = v.lexicon.noun(extra)
731
+ det = "an" if _vowel_start(noun) else "a"
732
+ clauses.append(f"{env[r].var} is {det} {noun}")
733
+ for p in predicative_consts:
734
+ noun = v.lexicon.noun(p.name)
735
+ det = "an" if _vowel_start(noun) else "a"
736
+ clauses.append(f"{v.constant_np(p.args[0])} is {det} {noun}")
737
+
738
+ # 6. equalities and comparatives (every referent is introduced by now)
739
+ def np_or_name(name: str) -> str:
740
+ return np_use(name) if v.is_ref(name) else v.constant_np(name)
741
+
742
+ for eq in eqs:
743
+ if id(eq) in consumed_eqs:
744
+ continue
745
+ left, right = eq.a, eq.b
746
+ if not v.is_ref(left) and v.is_ref(right):
747
+ left, right = right, left # values read best on the right
748
+ right_text = (np_use(right) if v.is_ref(right)
749
+ else v.constant_value(right))
750
+ clauses.append(f"{np_or_name(left)} is {right_text}")
751
+ for p in comparatives:
752
+ clauses.append(f"{np_or_name(p.args[0])} is "
753
+ f"{v.lexicon.comparative(p.name)} than "
754
+ f"{np_or_name(p.args[1])}")
755
+
756
+ # 7. complex conditions
757
+ for cond in complexes:
758
+ if id(cond) in consumed:
759
+ continue
760
+ clauses.append(_complex_clause(cond, v, env, depth))
761
+
762
+ if not clauses:
763
+ raise AceVerbalizationError(
764
+ "drs_to_ace: the box has no conditions to verbalize")
765
+ return clauses
766
+
767
+
768
+ def _each_of_clause(g: str, np: _Np, v: _Verbalizer) -> str:
769
+ """"each of John and Mary waits" — the duplex consequent as one clause
770
+ whose subject is the spoken member list. The distributed box must hold
771
+ exactly one verb over the member variable (plus event-anchored
772
+ modifiers with constant objects) — anything else is refused."""
773
+ dup_var = _membership_duplex(np.duplex, g)
774
+ cons = np.duplex.consequent
775
+ cons_events = [r for r in cons.referents if _is_event(r)]
776
+ if len(cons_events) != 1 or len(cons.referents) != 1:
777
+ raise AceVerbalizationError(
778
+ f"drs_to_ace: each-of group {g!r}: the distributed box must "
779
+ "declare exactly the verb's event")
780
+ event = cons_events[0]
781
+ if not all(isinstance(c, Pred) and c.args and c.args[0] == event
782
+ for c in cons.conditions):
783
+ raise AceVerbalizationError(
784
+ f"drs_to_ace: each-of group {g!r}: the distributed box holds "
785
+ "conditions beside its verb — not in the measured fragment")
786
+ ordered = list(cons.conditions)
787
+ verb = next((p for p in ordered if len(p.args) >= 2), None)
788
+ if verb is None or verb.args[1] != dup_var:
789
+ raise AceVerbalizationError(
790
+ f"drs_to_ace: each-of group {g!r}: the distributed verb must "
791
+ "take the member variable as its subject")
792
+ words = ["each of",
793
+ " and ".join(v.constant_np(m) for m in np.members),
794
+ v.lexicon.verb(verb.name, len(verb.args) - 1, plural=False)]
795
+ objects = list(verb.args[2:])
796
+ if objects:
797
+ words.append(v.constant_np(objects[0]))
798
+ if len(objects) == 2:
799
+ words += ["to", v.constant_np(objects[1])]
800
+ for m in ordered:
801
+ if m is verb:
802
+ continue
803
+ if len(m.args) == 1:
804
+ words.append(v.lexicon.adverb(m.name))
805
+ elif len(m.args) == 2:
806
+ words += [v.lexicon.preposition(m.name),
807
+ v.constant_np(m.args[1])]
808
+ else:
809
+ raise AceVerbalizationError(
810
+ f"drs_to_ace: each-of group {g!r}: modifier shape not in "
811
+ "the measured fragment")
812
+ np.introduced = True
813
+ return " ".join(words)
814
+
815
+
816
+ def _complex_clause(cond: Condition, v: _Verbalizer, env: Dict[str, _Np],
817
+ depth: int) -> str:
818
+ """One embedded box structure as ONE clause. Scope handling probed:
819
+ an if-then SHARES one env copy between antecedent and consequent (the
820
+ antecedent's referents stay visible after "then"), or-branches and
821
+ negations are islands and get their own copies."""
822
+ if depth > 1:
823
+ raise AceVerbalizationError(
824
+ "drs_to_ace: complex conditions nested deeper than the probed "
825
+ "fragment (one embedded level)")
826
+ if isinstance(cond, Neg):
827
+ mark = v._var_n
828
+ inner = _box_clauses(cond.drs, v, dict(env), depth + 1)
829
+ if len(inner) == 1:
830
+ return "it is false that " + inner[0]
831
+ v._var_n = mark # the discarded attempt must not burn variables
832
+ return _rewrite_neg(cond, v, env, depth)
833
+ if isinstance(cond, Impl):
834
+ shared = dict(env)
835
+ ante = _box_clauses(cond.antecedent, v, shared, depth + 1)
836
+ cons = _box_clauses(cond.consequent, v, shared, depth + 1)
837
+ return "if " + " and ".join(ante) + " then " + " and ".join(cons)
838
+ if isinstance(cond, Or):
839
+ left = _box_clauses(cond.left, v, dict(env), depth + 1)
840
+ right = _box_clauses(cond.right, v, dict(env), depth + 1)
841
+ return " and ".join(left) + " or " + " and ".join(right)
842
+ raise AceVerbalizationError(
843
+ f"drs_to_ace: condition {type(cond).__name__} is not verbalizable")
844
+
845
+
846
+ def _rewrite_neg(cond: Neg, v: _Verbalizer, env: Dict[str, _Np],
847
+ depth: int) -> str:
848
+ """``¬∃R(C₁ ∧ … ∧ Cₙ)`` → "if C₁ … Cₙ₋₁ then it is false that Cₙ" —
849
+ the probed way to keep every conjunct inside the negation's scope
850
+ (a "that A and B" surface drops B out of the negation). The split is
851
+ a classical equivalence; the round trip re-checks it per DRS."""
852
+ box = cond.drs
853
+ conds = list(box.conditions)
854
+ back_i = None
855
+ for i in range(len(conds) - 1, -1, -1):
856
+ c = conds[i]
857
+ if isinstance(c, (Card, Part)):
858
+ continue # group infrastructure must stay with its group
859
+ if isinstance(c, Impl) and any(
860
+ _membership_duplex(c, g) is not None
861
+ for g in box.referents):
862
+ continue
863
+ back_i = i
864
+ break
865
+ if back_i is None:
866
+ raise AceVerbalizationError(
867
+ "drs_to_ace: negated box holds only group infrastructure — "
868
+ "not in the measured fragment")
869
+ back = conds[back_i]
870
+ front = conds[:back_i] + conds[back_i + 1:]
871
+ front_names: Set[str] = set()
872
+ for c in front:
873
+ front_names |= _cond_names(c)
874
+ back_only = tuple(r for r in box.referents
875
+ if r in _cond_names(back) and r not in front_names)
876
+ front_refs = tuple(r for r in box.referents if r not in back_only)
877
+ rewritten = Impl(DRS(front_refs, tuple(front)),
878
+ DRS((), (Neg(DRS(back_only, (back,))),)))
879
+ return _complex_clause(rewritten, v, env, depth)
880
+
881
+
882
+ def formula_to_ace(formula) -> AceText:
883
+ """Is this FORMULA expressible as ACE? Convert it — or refuse by name.
884
+
885
+ Two refusal-checked steps: :func:`unicode_logic_kit.drt.fol_to_drs`
886
+ rebuilds the DRS the formula is the standard translation of (raising
887
+ :class:`~unicode_logic_kit.drt.FolToDrsError` for anything outside that
888
+ image — modality, bare universals, formula-level counting, function
889
+ terms), then :func:`drs_to_ace` verbalizes it (raising
890
+ :class:`AceVerbalizationError` for DRSs outside the probed ACE
891
+ fragment). So the two exceptions ARE the expressibility verdict, each
892
+ naming what exactly does not fit. The live self-check for the result
893
+ is ``ace_round_trip(fol_to_drs(formula))``.
894
+ """
895
+ from ..drt.reverse import fol_to_drs
896
+
897
+ _refuse_truth_constants(formula, "formula_to_ace")
898
+ return drs_to_ace(fol_to_drs(formula))
899
+
900
+
901
+ # ---------------------------------------------------------------------------
902
+ # The self-check loop
903
+ # ---------------------------------------------------------------------------
904
+
905
+ def ace_round_trip(drs: DRS, *, timeout: float = 30.0) -> AceRoundTrip:
906
+ """DRS → ACE → APE → DRS, judged by Z3 — the machine self-check.
907
+
908
+ Needs a live APE (:func:`~unicode_logic_kit.ace.runner.ape_available`).
909
+ Raises :class:`AceVerbalizationError` when the DRS is outside the
910
+ verbalizable fragment, and propagates the mapping's own errors if the
911
+ produced text comes back unmappable (which would be a generator bug —
912
+ the tests keep that set empty over the whole mappable corpus).
913
+ """
914
+ from ..eval.equivalence import equivalent
915
+
916
+ verbal = drs_to_ace(drs)
917
+ back = ace_to_drs(verbal.text, ulex=verbal.ulex or None, timeout=timeout)
918
+ ours = drs_to_fol(drs)
919
+ theirs = drs_to_fol(back)
920
+ result = equivalent(ours, theirs)
921
+ detail = "" if result.equivalent else (
922
+ f"forward: {ours.to_unicode_str()} back: {theirs.to_unicode_str()}")
923
+ return AceRoundTrip(verbalization=verbal, back=back,
924
+ equivalent=bool(result.equivalent), detail=detail)
925
+
926
+
927
+ # ---------------------------------------------------------------------------
928
+ # ACE-7: modal/deontic formulas → ACE text
929
+ # ---------------------------------------------------------------------------
930
+
931
+ def _modal_referents_shell(modal) -> DRS:
932
+ """A referents-only synthetic DRS, purely to seed :class:`_Verbalizer`'s
933
+ declared-referent set the same way :func:`drs_to_ace` does — the modal
934
+ wrapper itself is not a DRS (see :mod:`unicode_logic_kit.ace.reverse_modal`),
935
+ but every referent it carries was already checked for global uniqueness
936
+ by the ``fol_to_drs`` call(s) that built it, so combining them here is
937
+ safe."""
938
+ if isinstance(modal, ModalBox):
939
+ refs = _all_referents(modal.drs)
940
+ else:
941
+ assert isinstance(modal, ModalImpl)
942
+ refs = _all_referents(modal.antecedent) | _all_referents(modal.consequent)
943
+ return DRS(tuple(sorted(refs)), ())
944
+
945
+
946
+ def _one_clause(box: DRS, clauses: List[str], where: str) -> str:
947
+ """The only shape measured live under a modal auxiliary: the box's sole
948
+ clause must be the EVENT-ANCHORED verb clause the modality actually
949
+ attaches to. ``modality`` only ever reaches the text through
950
+ :func:`verb_clause`'s own words list (see ``_box_clauses``'s docstring)
951
+ — a box whose single clause instead comes from the "there is a NOUN"
952
+ introduction, the predicative-constant path ("X is a NOUN"), a bare
953
+ equality or a comparative would pass a naive ``len(clauses) == 1`` count
954
+ while silently DROPPING the modal auxiliary from the text (found live:
955
+ ``□Man(john)`` -> "John is a man.", no "must"). Since ``verb_clause`` is
956
+ reached only through a box's OWN event referent (assembly step 3 in
957
+ ``_box_clauses``), requiring that referent set to be exactly one event
958
+ is equivalent to requiring the single clause be that verb clause."""
959
+ single_verb_clause = (len(clauses) == 1 and len(box.referents) == 1
960
+ and _is_event(box.referents[0]))
961
+ if not single_verb_clause:
962
+ raise AceVerbalizationError(
963
+ f"drs_to_ace: a modal box with {len(clauses)} clause(s) {where} "
964
+ "is outside the probed ACE fragment — only a single verb clause "
965
+ "has been measured under a modal auxiliary "
966
+ "('must'/'can'/'should'/'may')")
967
+ return clauses[0]
968
+
969
+
970
+ def modal_drs_to_ace(modal) -> AceText:
971
+ """Verbalize a modal/deontic DRS shell as ACE text (ACE-7).
972
+
973
+ ``modal`` is whatever :func:`~unicode_logic_kit.ace.reverse_modal.fol_to_modal_drs`
974
+ returns: a plain classical :class:`~unicode_logic_kit.drt.nodes.DRS`
975
+ (delegated to :func:`drs_to_ace` unchanged), a
976
+ :class:`~unicode_logic_kit.ace.reverse_modal.ModalBox` ("John must
977
+ wait."), or a :class:`~unicode_logic_kit.ace.reverse_modal.ModalImpl`
978
+ ("If there is a man X1 then X1 must wait."). Both modal shapes are
979
+ restricted to a box that renders to EXACTLY ONE clause — the only shape
980
+ measured live under a modal auxiliary; anything else raises
981
+ :class:`AceVerbalizationError` naming the construct, same discipline as
982
+ :func:`drs_to_ace`.
983
+ """
984
+ if isinstance(modal, DRS):
985
+ return drs_to_ace(modal)
986
+ if isinstance(modal, ModalBox):
987
+ v = _Verbalizer(_modal_referents_shell(modal))
988
+ clause = _one_clause(
989
+ modal.drs,
990
+ _box_clauses(modal.drs, v, {}, depth=0, modality=modal.modality),
991
+ "under its modality")
992
+ sentence = clause[0].upper() + clause[1:] + "."
993
+ return AceText(text=sentence, ulex=v.lexicon.text())
994
+ if isinstance(modal, ModalImpl):
995
+ v = _Verbalizer(_modal_referents_shell(modal))
996
+ shared: Dict[str, _Np] = {}
997
+ ante = _box_clauses(modal.antecedent, v, shared, depth=1)
998
+ cons = _one_clause(
999
+ modal.consequent,
1000
+ _box_clauses(modal.consequent, v, shared, depth=1,
1001
+ modality=modal.modality),
1002
+ "in a duplex consequent")
1003
+ sentence = ("if " + " and ".join(ante) + " then " + cons)
1004
+ sentence = sentence[0].upper() + sentence[1:] + "."
1005
+ return AceText(text=sentence, ulex=v.lexicon.text())
1006
+ raise TypeError(
1007
+ f"modal_drs_to_ace: expected a DRS/ModalBox/ModalImpl, got "
1008
+ f"{type(modal).__name__}")
1009
+
1010
+
1011
+ def modal_formula_to_ace(formula) -> AceText:
1012
+ """Is this modal/deontic FORMULA expressible as ACE? Convert it — or
1013
+ refuse by name (ACE-7's :func:`formula_to_ace` counterpart).
1014
+
1015
+ Two refusal-checked steps, mirroring :func:`formula_to_ace` exactly:
1016
+ :func:`~unicode_logic_kit.ace.reverse_modal.fol_to_modal_drs` rebuilds the
1017
+ DRS (or modal DRS shell) the formula is the standard translation of
1018
+ (raising :class:`~unicode_logic_kit.drt.reverse.FolToDrsError` for
1019
+ anything outside that image — including a modal placement neither of
1020
+ the two probed shapes covers), then :func:`modal_drs_to_ace` verbalizes
1021
+ it (raising :class:`AceVerbalizationError` for a modal box outside the
1022
+ single-clause fragment). The live self-check is
1023
+ :func:`modal_ace_round_trip`.
1024
+ """
1025
+ from .reverse_modal import fol_to_modal_drs
1026
+
1027
+ _refuse_truth_constants(formula, "modal_formula_to_ace")
1028
+ return modal_drs_to_ace(fol_to_modal_drs(formula))
1029
+
1030
+
1031
+ @dataclass(frozen=True)
1032
+ class ModalAceRoundTrip:
1033
+ """One closed (or failed) loop for the modal route: formula → ACE →
1034
+ APE → formula, judged by :func:`~unicode_logic_kit.eval.equivalence.equivalent`
1035
+ (NOT raw Z3 directly — a modal ``Node``'s own ``to_z3`` refuses by
1036
+ design; ``equivalent`` already routes a modal pair through
1037
+ ``modal_decide``/``qml_equivalent``, see that module)."""
1038
+
1039
+ verbalization: AceText
1040
+ back: "unicode_logic_kit.ace.AceFormula"
1041
+ equivalent: bool
1042
+ detail: str = ""
1043
+
1044
+
1045
+ def modal_ace_round_trip(formula, *, timeout: float = 30.0) -> ModalAceRoundTrip:
1046
+ """Formula → ACE → APE → formula, judged by
1047
+ :func:`~unicode_logic_kit.eval.equivalence.equivalent` — the modal
1048
+ counterpart of :func:`ace_round_trip`.
1049
+
1050
+ Needs a live APE (:func:`~unicode_logic_kit.ace.runner.ape_available`).
1051
+ Raises :class:`~unicode_logic_kit.drt.reverse.FolToDrsError` or
1052
+ :class:`AceVerbalizationError` when ``formula`` is outside the
1053
+ verbalizable fragment (see :func:`modal_formula_to_ace`), and
1054
+ propagates :func:`~unicode_logic_kit.ace.translate.ace_to_formula`'s own
1055
+ errors if the produced text comes back unmappable on the backward leg
1056
+ (a generator bug — the tests keep that set empty over the probed
1057
+ corpus).
1058
+ """
1059
+ from ..eval.equivalence import equivalent
1060
+ from .translate import ace_to_formula
1061
+
1062
+ verbal = modal_formula_to_ace(formula)
1063
+ back = ace_to_formula(verbal.text, ulex=verbal.ulex or None,
1064
+ timeout=timeout)
1065
+ result = equivalent(formula, back.formula)
1066
+ detail = "" if result.equivalent else (
1067
+ f"forward: {formula.to_unicode_str()} "
1068
+ f"back: {back.formula.to_unicode_str()}")
1069
+ return ModalAceRoundTrip(verbalization=verbal, back=back,
1070
+ equivalent=bool(result.equivalent), detail=detail)