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.
- unicode_logic_kit/__init__.py +385 -0
- unicode_logic_kit/__main__.py +520 -0
- unicode_logic_kit/_deadline.py +219 -0
- unicode_logic_kit/ace/__init__.py +126 -0
- unicode_logic_kit/ace/_align.py +135 -0
- unicode_logic_kit/ace/chem_lexicon.py +128 -0
- unicode_logic_kit/ace/drs_reader.py +570 -0
- unicode_logic_kit/ace/mapping.py +666 -0
- unicode_logic_kit/ace/reverse_modal.py +138 -0
- unicode_logic_kit/ace/runner.py +551 -0
- unicode_logic_kit/ace/translate.py +452 -0
- unicode_logic_kit/ace/verbalize.py +1070 -0
- unicode_logic_kit/api.py +1284 -0
- unicode_logic_kit/atp/__init__.py +177 -0
- unicode_logic_kit/atp/_ascii_names.py +113 -0
- unicode_logic_kit/atp/_html.py +72 -0
- unicode_logic_kit/atp/_substructural_input.py +228 -0
- unicode_logic_kit/atp/_tff_problem.py +715 -0
- unicode_logic_kit/atp/_tptp_problem.py +1111 -0
- unicode_logic_kit/atp/_writer_support.py +289 -0
- unicode_logic_kit/atp/clingo_backend.py +1180 -0
- unicode_logic_kit/atp/cvc5_backend.py +1385 -0
- unicode_logic_kit/atp/eprover_backend.py +732 -0
- unicode_logic_kit/atp/finite_domain.py +1055 -0
- unicode_logic_kit/atp/fitch.py +1547 -0
- unicode_logic_kit/atp/fitch_search.py +551 -0
- unicode_logic_kit/atp/hets_backend.py +339 -0
- unicode_logic_kit/atp/hybrid_down.py +120 -0
- unicode_logic_kit/atp/incremental.py +250 -0
- unicode_logic_kit/atp/kripke_enum.py +741 -0
- unicode_logic_kit/atp/lambek.py +436 -0
- unicode_logic_kit/atp/leo3_backend.py +332 -0
- unicode_logic_kit/atp/linear.py +738 -0
- unicode_logic_kit/atp/lj.py +705 -0
- unicode_logic_kit/atp/logic_backends.py +566 -0
- unicode_logic_kit/atp/ltl_tableau.py +1084 -0
- unicode_logic_kit/atp/minizinc_backend.py +1402 -0
- unicode_logic_kit/atp/modal_tableau.py +1382 -0
- unicode_logic_kit/atp/nanocop_backend.py +410 -0
- unicode_logic_kit/atp/portfolio.py +489 -0
- unicode_logic_kit/atp/protocol.py +1803 -0
- unicode_logic_kit/atp/prover9_entailment.py +1153 -0
- unicode_logic_kit/atp/resolution.py +1376 -0
- unicode_logic_kit/atp/resolution_check.py +1114 -0
- unicode_logic_kit/atp/sequent.py +1050 -0
- unicode_logic_kit/atp/tableau.py +921 -0
- unicode_logic_kit/atp/tableau_check.py +543 -0
- unicode_logic_kit/atp/tptp_ncl.py +811 -0
- unicode_logic_kit/atp/tptp_tff.py +1546 -0
- unicode_logic_kit/atp/tstp.py +1333 -0
- unicode_logic_kit/atp/tstp_check.py +1096 -0
- unicode_logic_kit/atp/twee_backend.py +236 -0
- unicode_logic_kit/atp/twee_check.py +711 -0
- unicode_logic_kit/atp/twee_entailment.py +953 -0
- unicode_logic_kit/atp/vampire_entailment.py +540 -0
- unicode_logic_kit/atp/z3_arith.py +470 -0
- unicode_logic_kit/atp/z3_equivalence.py +36 -0
- unicode_logic_kit/atp/z3_fuzzy.py +362 -0
- unicode_logic_kit/atp/z3_input.py +500 -0
- unicode_logic_kit/atp/z3_models.py +208 -0
- unicode_logic_kit/chem/__init__.py +88 -0
- unicode_logic_kit/chem/_naming.py +284 -0
- unicode_logic_kit/chem/cache.py +185 -0
- unicode_logic_kit/chem/interop.py +244 -0
- unicode_logic_kit/chem/mol.py +525 -0
- unicode_logic_kit/chem/signature.py +112 -0
- unicode_logic_kit/comorphism.py +497 -0
- unicode_logic_kit/dl/__init__.py +384 -0
- unicode_logic_kit/dl/classification.py +227 -0
- unicode_logic_kit/dl/concepts.py +632 -0
- unicode_logic_kit/dl/datatypes.py +818 -0
- unicode_logic_kit/dl/owl_functional.py +2433 -0
- unicode_logic_kit/dl/owl_manchester.py +1637 -0
- unicode_logic_kit/dl/owl_reasoner.py +790 -0
- unicode_logic_kit/dl/parser.py +391 -0
- unicode_logic_kit/dl/tableau.py +4048 -0
- unicode_logic_kit/dl/translate.py +2704 -0
- unicode_logic_kit/drt/__init__.py +94 -0
- unicode_logic_kit/drt/export.py +179 -0
- unicode_logic_kit/drt/nodes.py +506 -0
- unicode_logic_kit/drt/parser.py +965 -0
- unicode_logic_kit/drt/resolve.py +195 -0
- unicode_logic_kit/drt/reverse.py +175 -0
- unicode_logic_kit/eval/__init__.py +106 -0
- unicode_logic_kit/eval/batch.py +382 -0
- unicode_logic_kit/eval/canonical.py +663 -0
- unicode_logic_kit/eval/chem_batch.py +606 -0
- unicode_logic_kit/eval/converses.py +200 -0
- unicode_logic_kit/eval/datasets/__init__.py +136 -0
- unicode_logic_kit/eval/datasets/_base.py +263 -0
- unicode_logic_kit/eval/datasets/_proofwriter_proof.py +422 -0
- unicode_logic_kit/eval/datasets/c3po.py +678 -0
- unicode_logic_kit/eval/datasets/folio.py +158 -0
- unicode_logic_kit/eval/datasets/fracas.py +418 -0
- unicode_logic_kit/eval/datasets/groves.py +191 -0
- unicode_logic_kit/eval/datasets/logicbench.py +467 -0
- unicode_logic_kit/eval/datasets/logicnli.py +303 -0
- unicode_logic_kit/eval/datasets/malls.py +133 -0
- unicode_logic_kit/eval/datasets/pfolio.py +594 -0
- unicode_logic_kit/eval/datasets/pmb.py +242 -0
- unicode_logic_kit/eval/datasets/prontoqa.py +611 -0
- unicode_logic_kit/eval/datasets/proofwriter.py +1431 -0
- unicode_logic_kit/eval/datasets/proverqa.py +674 -0
- unicode_logic_kit/eval/datasets/willow.py +478 -0
- unicode_logic_kit/eval/equivalence.py +466 -0
- unicode_logic_kit/eval/exercise_gen.py +533 -0
- unicode_logic_kit/eval/explain.py +791 -0
- unicode_logic_kit/eval/generality.py +750 -0
- unicode_logic_kit/eval/metric_hf.py +458 -0
- unicode_logic_kit/eval/predicate_match.py +343 -0
- unicode_logic_kit/eval/theory_check.py +1170 -0
- unicode_logic_kit/eval/validate.py +306 -0
- unicode_logic_kit/fol/__init__.py +177 -0
- unicode_logic_kit/fol/_atom_keys.py +510 -0
- unicode_logic_kit/fol/_fol_nodes.py +3586 -0
- unicode_logic_kit/fol/_free_parameters.py +105 -0
- unicode_logic_kit/fol/_ho_nodes.py +448 -0
- unicode_logic_kit/fol/_hybrid_nodes.py +308 -0
- unicode_logic_kit/fol/_identifiers.py +1091 -0
- unicode_logic_kit/fol/_lambek_nodes.py +112 -0
- unicode_logic_kit/fol/_linear_nodes.py +352 -0
- unicode_logic_kit/fol/_modal_nodes.py +1467 -0
- unicode_logic_kit/fol/_msfl_nodes.py +2196 -0
- unicode_logic_kit/fol/_numeral_symbols.py +231 -0
- unicode_logic_kit/fol/_so_nodes.py +200 -0
- unicode_logic_kit/fol/_symbol_names.py +81 -0
- unicode_logic_kit/fol/_team_nodes.py +181 -0
- unicode_logic_kit/fol/_tptp_symbols.py +551 -0
- unicode_logic_kit/fol/_truth_constants.py +117 -0
- unicode_logic_kit/fol/casl_export.py +1135 -0
- unicode_logic_kit/fol/casl_import.py +929 -0
- unicode_logic_kit/fol/derivation.py +367 -0
- unicode_logic_kit/fol/dialect_detect.py +70 -0
- unicode_logic_kit/fol/dialect_repair.py +537 -0
- unicode_logic_kit/fol/frames.py +637 -0
- unicode_logic_kit/fol/grammars/terminals.lark +31 -0
- unicode_logic_kit/fol/lambda_tools.py +297 -0
- unicode_logic_kit/fol/latex_input.py +429 -0
- unicode_logic_kit/fol/modal_translation.py +944 -0
- unicode_logic_kit/fol/msflparser.py +1033 -0
- unicode_logic_kit/fol/naming.py +422 -0
- unicode_logic_kit/fol/nodes.py +241 -0
- unicode_logic_kit/fol/normalforms.py +492 -0
- unicode_logic_kit/fol/pal.py +287 -0
- unicode_logic_kit/fol/prolog_export.py +566 -0
- unicode_logic_kit/fol/prolog_input.py +505 -0
- unicode_logic_kit/fol/prover9_input.py +1325 -0
- unicode_logic_kit/fol/qml.py +1760 -0
- unicode_logic_kit/fol/qmltp_input.py +525 -0
- unicode_logic_kit/fol/sanitize.py +221 -0
- unicode_logic_kit/fol/serialize.py +79 -0
- unicode_logic_kit/fol/signature.py +1290 -0
- unicode_logic_kit/fol/simplify_check.py +544 -0
- unicode_logic_kit/fol/spans.py +594 -0
- unicode_logic_kit/fol/tptp_input.py +1503 -0
- unicode_logic_kit/fol/tptp_repair.py +941 -0
- unicode_logic_kit/fol/unification.py +157 -0
- unicode_logic_kit/fol/verbalize.py +263 -0
- unicode_logic_kit/hets/__init__.py +163 -0
- unicode_logic_kit/hets/bridge.py +142 -0
- unicode_logic_kit/hets/client.py +748 -0
- unicode_logic_kit/hets/docker.py +420 -0
- unicode_logic_kit/hets/dol.py +712 -0
- unicode_logic_kit/hets/haskell_json.py +355 -0
- unicode_logic_kit/hets/owl_backend.py +794 -0
- unicode_logic_kit/hets/owl_cli.py +598 -0
- unicode_logic_kit/hets/symbols.py +512 -0
- unicode_logic_kit/hol/__init__.py +140 -0
- unicode_logic_kit/hol/_ho_common.py +323 -0
- unicode_logic_kit/hol/_isabelle_binders.py +125 -0
- unicode_logic_kit/hol/classical.py +812 -0
- unicode_logic_kit/hol/deepshallow/__init__.py +45 -0
- unicode_logic_kit/hol/deepshallow/_common.py +177 -0
- unicode_logic_kit/hol/deepshallow/conditional.py +225 -0
- unicode_logic_kit/hol/deepshallow/intuitionistic.py +181 -0
- unicode_logic_kit/hol/deepshallow/modal.py +217 -0
- unicode_logic_kit/hol/deepshallow/qml.py +406 -0
- unicode_logic_kit/hol/deepshallow/relevant.py +206 -0
- unicode_logic_kit/hol/free.py +753 -0
- unicode_logic_kit/hol/goedel.py +336 -0
- unicode_logic_kit/hol/ho_modal.py +1743 -0
- unicode_logic_kit/hol/intuitionistic.py +403 -0
- unicode_logic_kit/hol/isabelle_conditional.py +593 -0
- unicode_logic_kit/hol/isabelle_modal.py +1908 -0
- unicode_logic_kit/hol/isabelle_relevant.py +412 -0
- unicode_logic_kit/hol/isabelle_runner.py +1147 -0
- unicode_logic_kit/hol/isabelle_substructural.py +884 -0
- unicode_logic_kit/hol/lean.py +1018 -0
- unicode_logic_kit/hol/manyvalued.py +921 -0
- unicode_logic_kit/hol/secondorder.py +687 -0
- unicode_logic_kit/hol/thf_modal.py +941 -0
- unicode_logic_kit/hol/thirdorder.py +397 -0
- unicode_logic_kit/ilp/__init__.py +89 -0
- unicode_logic_kit/ilp/readback.py +389 -0
- unicode_logic_kit/ilp/separation.py +153 -0
- unicode_logic_kit/ilp/task.py +730 -0
- unicode_logic_kit/logic.py +163 -0
- unicode_logic_kit/mcp/__init__.py +28 -0
- unicode_logic_kit/mcp/__main__.py +5 -0
- unicode_logic_kit/mcp/chem_tools.py +1031 -0
- unicode_logic_kit/mcp/server.py +2453 -0
- unicode_logic_kit/mcp/syntax_spec.py +681 -0
- unicode_logic_kit/prob/__init__.py +53 -0
- unicode_logic_kit/prob/_bdd.py +225 -0
- unicode_logic_kit/prob/_column_gen.py +668 -0
- unicode_logic_kit/prob/distribution.py +686 -0
- unicode_logic_kit/prob/nilsson.py +470 -0
- unicode_logic_kit/py.typed +0 -0
- unicode_logic_kit/semantics/__init__.py +137 -0
- unicode_logic_kit/semantics/_modal_reject.py +156 -0
- unicode_logic_kit/semantics/action_models.py +466 -0
- unicode_logic_kit/semantics/asp_models.py +1200 -0
- unicode_logic_kit/semantics/conditional.py +580 -0
- unicode_logic_kit/semantics/dynamic_epistemic.py +95 -0
- unicode_logic_kit/semantics/free_logic.py +913 -0
- unicode_logic_kit/semantics/fuzzy.py +384 -0
- unicode_logic_kit/semantics/fuzzy_kripke.py +442 -0
- unicode_logic_kit/semantics/intuitionistic.py +581 -0
- unicode_logic_kit/semantics/kripke.py +1139 -0
- unicode_logic_kit/semantics/manyvalued.py +580 -0
- unicode_logic_kit/semantics/matrix.py +342 -0
- unicode_logic_kit/semantics/model_eval.py +1135 -0
- unicode_logic_kit/semantics/modelfinder.py +1036 -0
- unicode_logic_kit/semantics/nonmonotonic.py +372 -0
- unicode_logic_kit/semantics/relevant.py +331 -0
- unicode_logic_kit/semantics/secondorder.py +657 -0
- unicode_logic_kit/semantics/structures.py +352 -0
- unicode_logic_kit/semantics/tarski.py +975 -0
- unicode_logic_kit/semantics/team.py +315 -0
- unicode_logic_kit/semantics/team_translation.py +416 -0
- unicode_logic_kit/semantics/thirdorder.py +358 -0
- unicode_logic_kit/semantics/tnorm.py +85 -0
- unicode_logic_kit/semantics/truthtable.py +201 -0
- unicode_logic_kit-0.31.0.dist-info/METADATA +333 -0
- unicode_logic_kit-0.31.0.dist-info/RECORD +237 -0
- unicode_logic_kit-0.31.0.dist-info/WHEEL +4 -0
- unicode_logic_kit-0.31.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
"""Deductive verification layer for a collection of predicate definitions.
|
|
2
|
+
|
|
3
|
+
An LLM translates chemical class definitions to FOL, and classification is
|
|
4
|
+
then MODEL CHECKING a definition against a molecule structure
|
|
5
|
+
(:mod:`unicode_logic_kit.semantics.model_eval`). That measures whether the
|
|
6
|
+
definitions are individually *useful* against real data (a definition that
|
|
7
|
+
is too general matches far too much, and the precision cost stays invisible
|
|
8
|
+
until it is measured against a corpus). It says nothing about whether the
|
|
9
|
+
definitions are *coherent as a theory*: an auxiliary predicate is optimised
|
|
10
|
+
only in the context of the one class that introduces it and then silently
|
|
11
|
+
inherited by every subclass, which overfits it to that one context, and a
|
|
12
|
+
prover-backed OWL-subsumption check reports only proved/not proved, with
|
|
13
|
+
**no counterexample** when a subsumption fails. This module is the layer that
|
|
14
|
+
sits between "does this predicate fire on real molecules" and "is this
|
|
15
|
+
DEFINITION SET internally consistent" — it never touches a molecule; every
|
|
16
|
+
question here is decided purely from the definitions' own logical content,
|
|
17
|
+
via the kit's existing prover chain (:mod:`unicode_logic_kit.atp.protocol`)
|
|
18
|
+
and finite model finder.
|
|
19
|
+
|
|
20
|
+
Data model — why a plain ``name -> body`` mapping
|
|
21
|
+
---------------------------------------------------
|
|
22
|
+
A "definition" is a **named 0-ary predicate** with a defining FORMULA: exactly
|
|
23
|
+
the ``className <=> body`` shape such a translation produces, e.g.::
|
|
24
|
+
|
|
25
|
+
molecule <=> net_charge_neutral
|
|
26
|
+
organicMolecularEntity <=> ?[A1]: (molecule & c(A1))
|
|
27
|
+
carboxylicAcid <=> (carbonOxoacid & ?[A1,A2,A3]: (c(A1) & o(A2) & o(A3) &
|
|
28
|
+
has_1_hs(A3) & bDOUBLE(A1,A2) & bSINGLE(A1,A3)))
|
|
29
|
+
|
|
30
|
+
Every LHS here (``molecule``, ``organicMolecularEntity``, ``carboxylicAcid``,
|
|
31
|
+
``carbonOxoacid``) is used with NO arguments — a ChEBI class membership fact is
|
|
32
|
+
a global property of "the one molecule this structure represents"
|
|
33
|
+
(:class:`~unicode_logic_kit.semantics.structures.FiniteStructure` already builds
|
|
34
|
+
its 0-ary extension exactly this way: ``frozenset()`` or ``frozenset({()})``).
|
|
35
|
+
So this module represents a definition set as ``Mapping[str, Node]``: the key
|
|
36
|
+
is the defined 0-ary predicate's name, the value is its BODY (the right-hand
|
|
37
|
+
side of the ``<=>`` — never re-wrap it in ``Iff(Atom(name, ()), body)``, the
|
|
38
|
+
mapping key already carries the left-hand side). This is a deliberate,
|
|
39
|
+
narrower choice than a general "Definition object with parameters" — a
|
|
40
|
+
parametrised definition (``isRing(x) <=> ...``) would need a substitution-
|
|
41
|
+
with-arguments mechanic none of these definitions exercise; admitting it
|
|
42
|
+
here would be a real design expansion, not a small one, so a body atom that
|
|
43
|
+
names a defined predicate is only ever recognised as a USE of that definition
|
|
44
|
+
when it appears with ZERO arguments (:func:`dependency_graph`, :func:`unfold`)
|
|
45
|
+
— a same-named atom used elsewhere with arguments is simply a different,
|
|
46
|
+
primitive symbol, left completely alone.
|
|
47
|
+
|
|
48
|
+
Why unfolding is the hard part
|
|
49
|
+
-------------------------------
|
|
50
|
+
Definitions reference each other (``carboxylicAcid`` names ``carbonOxoacid``,
|
|
51
|
+
``organicMolecularEntity`` names ``molecule``), and a subsumption or
|
|
52
|
+
satisfiability question is only meaningful against the CLOSED formula in
|
|
53
|
+
terms of primitive (undefined) predicates — a prover asked to check
|
|
54
|
+
``carboxylicAcid -> carbonOxoacid`` while ``carbonOxoacid`` is still an
|
|
55
|
+
uninterpreted 0-ary atom would trivially fail to see that the axioms actually
|
|
56
|
+
force it (nothing tells the prover ``carbonOxoacid`` unfolds to anything).
|
|
57
|
+
:func:`unfold` collects that background theory correctly by *substituting*
|
|
58
|
+
every defined-predicate use with its own (recursively unfolded) body, so what
|
|
59
|
+
reaches the prover is a single sentence over only primitive vocabulary — no
|
|
60
|
+
separate list of background axioms is needed or possible, because a defined
|
|
61
|
+
0-ary predicate reused in two different bodies is DEFINITIONALLY (not just
|
|
62
|
+
materially) identical to its expansion everywhere, and substitution is the
|
|
63
|
+
operation that makes that identity explicit to a syntactic prover.
|
|
64
|
+
|
|
65
|
+
Plain substitution like this is only correct when every body is CLOSED
|
|
66
|
+
(no free variable): pasting a body verbatim into an enclosing definition's
|
|
67
|
+
own ``∃``/``∀`` scope would otherwise silently CAPTURE a free variable that
|
|
68
|
+
never meant to refer to that binder at all. :func:`unfold` therefore refuses
|
|
69
|
+
up front — see :class:`NonClosedDefinition` — rather than attempt
|
|
70
|
+
capture-avoiding (alpha-renaming) substitution: this module's data model is
|
|
71
|
+
0-ARY class-membership definitions (see "Data model" above), for which a
|
|
72
|
+
free variable is a malformed input, not a feature to accommodate.
|
|
73
|
+
|
|
74
|
+
Circular definitions are MEANINGLESS, not merely hard
|
|
75
|
+
-------------------------------------------------------
|
|
76
|
+
``className <=> P(..., className, ...)`` pins down no fact: any truth value
|
|
77
|
+
of ``className`` is self-consistent with such a biconditional (a fixed point
|
|
78
|
+
is not guaranteed to be UNIQUE — the classical case for why circular
|
|
79
|
+
"definitions" are not definitions at all). This module never guesses a
|
|
80
|
+
reading for one (least/greatest fixed point, etc.) — :func:`unfold` refuses
|
|
81
|
+
with :class:`CyclicDefinition` the moment substitution would revisit a name
|
|
82
|
+
already being expanded on the current path, and :func:`find_cycles` reports
|
|
83
|
+
every such cycle in the STATIC dependency graph up front so a caller can see
|
|
84
|
+
the problem without triggering it via a substitution call. A definition
|
|
85
|
+
reachable from a cycle cannot be unfolded at all, so :func:`check_satisfiable`
|
|
86
|
+
/ :func:`check_subsumption` report it as its own status, ``"cyclic"`` —
|
|
87
|
+
distinct from ``"unknown"`` (an honest "we could not decide this within the
|
|
88
|
+
budget") because a cyclic definition is not merely undecided, it is a PROVEN
|
|
89
|
+
defect in the definition set, on exactly the same footing as an unsatisfiable
|
|
90
|
+
one (see :attr:`TheoryReport.proved_problems`).
|
|
91
|
+
|
|
92
|
+
Three-valued honesty, end to end
|
|
93
|
+
-----------------------------------
|
|
94
|
+
Every check in this module returns one of a definitive PROVEN status
|
|
95
|
+
(``"unsatisfiable"`` / ``"entailed"`` / ``"refuted"``, the last always
|
|
96
|
+
carrying a countermodel — see :func:`check_subsumption`), the definitive
|
|
97
|
+
defect status ``"cyclic"``, or ``"unknown"``. ``"unknown"`` is never
|
|
98
|
+
downgraded to ``False``/``"refuted"`` and never upgraded to ``True``/
|
|
99
|
+
``"entailed"`` — a timeout, a step-bound, or an incomplete backend all mean
|
|
100
|
+
exactly "not decided", full stop, matching this kit's house rule (see
|
|
101
|
+
CLAUDE.md) that an unknown result is never reported as a false one.
|
|
102
|
+
:func:`check_satisfiable` and :func:`check_subsumption` are thin, honest
|
|
103
|
+
wrappers around :func:`unicode_logic_kit.api.prove` (which already returns this
|
|
104
|
+
same three/four-valued :class:`~unicode_logic_kit.atp.protocol.Verdict`
|
|
105
|
+
contract over its whole backend chain — Z3, the kit's own tableau/resolution
|
|
106
|
+
provers, and the finite model finder by default): they add nothing but the
|
|
107
|
+
unfolding step and the translation from "prove/refute a goal" to "is this
|
|
108
|
+
definition satisfiable" / "does this subsumption hold", so an unsatisfiable
|
|
109
|
+
result is exactly a Z3/tableau/resolution-checkable PROOF of a contradiction
|
|
110
|
+
and a refuted subsumption's countermodel is exactly the model the backend
|
|
111
|
+
that refuted it actually produced (never fabricated or approximated).
|
|
112
|
+
|
|
113
|
+
What this module is deliberately silent about
|
|
114
|
+
-------------------------------------------------
|
|
115
|
+
It never runs a definition against real data (that is model_eval's job) and
|
|
116
|
+
it never suggests a fix for a broken definition — it only proves, precisely,
|
|
117
|
+
THAT something is broken (dead classifier, meaningless cycle, missing
|
|
118
|
+
conjunct) and, for a refuted subsumption, exhibits WHY via a concrete
|
|
119
|
+
countermodel. That is the whole value-add over a bare prover-backed
|
|
120
|
+
subsumption check, which reports only proved/not proved with no witness.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
from dataclasses import dataclass, field
|
|
124
|
+
from typing import Dict, FrozenSet, List, Mapping, Optional, Sequence, Tuple
|
|
125
|
+
|
|
126
|
+
from ..fol.nodes import Node, Atom, Not, Implies
|
|
127
|
+
from ..atp.protocol import PROVED, REFUTED, UNKNOWN
|
|
128
|
+
# eval -> atp is already the established direction in this module (see the
|
|
129
|
+
# PROVED/REFUTED/UNKNOWN import above), so to_html() reuses the small,
|
|
130
|
+
# private HTML-page helper the atp Fitch/sequent renderers share rather than
|
|
131
|
+
# reimplementing page-wrapping/escaping a third time — see atp/_html's own
|
|
132
|
+
# module docstring for why it is safe to import across the package boundary
|
|
133
|
+
# this way (it imports nothing back).
|
|
134
|
+
from ..atp._html import esc_html, html_page
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"Definitions",
|
|
138
|
+
"CyclicDefinition", "UnfoldDepthExceeded", "NonClosedDefinition",
|
|
139
|
+
"DEFAULT_MAX_DEPTH",
|
|
140
|
+
"dependency_graph", "find_cycles", "unfold",
|
|
141
|
+
"SatisfiabilityResult", "check_satisfiable",
|
|
142
|
+
"SubsumptionResult", "check_subsumption",
|
|
143
|
+
"TheoryReport", "check_theory",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
#: A definition set: defined 0-ary predicate name -> its defining BODY (the
|
|
147
|
+
#: right-hand side of ``name <=> body``). See the module docstring's "Data
|
|
148
|
+
#: model" section for why this shape and not a parametrised Definition object.
|
|
149
|
+
Definitions = Mapping[str, Node]
|
|
150
|
+
|
|
151
|
+
#: How many nested definitional expansions :func:`unfold` will perform along
|
|
152
|
+
#: one substitution path before giving up with :class:`UnfoldDepthExceeded`.
|
|
153
|
+
#: Chosen generously relative to any real ChEBI-style class hierarchy (a
|
|
154
|
+
#: handful to a few dozen levels of subclassing) while still catching a
|
|
155
|
+
#: genuinely runaway/undetected-cycle situation instead of recursing forever.
|
|
156
|
+
DEFAULT_MAX_DEPTH = 50
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Errors
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
class CyclicDefinition(ValueError):
|
|
164
|
+
""":func:`unfold` refuses to expand a definition through a cycle.
|
|
165
|
+
|
|
166
|
+
Raised the moment substitution would revisit a name already being
|
|
167
|
+
expanded on the CURRENT path (a live on-stack check, the standard DFS
|
|
168
|
+
cycle test) — see the module docstring's "Circular definitions are
|
|
169
|
+
MEANINGLESS" section for why this is a refusal, not an approximation.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UnfoldDepthExceeded(ValueError):
|
|
174
|
+
""":func:`unfold`'s substitution chain exceeded ``max_depth`` without
|
|
175
|
+
terminating in primitive-only vocabulary.
|
|
176
|
+
|
|
177
|
+
This is a SEPARATE failure mode from :class:`CyclicDefinition`: the
|
|
178
|
+
on-stack check catches every cycle regardless of depth, so hitting this
|
|
179
|
+
instead means either a definition chain that is genuinely deeper than
|
|
180
|
+
``max_depth`` (raise it), or — mixed with sibling reuse of the same name
|
|
181
|
+
at different points of a body — a combinatorial expansion that never
|
|
182
|
+
revisits a name on any single path yet keeps growing (rare, but not
|
|
183
|
+
provably impossible for a hand-written definition set); either way this
|
|
184
|
+
is reported rather than silently truncating the formula.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class NonClosedDefinition(ValueError):
|
|
189
|
+
""":func:`unfold` refuses to substitute a definition body that is not a
|
|
190
|
+
CLOSED sentence (has a free logical variable).
|
|
191
|
+
|
|
192
|
+
Why this matters: :func:`unfold` substitutes a defined 0-ary atom
|
|
193
|
+
IN PLACE, wherever it occurs — including inside an enclosing definition's
|
|
194
|
+
own ``∃``/``∀`` scopes. If the substituted body itself has a free
|
|
195
|
+
variable with the SAME NAME as one of those enclosing binders, plain
|
|
196
|
+
(non-capture-avoiding) substitution silently re-binds it: the free
|
|
197
|
+
occurrence, which denoted "whatever the definition's own body meant by
|
|
198
|
+
that name" (nothing, since it was never bound — an ill-formed input to
|
|
199
|
+
begin with), now denotes "the enclosing definition's witness" instead —
|
|
200
|
+
two occurrences that do not denote the same thing get silently
|
|
201
|
+
identified. That is a SOUNDNESS bug, not a decidability one, so it is
|
|
202
|
+
caught up front rather than risked on every substitution.
|
|
203
|
+
|
|
204
|
+
Why refuse rather than substitute capture-freely (alpha-rename the
|
|
205
|
+
clashing binder before substituting): every definition in this module's
|
|
206
|
+
data model is a 0-ARY class-membership fact (see the module docstring's
|
|
207
|
+
"Data model" section) — its body is only ever quantified INTERNALLY
|
|
208
|
+
(auxiliary existentials over its own chemical-substructure witnesses,
|
|
209
|
+
say), never parametrised by a variable a caller substitutes in from
|
|
210
|
+
outside. A body with a genuine free variable is therefore not a
|
|
211
|
+
well-formed 0-ary definition at all — it is a typo, an accidentally
|
|
212
|
+
unbound witness, or an attempt at a parametrised definition this module
|
|
213
|
+
was never designed to represent (see the module docstring's "why a plain
|
|
214
|
+
name -> body mapping" section: admitting parametrised definitions would
|
|
215
|
+
be a real design expansion, not a small one). Capture-avoiding
|
|
216
|
+
substitution would quietly accept and "fix" that malformed input by
|
|
217
|
+
inventing a reading for it, rather than reporting the actual defect —
|
|
218
|
+
exactly the kind of silent approximation this kit's house rules forbid.
|
|
219
|
+
This mirrors the ``KeyError`` :func:`unfold` already raises for a name
|
|
220
|
+
that is not even a key of ``definitions``: both are input-validation
|
|
221
|
+
failures decided BEFORE any unfolding/proving work begins, not
|
|
222
|
+
proof-budget questions like :class:`UnfoldDepthExceeded`.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Dependency graph / cycle detection
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _direct_uses(body: Node, definitions: Definitions) -> FrozenSet[str]:
|
|
231
|
+
"""The defined names ``body`` uses AS A DEFINITION (0-ary atom, name is a
|
|
232
|
+
key of ``definitions``) — direct references only, not transitive."""
|
|
233
|
+
used = set()
|
|
234
|
+
for node in body.walk():
|
|
235
|
+
if isinstance(node, Atom) and not node.args and node.predicate in definitions:
|
|
236
|
+
used.add(node.predicate)
|
|
237
|
+
return frozenset(used)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def dependency_graph(definitions: Definitions) -> Dict[str, FrozenSet[str]]:
|
|
241
|
+
"""Direct definitional dependencies: ``name -> frozenset(names it uses)``.
|
|
242
|
+
|
|
243
|
+
A name is counted as "used" only where it occurs as a bare 0-ary atom
|
|
244
|
+
whose predicate is itself a key of ``definitions`` — matching exactly what
|
|
245
|
+
:func:`unfold` will substitute (see the module docstring). This is the
|
|
246
|
+
DIRECT (one-hop) graph; :func:`find_cycles` walks it to find cycles, and
|
|
247
|
+
a topological/transitive closure is deliberately not offered here — a
|
|
248
|
+
caller that needs it can compute it from this graph, and adding it would
|
|
249
|
+
just be more surface for something one line of graph code already covers.
|
|
250
|
+
"""
|
|
251
|
+
return {name: _direct_uses(body, definitions) for name, body in definitions.items()}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _normalize_cycle(cycle: Tuple[str, ...]) -> Tuple[str, ...]:
|
|
255
|
+
"""Canonical form of a cycle (``(n0, n1, ..., n0)``, first==last) for
|
|
256
|
+
deduplication: rotate to start at the lexicographically smallest name,
|
|
257
|
+
keeping direction (A->B->A and B->A->B are the SAME cycle; A->B->A and
|
|
258
|
+
A->C->A, if both exist, are DIFFERENT cycles and stay distinct)."""
|
|
259
|
+
core = cycle[:-1]
|
|
260
|
+
best = None
|
|
261
|
+
for i in range(len(core)):
|
|
262
|
+
rotated = core[i:] + core[:i]
|
|
263
|
+
candidate = rotated + (rotated[0],)
|
|
264
|
+
if best is None or candidate < best:
|
|
265
|
+
best = candidate
|
|
266
|
+
return best
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def find_cycles(definitions: Definitions) -> Tuple[Tuple[str, ...], ...]:
|
|
270
|
+
"""Every elementary cycle in the definitional dependency graph.
|
|
271
|
+
|
|
272
|
+
Each cycle is a name sequence ``(n0, n1, ..., nk, n0)`` — first and last
|
|
273
|
+
entry identical, tracing the dependency chain that closes the loop. A
|
|
274
|
+
direct self-reference (``A <=> ... A ...``) appears as ``("A", "A")``.
|
|
275
|
+
Results are deduplicated (see :func:`_normalize_cycle`) and returned in
|
|
276
|
+
sorted order, so the output is deterministic regardless of dict iteration
|
|
277
|
+
order.
|
|
278
|
+
|
|
279
|
+
Implementation: plain DFS from every node with an explicit "on the current
|
|
280
|
+
path" set — the standard cycle test, and exactly what :func:`unfold`'s
|
|
281
|
+
live on-stack check also performs (this function just runs it eagerly,
|
|
282
|
+
up front, over the whole graph, rather than only along the one path a
|
|
283
|
+
particular :func:`unfold` call happens to take). Complexity is bounded by
|
|
284
|
+
the number of simple paths in the graph, which is fine for a definition
|
|
285
|
+
set the size of a class hierarchy (sparse, near-tree-shaped with the
|
|
286
|
+
occasional bug introducing a 2-3 node loop) but is NOT guaranteed
|
|
287
|
+
polynomial on a dense graph — this is a correctness-first, not a
|
|
288
|
+
scalability-first, implementation.
|
|
289
|
+
"""
|
|
290
|
+
deps = dependency_graph(definitions)
|
|
291
|
+
cycles: set = set()
|
|
292
|
+
|
|
293
|
+
def dfs(node: str, path: Tuple[str, ...], on_path: FrozenSet[str]) -> None:
|
|
294
|
+
for nxt in sorted(deps.get(node, ())):
|
|
295
|
+
if nxt in on_path:
|
|
296
|
+
idx = path.index(nxt)
|
|
297
|
+
cycles.add(_normalize_cycle(path[idx:] + (nxt,)))
|
|
298
|
+
else:
|
|
299
|
+
dfs(nxt, path + (nxt,), on_path | {nxt})
|
|
300
|
+
|
|
301
|
+
for start in sorted(deps):
|
|
302
|
+
dfs(start, (start,), frozenset({start}))
|
|
303
|
+
|
|
304
|
+
return tuple(sorted(cycles))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Unfolding
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _require_closed_definitions(definitions: Definitions) -> None:
|
|
312
|
+
"""Raise :class:`NonClosedDefinition` if any body in ``definitions`` is
|
|
313
|
+
not a closed sentence.
|
|
314
|
+
|
|
315
|
+
Reuses :func:`unicode_logic_kit.eval.validate.validate`'s own closedness
|
|
316
|
+
check (``ValidationReport.is_closed``, which counts a free ``Variable``
|
|
317
|
+
OR a free ``LambdaVar`` against closedness) rather than reimplementing
|
|
318
|
+
it. Checks EVERY body in ``definitions``, not just ones reachable from a
|
|
319
|
+
particular call's starting name: :func:`unfold`'s substitution walk can
|
|
320
|
+
reach any definition transitively, and which ones it actually reaches
|
|
321
|
+
depends on ``name`` and on the bodies themselves, so a check narrower
|
|
322
|
+
than "the whole set" would make whether a malformed body gets caught
|
|
323
|
+
depend on which name happens to be unfolded first — see
|
|
324
|
+
:class:`NonClosedDefinition` for why this is refused at all.
|
|
325
|
+
"""
|
|
326
|
+
from .validate import validate # lazy: mirrors this module's other lazy imports
|
|
327
|
+
|
|
328
|
+
offenders = []
|
|
329
|
+
for name, body in definitions.items():
|
|
330
|
+
report = validate(body)
|
|
331
|
+
if not report.is_closed:
|
|
332
|
+
offenders.append((name, report.free_variable_names))
|
|
333
|
+
if offenders:
|
|
334
|
+
detail = "; ".join(
|
|
335
|
+
f"{name!r} has free variable(s) {', '.join(free_names)}"
|
|
336
|
+
for name, free_names in offenders
|
|
337
|
+
)
|
|
338
|
+
raise NonClosedDefinition(
|
|
339
|
+
"unfold: every definition body must be a CLOSED sentence — this "
|
|
340
|
+
"module's data model is 0-ary class-membership definitions, not "
|
|
341
|
+
f"parametrised ones (see the module docstring): {detail}. See "
|
|
342
|
+
"NonClosedDefinition's docstring for why this is refused rather "
|
|
343
|
+
"than patched via capture-avoiding substitution."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _substitute(node: Node, definitions: Definitions, active: Tuple[str, ...],
|
|
348
|
+
depth: int, max_depth: int) -> Node:
|
|
349
|
+
"""Recursively replace every 0-ary defined-predicate atom in ``node`` with
|
|
350
|
+
its (further-unfolded) body. ``active`` is the ordered stack of names
|
|
351
|
+
currently being expanded ON THIS PATH — membership in it is the live
|
|
352
|
+
cycle check; ``depth`` is how many substitutions deep this path already
|
|
353
|
+
is. Sibling reuse of the same name (e.g. ``A & A`` in one body) is NOT a
|
|
354
|
+
cycle: each occurrence recurses independently with the SAME ``active``/
|
|
355
|
+
``depth`` it was called with, since neither branch is "inside" the
|
|
356
|
+
other's expansion."""
|
|
357
|
+
if isinstance(node, Atom) and not node.args and node.predicate in definitions:
|
|
358
|
+
pred = node.predicate
|
|
359
|
+
if pred in active:
|
|
360
|
+
raise CyclicDefinition(
|
|
361
|
+
f"unfold: definitional cycle detected: {' -> '.join(active + (pred,))} "
|
|
362
|
+
"— a circular biconditional like this pins down no fact (any truth "
|
|
363
|
+
"value is self-consistent with it), so it is refused rather than "
|
|
364
|
+
"evaluated. See find_cycles() to locate every such cycle up front."
|
|
365
|
+
)
|
|
366
|
+
if depth >= max_depth:
|
|
367
|
+
raise UnfoldDepthExceeded(
|
|
368
|
+
f"unfold: expanding {active[0]!r} did not reach a primitive-only "
|
|
369
|
+
f"formula within max_depth={max_depth} steps (currently expanding: "
|
|
370
|
+
f"{' -> '.join(active + (pred,))}). Raise max_depth if this chain is "
|
|
371
|
+
"genuinely this deep, or check find_cycles() for an undetected cycle."
|
|
372
|
+
)
|
|
373
|
+
body = definitions[pred]
|
|
374
|
+
return _substitute(body, definitions, active + (pred,), depth + 1, max_depth)
|
|
375
|
+
return node.map_children(
|
|
376
|
+
lambda c: _substitute(c, definitions, active, depth, max_depth))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def unfold(name: str, definitions: Definitions, *,
|
|
380
|
+
max_depth: int = DEFAULT_MAX_DEPTH) -> Node:
|
|
381
|
+
"""Expand ``definitions[name]`` until only primitive predicates remain.
|
|
382
|
+
|
|
383
|
+
Every 0-ary atom in the body whose predicate is itself a key of
|
|
384
|
+
``definitions`` is replaced by that key's own (recursively unfolded)
|
|
385
|
+
body — see the module docstring's "Why unfolding is the hard part". The
|
|
386
|
+
result is a single formula a prover can check directly, with no separate
|
|
387
|
+
background-axiom list needed (there is nothing FOR such a list to say
|
|
388
|
+
that substitution has not already said).
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
KeyError: ``name`` is not a key of ``definitions``.
|
|
392
|
+
NonClosedDefinition: some body in ``definitions`` has a free
|
|
393
|
+
variable — checked over the WHOLE ``definitions`` mapping before
|
|
394
|
+
any substitution begins (see :class:`NonClosedDefinition` for
|
|
395
|
+
why: substituting a non-closed body can silently capture its
|
|
396
|
+
free variable in an unrelated enclosing binder of the same
|
|
397
|
+
name).
|
|
398
|
+
CyclicDefinition: expanding ``name`` would revisit a name already
|
|
399
|
+
being expanded on the current path — see :func:`find_cycles` to
|
|
400
|
+
locate the cycle without triggering this.
|
|
401
|
+
UnfoldDepthExceeded: the expansion did not terminate within
|
|
402
|
+
``max_depth`` nested substitutions.
|
|
403
|
+
"""
|
|
404
|
+
if name not in definitions:
|
|
405
|
+
raise KeyError(
|
|
406
|
+
f"unfold: {name!r} is not a defined name (known: {sorted(definitions)})")
|
|
407
|
+
_require_closed_definitions(definitions)
|
|
408
|
+
return _substitute(definitions[name], definitions, (name,), 0, max_depth)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Satisfiability
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
_SATISFIABILITY_STATUSES = ("satisfiable", "unsatisfiable", "unknown", "cyclic")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass(frozen=True)
|
|
419
|
+
class SatisfiabilityResult:
|
|
420
|
+
"""Outcome of :func:`check_satisfiable`.
|
|
421
|
+
|
|
422
|
+
``status``:
|
|
423
|
+
``"unsatisfiable"`` — PROVEN: the unfolded definition is a
|
|
424
|
+
contradiction, so ``name`` can hold of no possible molecule/object
|
|
425
|
+
whatsoever — a dead classifier that silently returns 0 matches
|
|
426
|
+
forever. ``detail`` names the backend that proved it.
|
|
427
|
+
``"satisfiable"`` — PROVEN: some model makes ``name`` true.
|
|
428
|
+
``witness`` is that model, in the SAME JSON-able shape as
|
|
429
|
+
:attr:`unicode_logic_kit.atp.protocol.Verdict.countermodel`
|
|
430
|
+
(``{"kind": ..., ...}``) — it may occasionally be ``None`` even
|
|
431
|
+
on this status, for a backend that can refute unsatisfiability
|
|
432
|
+
without producing a readable model (rare; the default chain's
|
|
433
|
+
Z3/model-finder members always attach one).
|
|
434
|
+
``"unknown"`` — neither was established within the backend chain's
|
|
435
|
+
budget. NEVER a claim of unsatisfiability — see the module
|
|
436
|
+
docstring's three-valued-honesty section.
|
|
437
|
+
``"cyclic"`` — ``name`` could not even be unfolded (it is reachable
|
|
438
|
+
from a definitional cycle); ``unfolded`` is ``None`` in this case.
|
|
439
|
+
This is its own PROVEN-defect status, not folded into
|
|
440
|
+
``"unknown"`` — see :func:`find_cycles`.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
name: str
|
|
444
|
+
status: str
|
|
445
|
+
unfolded: Optional[Node]
|
|
446
|
+
witness: Optional[dict]
|
|
447
|
+
backend: Optional[str]
|
|
448
|
+
detail: Optional[str]
|
|
449
|
+
|
|
450
|
+
def __post_init__(self):
|
|
451
|
+
if self.status not in _SATISFIABILITY_STATUSES:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
f"SatisfiabilityResult: unknown status {self.status!r} "
|
|
454
|
+
f"(use one of {_SATISFIABILITY_STATUSES})")
|
|
455
|
+
|
|
456
|
+
def to_dict(self) -> dict:
|
|
457
|
+
return {
|
|
458
|
+
"name": self.name,
|
|
459
|
+
"status": self.status,
|
|
460
|
+
"unfolded": self.unfolded.to_dict() if self.unfolded is not None else None,
|
|
461
|
+
"witness": self.witness,
|
|
462
|
+
"backend": self.backend,
|
|
463
|
+
"detail": self.detail,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def check_satisfiable(name: str, definitions: Definitions, *,
|
|
468
|
+
timeout: int = 10000,
|
|
469
|
+
backends: Optional[Sequence[str]] = None,
|
|
470
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
471
|
+
**options) -> SatisfiabilityResult:
|
|
472
|
+
"""Is ``name``'s (unfolded) definition satisfiable by ANY object at all?
|
|
473
|
+
|
|
474
|
+
Decided by handing ``Not(unfold(name, definitions))`` to
|
|
475
|
+
:func:`unicode_logic_kit.api.prove` (empty premises, i.e. a validity check)
|
|
476
|
+
over its FOL backend chain (Z3, the kit's tableau/resolution provers, and
|
|
477
|
+
the finite model finder, by default — see
|
|
478
|
+
:func:`unicode_logic_kit.atp.protocol.default_chain`): proving
|
|
479
|
+
``¬unfolded`` valid is exactly proving ``unfolded`` unsatisfiable, and
|
|
480
|
+
REFUTING ``¬unfolded`` means some backend produced an actual model of
|
|
481
|
+
``unfolded`` — a genuine, checkable witness rather than a guess. This is
|
|
482
|
+
the "und/oder api.prove" reading: the model finder is already one member
|
|
483
|
+
of that chain, so this one call already uses both routes the finder and
|
|
484
|
+
the prover can offer.
|
|
485
|
+
|
|
486
|
+
``backends``/``timeout``/``**options`` are forwarded verbatim to
|
|
487
|
+
:func:`~unicode_logic_kit.api.prove` — as there, extra ``options`` (e.g.
|
|
488
|
+
``max_steps=``) are sent to EVERY backend in the chain, so only combine
|
|
489
|
+
them with an explicit single-backend ``backends=[...]`` list unless every
|
|
490
|
+
member of the chain genuinely accepts that keyword.
|
|
491
|
+
|
|
492
|
+
Raises:
|
|
493
|
+
KeyError: ``name`` is not a key of ``definitions``.
|
|
494
|
+
NonClosedDefinition: some body in ``definitions`` is not closed —
|
|
495
|
+
see :func:`unfold`.
|
|
496
|
+
"""
|
|
497
|
+
if name not in definitions:
|
|
498
|
+
raise KeyError(
|
|
499
|
+
f"check_satisfiable: {name!r} is not a defined name "
|
|
500
|
+
f"(known: {sorted(definitions)})")
|
|
501
|
+
try:
|
|
502
|
+
unfolded = unfold(name, definitions, max_depth=max_depth)
|
|
503
|
+
except CyclicDefinition as exc:
|
|
504
|
+
return SatisfiabilityResult(name=name, status="cyclic", unfolded=None,
|
|
505
|
+
witness=None, backend=None, detail=str(exc))
|
|
506
|
+
except UnfoldDepthExceeded as exc:
|
|
507
|
+
# NOT the same finding as CyclicDefinition: budget exhaustion proves
|
|
508
|
+
# nothing about whether name's definition chain is actually acyclic
|
|
509
|
+
# (it may well be a long but perfectly acyclic hierarchy — see
|
|
510
|
+
# UnfoldDepthExceeded's own docstring) — reporting "cyclic" here would
|
|
511
|
+
# upgrade an undecided budget question into a PROVEN-defect claim
|
|
512
|
+
# (see TheoryReport.proved_problems, which treats "cyclic" as exactly
|
|
513
|
+
# that). Honest status is "unknown".
|
|
514
|
+
return SatisfiabilityResult(name=name, status="unknown", unfolded=None,
|
|
515
|
+
witness=None, backend=None, detail=str(exc))
|
|
516
|
+
|
|
517
|
+
from .. import api # lazy: avoids importing the whole facade at module load
|
|
518
|
+
|
|
519
|
+
verdict = api.prove(Not(unfolded), timeout=timeout, backends=backends, **options)
|
|
520
|
+
if verdict.status == PROVED:
|
|
521
|
+
return SatisfiabilityResult(
|
|
522
|
+
name=name, status="unsatisfiable", unfolded=unfolded, witness=None,
|
|
523
|
+
backend=verdict.backend,
|
|
524
|
+
detail=f"{name} can hold of no object: its unfolded definition is a "
|
|
525
|
+
f"contradiction, proved by {verdict.backend}.")
|
|
526
|
+
if verdict.status == REFUTED:
|
|
527
|
+
return SatisfiabilityResult(
|
|
528
|
+
name=name, status="satisfiable", unfolded=unfolded,
|
|
529
|
+
witness=verdict.countermodel, backend=verdict.backend,
|
|
530
|
+
detail=f"a model satisfying {name} was found by {verdict.backend}.")
|
|
531
|
+
return SatisfiabilityResult(
|
|
532
|
+
name=name, status="unknown", unfolded=unfolded, witness=None,
|
|
533
|
+
backend=verdict.backend if verdict.backend != "chain" else None,
|
|
534
|
+
detail=verdict.detail or "neither proved unsatisfiable nor witnessed "
|
|
535
|
+
"satisfiable within the given backend budget")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# Subsumption
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
_SUBSUMPTION_STATUSES = ("entailed", "refuted", "unknown", "cyclic")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@dataclass(frozen=True)
|
|
546
|
+
class SubsumptionResult:
|
|
547
|
+
"""Outcome of :func:`check_subsumption` — does ``Def(sub) |= Def(sup)``?
|
|
548
|
+
|
|
549
|
+
``status``:
|
|
550
|
+
``"entailed"`` — PROVEN: every object satisfying ``sub``'s unfolded
|
|
551
|
+
definition also satisfies ``sup``'s.
|
|
552
|
+
``"refuted"`` — PROVEN false: ``countermodel`` is ALWAYS populated
|
|
553
|
+
here (never ``None`` — see the defensive fallback in
|
|
554
|
+
:func:`check_subsumption`'s implementation), a concrete witness
|
|
555
|
+
where ``sub`` holds and ``sup`` does not. This is the module's
|
|
556
|
+
main value-add over a bare prover's witness-free "not proved": a
|
|
557
|
+
reader can see EXACTLY which conjunct of the superclass the
|
|
558
|
+
subclass definition forgot.
|
|
559
|
+
``"unknown"`` — neither proved nor refuted within the backend
|
|
560
|
+
budget (e.g. a timeout, or a deliberately tiny step bound) —
|
|
561
|
+
NEVER reported as ``"refuted"``.
|
|
562
|
+
``"cyclic"`` — ``sub`` or ``sup`` could not be unfolded (reachable
|
|
563
|
+
from a definitional cycle); ``verdict``/``countermodel`` are
|
|
564
|
+
``None`` in this case.
|
|
565
|
+
|
|
566
|
+
``verdict`` is the underlying :class:`~unicode_logic_kit.atp.protocol.Verdict`
|
|
567
|
+
(as a dict, via its own ``to_dict()``) for full provenance — ``None`` only
|
|
568
|
+
for ``"cyclic"``. ``explanation`` is a short plain-English gloss: for
|
|
569
|
+
``"refuted"`` it is built the same way ``api.countermodel`` builds
|
|
570
|
+
``explanation_nl`` (:func:`unicode_logic_kit.eval.explain.explain_countermodel`),
|
|
571
|
+
for every other status it falls back to the verdict's own ``detail``/the
|
|
572
|
+
cycle message.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
sub: str
|
|
576
|
+
sup: str
|
|
577
|
+
status: str
|
|
578
|
+
verdict: Optional[dict]
|
|
579
|
+
countermodel: Optional[dict]
|
|
580
|
+
explanation: Optional[str]
|
|
581
|
+
|
|
582
|
+
def __post_init__(self):
|
|
583
|
+
if self.status not in _SUBSUMPTION_STATUSES:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
f"SubsumptionResult: unknown status {self.status!r} "
|
|
586
|
+
f"(use one of {_SUBSUMPTION_STATUSES})")
|
|
587
|
+
if self.status == "refuted" and self.countermodel is None:
|
|
588
|
+
raise ValueError(
|
|
589
|
+
"SubsumptionResult: status='refuted' must always carry a "
|
|
590
|
+
"countermodel — a refutation claim with no witness is exactly "
|
|
591
|
+
"what this module exists to never emit (see check_subsumption's "
|
|
592
|
+
"defensive fallback, which downgrades to 'unknown' instead of "
|
|
593
|
+
"constructing a result like this).")
|
|
594
|
+
|
|
595
|
+
def to_dict(self) -> dict:
|
|
596
|
+
return {
|
|
597
|
+
"sub": self.sub,
|
|
598
|
+
"sup": self.sup,
|
|
599
|
+
"status": self.status,
|
|
600
|
+
"verdict": self.verdict,
|
|
601
|
+
"countermodel": self.countermodel,
|
|
602
|
+
"explanation": self.explanation,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def check_subsumption(sub: str, sup: str, definitions: Definitions, *,
|
|
607
|
+
timeout: int = 10000,
|
|
608
|
+
backends: Optional[Sequence[str]] = None,
|
|
609
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
610
|
+
**options) -> SubsumptionResult:
|
|
611
|
+
"""Does ``Def(sub)`` entail ``Def(sup)`` — ``sub`` really a subclass?
|
|
612
|
+
|
|
613
|
+
Both definitions are unfolded (see :func:`unfold`), and
|
|
614
|
+
:func:`unicode_logic_kit.api.prove` is asked to decide
|
|
615
|
+
``unfold(sub) -> unfold(sup)`` (empty premises: a plain validity check of
|
|
616
|
+
the implication) over its FOL backend chain. A ``"refuted"`` result is
|
|
617
|
+
NEVER handed back without a countermodel: if the chain's own
|
|
618
|
+
:class:`~unicode_logic_kit.atp.protocol.Verdict` reports REFUTED but (for
|
|
619
|
+
some backend combination) carries no witness, :func:`api.countermodel`
|
|
620
|
+
is tried once more before conceding — and if that also finds nothing,
|
|
621
|
+
the result is downgraded to ``"unknown"`` rather than asserting an
|
|
622
|
+
unwitnessed refutation (see :class:`SubsumptionResult`'s invariant).
|
|
623
|
+
|
|
624
|
+
``backends``/``timeout``/``**options`` are forwarded verbatim to
|
|
625
|
+
:func:`~unicode_logic_kit.api.prove` — see :func:`check_satisfiable`'s
|
|
626
|
+
docstring for the same caveat about mixing ``**options`` with a
|
|
627
|
+
multi-backend chain.
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
KeyError: ``sub`` or ``sup`` is not a key of ``definitions``.
|
|
631
|
+
NonClosedDefinition: some body in ``definitions`` is not closed —
|
|
632
|
+
see :func:`unfold`.
|
|
633
|
+
"""
|
|
634
|
+
for role, class_name in (("sub", sub), ("sup", sup)):
|
|
635
|
+
if class_name not in definitions:
|
|
636
|
+
raise KeyError(
|
|
637
|
+
f"check_subsumption: {role}={class_name!r} is not a defined name "
|
|
638
|
+
f"(known: {sorted(definitions)})")
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
unfolded_sub = unfold(sub, definitions, max_depth=max_depth)
|
|
642
|
+
unfolded_sup = unfold(sup, definitions, max_depth=max_depth)
|
|
643
|
+
except CyclicDefinition as exc:
|
|
644
|
+
return SubsumptionResult(sub=sub, sup=sup, status="cyclic", verdict=None,
|
|
645
|
+
countermodel=None, explanation=str(exc))
|
|
646
|
+
except UnfoldDepthExceeded as exc:
|
|
647
|
+
# See check_satisfiable's identical split: a depth-budget exhaustion
|
|
648
|
+
# is not a proof of a cycle (find_cycles proves that structurally,
|
|
649
|
+
# via find_cycles/CyclicDefinition), so it must not be reported as
|
|
650
|
+
# "cyclic" — that status is documented (TheoryReport.proved_problems)
|
|
651
|
+
# as a PROVEN defect. Honest status is "unknown".
|
|
652
|
+
return SubsumptionResult(sub=sub, sup=sup, status="unknown", verdict=None,
|
|
653
|
+
countermodel=None, explanation=str(exc))
|
|
654
|
+
|
|
655
|
+
from .. import api # lazy, matching check_satisfiable
|
|
656
|
+
|
|
657
|
+
goal = Implies(unfolded_sub, unfolded_sup)
|
|
658
|
+
verdict = api.prove(goal, timeout=timeout, backends=backends, **options)
|
|
659
|
+
|
|
660
|
+
if verdict.status == PROVED:
|
|
661
|
+
return SubsumptionResult(
|
|
662
|
+
sub=sub, sup=sup, status="entailed", verdict=verdict.to_dict(),
|
|
663
|
+
countermodel=None,
|
|
664
|
+
explanation=f"Def({sub}) |= Def({sup}) — proved by {verdict.backend}.")
|
|
665
|
+
|
|
666
|
+
if verdict.status == REFUTED:
|
|
667
|
+
countermodel = verdict.countermodel
|
|
668
|
+
if countermodel is None:
|
|
669
|
+
# Defensive fallback: every default-chain member that can REFUTE
|
|
670
|
+
# (z3, modelfinder) already attaches one, but a caller-supplied
|
|
671
|
+
# backends= list could in principle name one that cannot (see
|
|
672
|
+
# VampireBackend's documented CounterSatisfiable-without-model
|
|
673
|
+
# case). Try the dedicated countermodel search once before
|
|
674
|
+
# conceding — see the module/class docstrings for why an
|
|
675
|
+
# unwitnessed "refuted" is never acceptable here.
|
|
676
|
+
cm_result = api.countermodel(goal, timeout=timeout, backends=backends)
|
|
677
|
+
countermodel = cm_result.model if cm_result.found else None
|
|
678
|
+
if countermodel is None:
|
|
679
|
+
return SubsumptionResult(
|
|
680
|
+
sub=sub, sup=sup, status="unknown", verdict=verdict.to_dict(),
|
|
681
|
+
countermodel=None,
|
|
682
|
+
explanation="a backend reported refutation without a recoverable "
|
|
683
|
+
"countermodel witness; treated as undecided rather "
|
|
684
|
+
"than asserting an unwitnessed refutation.")
|
|
685
|
+
explanation = None
|
|
686
|
+
try:
|
|
687
|
+
from .explain import explain_countermodel
|
|
688
|
+
explanation = explain_countermodel(countermodel)
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
if explanation is None:
|
|
692
|
+
explanation = (f"{verdict.backend} found a countermodel: {sub} holds "
|
|
693
|
+
f"but {sup} does not.")
|
|
694
|
+
return SubsumptionResult(
|
|
695
|
+
sub=sub, sup=sup, status="refuted", verdict=verdict.to_dict(),
|
|
696
|
+
countermodel=countermodel, explanation=explanation)
|
|
697
|
+
|
|
698
|
+
return SubsumptionResult(
|
|
699
|
+
sub=sub, sup=sup, status="unknown", verdict=verdict.to_dict(),
|
|
700
|
+
countermodel=None,
|
|
701
|
+
explanation=verdict.detail or "neither proved nor refuted within the "
|
|
702
|
+
"given backend budget")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
# Whole-theory report
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
@dataclass(frozen=True)
|
|
710
|
+
class TheoryReport:
|
|
711
|
+
"""The combined verification result over a definition set.
|
|
712
|
+
|
|
713
|
+
``cycles`` is :func:`find_cycles`'s output (the authoritative structural
|
|
714
|
+
view — a name inside a cycle also shows up with ``status="cyclic"`` in
|
|
715
|
+
``satisfiability``/``subsumptions``, individually, but ``cycles`` is what
|
|
716
|
+
names the actual loop). ``satisfiability`` covers EVERY name in the
|
|
717
|
+
definition set (not just ones mentioned in ``subsumptions``); a dead
|
|
718
|
+
classifier is exactly as reportable on its own as a broken subsumption.
|
|
719
|
+
|
|
720
|
+
:attr:`proved_problems` / :attr:`undecided` are the split the module
|
|
721
|
+
docstring promises: the first is every finding this module actually
|
|
722
|
+
PROVED (a cycle, an unsatisfiable definition, a refuted subsumption —
|
|
723
|
+
each with its evidence), the second is every finding that stayed
|
|
724
|
+
genuinely open. A caller building a pass/fail gate should fail on the
|
|
725
|
+
first and merely flag the second for human attention.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
cycles: Tuple[Tuple[str, ...], ...]
|
|
729
|
+
satisfiability: Mapping[str, SatisfiabilityResult]
|
|
730
|
+
subsumptions: Tuple[SubsumptionResult, ...]
|
|
731
|
+
|
|
732
|
+
@property
|
|
733
|
+
def proved_problems(self) -> Tuple[dict, ...]:
|
|
734
|
+
"""Every DEFINITIVELY established defect — never an 'unknown'."""
|
|
735
|
+
problems = []
|
|
736
|
+
for cycle in self.cycles:
|
|
737
|
+
problems.append({"kind": "cycle", "names": list(cycle)})
|
|
738
|
+
for name, result in sorted(self.satisfiability.items()):
|
|
739
|
+
if result.status == "unsatisfiable":
|
|
740
|
+
problems.append({"kind": "unsatisfiable", "name": name,
|
|
741
|
+
"detail": result.detail})
|
|
742
|
+
for result in self.subsumptions:
|
|
743
|
+
if result.status == "refuted":
|
|
744
|
+
problems.append({"kind": "subsumption_refuted", "sub": result.sub,
|
|
745
|
+
"sup": result.sup, "explanation": result.explanation})
|
|
746
|
+
return tuple(problems)
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def undecided(self) -> Tuple[dict, ...]:
|
|
750
|
+
"""Every check that ended in 'unknown' — genuinely open, not a defect."""
|
|
751
|
+
open_items = []
|
|
752
|
+
for name, result in sorted(self.satisfiability.items()):
|
|
753
|
+
if result.status == "unknown":
|
|
754
|
+
open_items.append({"kind": "satisfiability", "name": name,
|
|
755
|
+
"detail": result.detail})
|
|
756
|
+
for result in self.subsumptions:
|
|
757
|
+
if result.status == "unknown":
|
|
758
|
+
open_items.append({"kind": "subsumption", "sub": result.sub,
|
|
759
|
+
"sup": result.sup, "detail": result.explanation})
|
|
760
|
+
return tuple(open_items)
|
|
761
|
+
|
|
762
|
+
def to_dict(self) -> dict:
|
|
763
|
+
return {
|
|
764
|
+
"cycles": [list(c) for c in self.cycles],
|
|
765
|
+
"satisfiability": {name: r.to_dict()
|
|
766
|
+
for name, r in sorted(self.satisfiability.items())},
|
|
767
|
+
"subsumptions": [r.to_dict() for r in self.subsumptions],
|
|
768
|
+
"proved_problems": list(self.proved_problems),
|
|
769
|
+
"undecided": list(self.undecided),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
def to_markdown(self) -> str:
|
|
773
|
+
"""Render this report as a plain-formatted Markdown document.
|
|
774
|
+
|
|
775
|
+
One top summary line (:attr:`proved_problems` vs :attr:`undecided`
|
|
776
|
+
counts), then a section each for ``cycles`` (the name chains
|
|
777
|
+
:func:`find_cycles` found), ``satisfiability`` (grouped by status; a
|
|
778
|
+
``"satisfiable"`` witness is glossed by :func:`_witness_gloss` when
|
|
779
|
+
one was recovered — honestly, as an existential witness rather than
|
|
780
|
+
an implication countermodel; see that function's own docstring and
|
|
781
|
+
:class:`SatisfiabilityResult`), and ``subsumptions`` (grouped by
|
|
782
|
+
status; each row's own ``.explanation`` is rendered verbatim,
|
|
783
|
+
falling back to
|
|
784
|
+
:func:`~unicode_logic_kit.eval.explain.explain_countermodel` on
|
|
785
|
+
``.countermodel`` only in the defensive case where ``.explanation``
|
|
786
|
+
is itself ``None``).
|
|
787
|
+
|
|
788
|
+
Every rendered name/detail/explanation is put through
|
|
789
|
+
:func:`_md_cell`, so a hostile string (one containing ``|`` or a
|
|
790
|
+
newline — a molecule name or an error message is never under this
|
|
791
|
+
module's control) cannot corrupt a table's row/column structure.
|
|
792
|
+
Calling this twice on the same report always returns the identical
|
|
793
|
+
string (nothing here depends on dict/set iteration order — every
|
|
794
|
+
grouping is walked in the fixed, sorted order already used by
|
|
795
|
+
:meth:`to_dict`/:attr:`proved_problems`).
|
|
796
|
+
"""
|
|
797
|
+
return "\n".join(_theory_markdown_lines(self))
|
|
798
|
+
|
|
799
|
+
def to_html(self, title: str = "Theory report") -> str:
|
|
800
|
+
"""Render as a self-contained, theme-aware HTML page.
|
|
801
|
+
|
|
802
|
+
Same idiom as :meth:`unicode_logic_kit.fol.derivation.CCGDerivation.to_html`
|
|
803
|
+
and the ``atp`` Fitch/sequent renderers built on
|
|
804
|
+
:mod:`unicode_logic_kit.atp._html`: one ``<!doctype html>`` page with
|
|
805
|
+
the shared colour tokens, headings/tables for the same three sections
|
|
806
|
+
:meth:`to_markdown` renders, and every user-supplied string
|
|
807
|
+
(definition name, detail, explanation) HTML-escaped via
|
|
808
|
+
:func:`~unicode_logic_kit.atp._html.esc_html`.
|
|
809
|
+
"""
|
|
810
|
+
return html_page(title, _theory_html_body(self), _THEORY_HTML_CSS)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def check_theory(definitions: Definitions, *,
|
|
814
|
+
subsumptions: Sequence[Tuple[str, str]] = (),
|
|
815
|
+
timeout: int = 10000,
|
|
816
|
+
backends: Optional[Sequence[str]] = None,
|
|
817
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
818
|
+
**options) -> TheoryReport:
|
|
819
|
+
"""Run every check this module offers over a whole definition set.
|
|
820
|
+
|
|
821
|
+
Cycles (:func:`find_cycles`), satisfiability of every definition
|
|
822
|
+
(:func:`check_satisfiable`, one call per name), and every requested
|
|
823
|
+
subsumption pair (:func:`check_subsumption`, one call per pair in
|
|
824
|
+
``subsumptions``) — see :class:`TheoryReport` for how the results are
|
|
825
|
+
organised and split into proved-vs-undecided. ``timeout``/``backends``/
|
|
826
|
+
``max_depth``/``**options`` apply uniformly to every underlying call.
|
|
827
|
+
|
|
828
|
+
Raises:
|
|
829
|
+
KeyError: a name in ``subsumptions`` is not a key of ``definitions``.
|
|
830
|
+
NonClosedDefinition: some body in ``definitions`` is not closed —
|
|
831
|
+
see :func:`unfold`. Raised by the first underlying
|
|
832
|
+
:func:`check_satisfiable`/:func:`check_subsumption` call that
|
|
833
|
+
reaches it, so ``cycles`` (computed first, structurally, with no
|
|
834
|
+
unfolding involved) is never the cause of this and is simply not
|
|
835
|
+
returned when it happens.
|
|
836
|
+
"""
|
|
837
|
+
cycles = find_cycles(definitions)
|
|
838
|
+
satisfiability = {
|
|
839
|
+
name: check_satisfiable(name, definitions, timeout=timeout,
|
|
840
|
+
backends=backends, max_depth=max_depth, **options)
|
|
841
|
+
for name in sorted(definitions)
|
|
842
|
+
}
|
|
843
|
+
sub_results = tuple(
|
|
844
|
+
check_subsumption(sub, sup, definitions, timeout=timeout,
|
|
845
|
+
backends=backends, max_depth=max_depth, **options)
|
|
846
|
+
for sub, sup in subsumptions
|
|
847
|
+
)
|
|
848
|
+
return TheoryReport(cycles=cycles, satisfiability=satisfiability,
|
|
849
|
+
subsumptions=sub_results)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# ---------------------------------------------------------------------------
|
|
853
|
+
# TheoryReport.to_markdown() / to_html() — display only, no new proof/model-
|
|
854
|
+
# finding logic (see the roadmap item this implements: a pure formatting
|
|
855
|
+
# layer over already-verified TheoryReport/SatisfiabilityResult/
|
|
856
|
+
# SubsumptionResult data, so it introduces no soundness risk).
|
|
857
|
+
# ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
def _md_cell(value) -> str:
|
|
860
|
+
"""Escape a value for safe embedding in one Markdown table cell.
|
|
861
|
+
|
|
862
|
+
A bare ``|`` would be read as a new column and an embedded newline would
|
|
863
|
+
split the row across lines, silently corrupting every column after it —
|
|
864
|
+
so both are neutralised. This only ever touches the RENDERED copy: the
|
|
865
|
+
original string on the result object is never modified.
|
|
866
|
+
"""
|
|
867
|
+
text = "" if value is None else str(value)
|
|
868
|
+
text = text.replace("|", "\\|")
|
|
869
|
+
return text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _witness_gloss(witness: Optional[dict]) -> Optional[str]:
|
|
873
|
+
"""A short, honest gloss of a :class:`SatisfiabilityResult` witness, or
|
|
874
|
+
``None`` if there is none.
|
|
875
|
+
|
|
876
|
+
``witness`` here is documented (see :class:`SatisfiabilityResult`) to be
|
|
877
|
+
in the exact same JSON-able shape as
|
|
878
|
+
:class:`~unicode_logic_kit.atp.protocol.Verdict.countermodel` — but it is
|
|
879
|
+
NOT itself an implication countermodel: :func:`check_satisfiable` proves
|
|
880
|
+
``Not(unfolded)`` REFUTED with EMPTY premises, so the model this witness
|
|
881
|
+
describes is a single-formula existential witness of ``unfolded``, not a
|
|
882
|
+
model where "premises hold and the goal fails" (the shape an actual
|
|
883
|
+
countermodel, e.g. :class:`SubsumptionResult`'s, has).
|
|
884
|
+
:func:`~unicode_logic_kit.eval.explain.explain_countermodel`'s Z3-assignment
|
|
885
|
+
branch is hardcoded to that implication wording ("... the two sides
|
|
886
|
+
differ"), which would misstate what was computed here — so any witness
|
|
887
|
+
carrying an ``"assignment"`` dict (a bare ``{name: value}`` witness, or a
|
|
888
|
+
``{"kind": ..., "assignment": {...}}`` one — ``"z3_model"`` is the common
|
|
889
|
+
case since Z3 leads the default backend chain, but
|
|
890
|
+
:mod:`unicode_logic_kit.atp.cvc5_backend` emits the identical
|
|
891
|
+
``{"kind": "cvc5_model", "assignment": {...}}`` shape and would otherwise
|
|
892
|
+
have no branch of its own here) is glossed locally instead, via
|
|
893
|
+
:func:`_z3_satisfiability_gloss`. This mirrors
|
|
894
|
+
:func:`unicode_logic_kit.eval.chem_batch._gloss_chem_witness`'s reasoning
|
|
895
|
+
for the exact same class of problem on that module's own (differently
|
|
896
|
+
shaped) row witnesses.
|
|
897
|
+
|
|
898
|
+
Every other witness kind that carries a ``"repr"`` fallback (a Kripke
|
|
899
|
+
model, the modelfinder's own ``{"kind": "finite_structure", "repr": ...}``,
|
|
900
|
+
a Nitpick counterexample, ...) has no implication-specific sentence in
|
|
901
|
+
:func:`explain_countermodel`'s output, so those are still glossed through
|
|
902
|
+
it as-is — reusing its richer world/domain/relation rendering rather than
|
|
903
|
+
duplicating it. A witness with neither an ``"assignment"`` dict nor a
|
|
904
|
+
``"repr"`` key — e.g. the clingo/minizinc backends' own
|
|
905
|
+
``{"kind": "finite_structure", "data": {...}}`` (a different key from the
|
|
906
|
+
modelfinder's ``"repr"`` shape above) — would make
|
|
907
|
+
:func:`explain_countermodel` raise ``ValueError`` (it has nothing to
|
|
908
|
+
explain), which must never propagate out of a report-rendering method, so
|
|
909
|
+
that case falls back to :func:`_generic_satisfiability_gloss` instead.
|
|
910
|
+
"""
|
|
911
|
+
if witness is None:
|
|
912
|
+
return None
|
|
913
|
+
kind = witness.get("kind") if isinstance(witness, dict) else None
|
|
914
|
+
assignment: Optional[dict] = None
|
|
915
|
+
if isinstance(witness, dict):
|
|
916
|
+
if kind is None:
|
|
917
|
+
assignment = witness # bare {name: value}, no "kind" key
|
|
918
|
+
elif isinstance(witness.get("assignment"), dict):
|
|
919
|
+
assignment = witness["assignment"] # any *_model kind: z3_model, cvc5_model, ...
|
|
920
|
+
if assignment is not None:
|
|
921
|
+
return _z3_satisfiability_gloss(assignment)
|
|
922
|
+
if isinstance(witness, dict) and "repr" in witness:
|
|
923
|
+
from .explain import explain_countermodel # lazy, mirrors check_subsumption's own import
|
|
924
|
+
return explain_countermodel(witness)
|
|
925
|
+
return _generic_satisfiability_gloss(witness)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _z3_satisfiability_gloss(assignment: dict) -> str:
|
|
929
|
+
"""Render a Z3/cvc5-style ``{name: value}`` SATISFIABILITY witness
|
|
930
|
+
honestly.
|
|
931
|
+
|
|
932
|
+
Despite the name (kept for the common Z3 case, and for the existing test
|
|
933
|
+
surface), this is used for any backend's ``"assignment"``-shaped witness
|
|
934
|
+
— see :func:`_witness_gloss`'s docstring. Deliberately NOT
|
|
935
|
+
:func:`~unicode_logic_kit.eval.explain.explain_countermodel`: this
|
|
936
|
+
assignment satisfies the definition directly — there is no second side
|
|
937
|
+
to compare it against, and that function does not accept a non-Z3 kind
|
|
938
|
+
with an assignment at all (it would raise).
|
|
939
|
+
"""
|
|
940
|
+
if not assignment:
|
|
941
|
+
return "a model was found, but it recorded no variable assignments."
|
|
942
|
+
items = sorted(assignment.items(), key=lambda kv: str(kv[0]))
|
|
943
|
+
assigned_str = ", ".join(f"{k} := {v}" for k, v in items)
|
|
944
|
+
return f"Under the assignment {assigned_str}, the definition is satisfied."
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _generic_satisfiability_gloss(witness: object) -> str:
|
|
948
|
+
"""A minimal, honest, NEVER-raising gloss for a satisfiability witness
|
|
949
|
+
that :func:`_witness_gloss` could not route anywhere more specific: no
|
|
950
|
+
``"assignment"`` dict (so :func:`_z3_satisfiability_gloss` does not
|
|
951
|
+
apply) and no ``"repr"`` fallback (so
|
|
952
|
+
:func:`~unicode_logic_kit.eval.explain.explain_countermodel` would raise
|
|
953
|
+
``ValueError`` rather than render anything).
|
|
954
|
+
|
|
955
|
+
The real shape hitting this today is the clingo/minizinc backends'
|
|
956
|
+
``{"kind": "finite_structure", "data": {...}}`` (see
|
|
957
|
+
``unicode_logic_kit.atp.clingo_backend``/``minizinc_backend``) — a
|
|
958
|
+
``"data"`` key, not the modelfinder's own ``"repr"``-carrying shape of
|
|
959
|
+
the same ``"kind"``. Deliberately does not attempt to parse or
|
|
960
|
+
pretty-print ``"data"``: that would risk a shape-specific, silently
|
|
961
|
+
incomplete duplication of what the backend already encodes, for a
|
|
962
|
+
one-line table cell that only needs to say a model exists.
|
|
963
|
+
"""
|
|
964
|
+
kind = witness.get("kind") if isinstance(witness, dict) else None
|
|
965
|
+
label = kind if kind else "unlabelled"
|
|
966
|
+
return f'A "{label}" model was found; the definition is satisfied.'
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _subsumption_explanation(result: "SubsumptionResult") -> Optional[str]:
|
|
970
|
+
"""``result.explanation`` if set, else a best-effort fallback computed
|
|
971
|
+
from ``result.countermodel`` — never raising, and never glossing a
|
|
972
|
+
countermodel as a refutation outside ``status="refuted"``.
|
|
973
|
+
|
|
974
|
+
Unlike a :class:`SatisfiabilityResult` witness (see :func:`_witness_gloss`),
|
|
975
|
+
a :class:`SubsumptionResult` countermodel genuinely IS an implication
|
|
976
|
+
countermodel when ``status == "refuted"`` (``check_subsumption`` proves
|
|
977
|
+
``Def(sub) -> Def(sup)`` REFUTED, i.e. finds a model where ``sub`` holds
|
|
978
|
+
and ``sup`` does not), so :func:`~unicode_logic_kit.eval.explain.explain_countermodel`'s
|
|
979
|
+
wording is the right one there — this is not the satisfiability-witness
|
|
980
|
+
deviation. But ``SubsumptionResult.__post_init__`` only requires
|
|
981
|
+
``countermodel is not None`` when ``status == "refuted"``; it never
|
|
982
|
+
forbids a countermodel from also being present alongside
|
|
983
|
+
``status in ("unknown", "entailed", "cyclic")`` on a hand-built instance
|
|
984
|
+
(as this file's own tests build throughout), and this module's own
|
|
985
|
+
docstring promises ``"unknown"`` is NEVER reported as ``"refuted"`` — so
|
|
986
|
+
the countermodel-based fallback below is only ever computed for
|
|
987
|
+
``status == "refuted"``, matching what ``check_subsumption`` itself ever
|
|
988
|
+
produces.
|
|
989
|
+
|
|
990
|
+
``check_subsumption`` itself always sets ``explanation`` to a non-``None``
|
|
991
|
+
string (it wraps its own ``explain_countermodel`` call in
|
|
992
|
+
``try/except Exception`` and falls back to a generic sentence — see that
|
|
993
|
+
function's body), so this fallback path is never hit by the module's own
|
|
994
|
+
top-level API. But ``SubsumptionResult`` is a public dataclass whose
|
|
995
|
+
``__post_init__`` never requires ``explanation`` to be set. A caller
|
|
996
|
+
building one by hand can therefore reach a ``status="refuted"``,
|
|
997
|
+
``explanation=None`` object carrying a countermodel shape
|
|
998
|
+
``explain_countermodel`` cannot handle — a bare
|
|
999
|
+
``cvc5_model``/``finite_structure``-without-``repr`` witness, for
|
|
1000
|
+
instance — and a report renderer must never crash on that, so the same
|
|
1001
|
+
guard ``check_subsumption`` uses internally is mirrored here.
|
|
1002
|
+
"""
|
|
1003
|
+
if result.explanation is not None:
|
|
1004
|
+
return result.explanation
|
|
1005
|
+
if result.status != "refuted" or result.countermodel is None:
|
|
1006
|
+
return None
|
|
1007
|
+
try:
|
|
1008
|
+
from .explain import explain_countermodel
|
|
1009
|
+
return explain_countermodel(result.countermodel)
|
|
1010
|
+
except Exception:
|
|
1011
|
+
backend = None
|
|
1012
|
+
if isinstance(result.verdict, dict):
|
|
1013
|
+
backend = result.verdict.get("backend")
|
|
1014
|
+
who = backend or "a backend"
|
|
1015
|
+
return (f"{who} found a countermodel: {result.sub} holds "
|
|
1016
|
+
f"but {result.sup} does not.")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _theory_markdown_lines(report: TheoryReport) -> List[str]:
|
|
1020
|
+
lines: List[str] = ["# Theory report", ""]
|
|
1021
|
+
lines.append(f"**{len(report.proved_problems)}** proved problem(s), "
|
|
1022
|
+
f"**{len(report.undecided)}** undecided.")
|
|
1023
|
+
lines.append("")
|
|
1024
|
+
|
|
1025
|
+
lines.append("## Cycles")
|
|
1026
|
+
lines.append("")
|
|
1027
|
+
if report.cycles:
|
|
1028
|
+
for cycle in report.cycles:
|
|
1029
|
+
lines.append("- " + " -> ".join(_md_cell(name) for name in cycle))
|
|
1030
|
+
else:
|
|
1031
|
+
lines.append("No cycles.")
|
|
1032
|
+
lines.append("")
|
|
1033
|
+
|
|
1034
|
+
lines.append("## Satisfiability")
|
|
1035
|
+
lines.append("")
|
|
1036
|
+
by_status: Dict[str, List[SatisfiabilityResult]] = {}
|
|
1037
|
+
for _, result in sorted(report.satisfiability.items()):
|
|
1038
|
+
by_status.setdefault(result.status, []).append(result)
|
|
1039
|
+
if not by_status:
|
|
1040
|
+
lines.append("No definitions.")
|
|
1041
|
+
for status in _SATISFIABILITY_STATUSES:
|
|
1042
|
+
results = by_status.get(status)
|
|
1043
|
+
if not results:
|
|
1044
|
+
continue
|
|
1045
|
+
lines.append(f"### {status}")
|
|
1046
|
+
lines.append("")
|
|
1047
|
+
lines.append("| name | detail |")
|
|
1048
|
+
lines.append("|---|---|")
|
|
1049
|
+
for result in results:
|
|
1050
|
+
detail = result.detail or ""
|
|
1051
|
+
# Only "satisfiable" is documented to carry a witness (see
|
|
1052
|
+
# SatisfiabilityResult's docstring); __post_init__ does not
|
|
1053
|
+
# forbid a witness on another status on a hand-built instance,
|
|
1054
|
+
# so gate on status here rather than on witness truthiness alone
|
|
1055
|
+
# to avoid glossing e.g. an "unsatisfiable" result as if a model
|
|
1056
|
+
# were found.
|
|
1057
|
+
gloss = _witness_gloss(result.witness) if result.status == "satisfiable" else None
|
|
1058
|
+
if gloss:
|
|
1059
|
+
detail = f"{detail} {gloss}".strip()
|
|
1060
|
+
lines.append(f"| {_md_cell(result.name)} | {_md_cell(detail)} |")
|
|
1061
|
+
lines.append("")
|
|
1062
|
+
|
|
1063
|
+
lines.append("## Subsumptions")
|
|
1064
|
+
lines.append("")
|
|
1065
|
+
sub_by_status: Dict[str, List[SubsumptionResult]] = {}
|
|
1066
|
+
for result in report.subsumptions:
|
|
1067
|
+
sub_by_status.setdefault(result.status, []).append(result)
|
|
1068
|
+
if not sub_by_status:
|
|
1069
|
+
lines.append("No subsumption checks.")
|
|
1070
|
+
for status in _SUBSUMPTION_STATUSES:
|
|
1071
|
+
results = sub_by_status.get(status)
|
|
1072
|
+
if not results:
|
|
1073
|
+
continue
|
|
1074
|
+
lines.append(f"### {status}")
|
|
1075
|
+
lines.append("")
|
|
1076
|
+
lines.append("| sub | sup | explanation |")
|
|
1077
|
+
lines.append("|---|---|---|")
|
|
1078
|
+
for result in results:
|
|
1079
|
+
explanation = _subsumption_explanation(result)
|
|
1080
|
+
lines.append(f"| {_md_cell(result.sub)} | {_md_cell(result.sup)} | "
|
|
1081
|
+
f"{_md_cell(explanation)} |")
|
|
1082
|
+
lines.append("")
|
|
1083
|
+
|
|
1084
|
+
while lines and lines[-1] == "":
|
|
1085
|
+
lines.pop()
|
|
1086
|
+
lines.append("")
|
|
1087
|
+
return lines
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
_THEORY_HTML_CSS = """
|
|
1091
|
+
.rpt{max-width:900px;margin:0 auto;padding:26px 16px;
|
|
1092
|
+
font-family:ui-sans-serif,system-ui,"Segoe UI",Arial,sans-serif;
|
|
1093
|
+
font-size:14px;line-height:1.5}
|
|
1094
|
+
.rpt h1{font-size:20px;margin:0 0 8px}
|
|
1095
|
+
.rpt h2{font-size:16px;margin:22px 0 6px;border-bottom:1.3px solid var(--bar);padding-bottom:3px}
|
|
1096
|
+
.rpt h3{font-size:12.5px;margin:14px 0 4px;color:var(--muted);
|
|
1097
|
+
text-transform:uppercase;letter-spacing:.03em}
|
|
1098
|
+
.rpt table{border-collapse:collapse;width:100%;margin:4px 0 14px}
|
|
1099
|
+
.rpt th,.rpt td{border:1px solid var(--bar);padding:4px 8px;text-align:left;
|
|
1100
|
+
vertical-align:top}
|
|
1101
|
+
.rpt th{color:var(--muted);font-weight:600}
|
|
1102
|
+
.rpt ul{margin:6px 0 14px;padding-left:22px}
|
|
1103
|
+
.rpt .muted{color:var(--muted)}
|
|
1104
|
+
"""
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _status_table(rows: List[Tuple[str, ...]], headers: Tuple[str, ...]) -> str:
|
|
1108
|
+
head = "".join("<th>%s</th>" % esc_html(h) for h in headers)
|
|
1109
|
+
body = "".join(
|
|
1110
|
+
"<tr>%s</tr>" % "".join("<td>%s</td>" % esc_html(cell) for cell in row)
|
|
1111
|
+
for row in rows
|
|
1112
|
+
)
|
|
1113
|
+
return "<table><tr>%s</tr>%s</table>" % (head, body)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _theory_html_body(report: TheoryReport) -> str:
|
|
1117
|
+
parts: List[str] = ['<div class="rpt">', "<h1>Theory report</h1>",
|
|
1118
|
+
"<p>%d proved problem(s), %d undecided.</p>"
|
|
1119
|
+
% (len(report.proved_problems), len(report.undecided))]
|
|
1120
|
+
|
|
1121
|
+
parts.append("<h2>Cycles</h2>")
|
|
1122
|
+
if report.cycles:
|
|
1123
|
+
items = "".join("<li>%s</li>" % esc_html(" -> ".join(cycle))
|
|
1124
|
+
for cycle in report.cycles)
|
|
1125
|
+
parts.append("<ul>%s</ul>" % items)
|
|
1126
|
+
else:
|
|
1127
|
+
parts.append('<p class="muted">No cycles.</p>')
|
|
1128
|
+
|
|
1129
|
+
parts.append("<h2>Satisfiability</h2>")
|
|
1130
|
+
by_status: Dict[str, List[SatisfiabilityResult]] = {}
|
|
1131
|
+
for _, result in sorted(report.satisfiability.items()):
|
|
1132
|
+
by_status.setdefault(result.status, []).append(result)
|
|
1133
|
+
if not by_status:
|
|
1134
|
+
parts.append('<p class="muted">No definitions.</p>')
|
|
1135
|
+
for status in _SATISFIABILITY_STATUSES:
|
|
1136
|
+
results = by_status.get(status)
|
|
1137
|
+
if not results:
|
|
1138
|
+
continue
|
|
1139
|
+
rows = []
|
|
1140
|
+
for result in results:
|
|
1141
|
+
detail = result.detail or ""
|
|
1142
|
+
# See the matching comment in _theory_markdown_lines: gate on
|
|
1143
|
+
# status, not witness truthiness, so only "satisfiable" ever
|
|
1144
|
+
# gets model-found prose.
|
|
1145
|
+
gloss = _witness_gloss(result.witness) if result.status == "satisfiable" else None
|
|
1146
|
+
if gloss:
|
|
1147
|
+
detail = f"{detail} {gloss}".strip()
|
|
1148
|
+
rows.append((result.name, detail))
|
|
1149
|
+
parts.append("<h3>%s</h3>" % esc_html(status))
|
|
1150
|
+
parts.append(_status_table(rows, ("name", "detail")))
|
|
1151
|
+
|
|
1152
|
+
parts.append("<h2>Subsumptions</h2>")
|
|
1153
|
+
sub_by_status: Dict[str, List[SubsumptionResult]] = {}
|
|
1154
|
+
for result in report.subsumptions:
|
|
1155
|
+
sub_by_status.setdefault(result.status, []).append(result)
|
|
1156
|
+
if not sub_by_status:
|
|
1157
|
+
parts.append('<p class="muted">No subsumption checks.</p>')
|
|
1158
|
+
for status in _SUBSUMPTION_STATUSES:
|
|
1159
|
+
results = sub_by_status.get(status)
|
|
1160
|
+
if not results:
|
|
1161
|
+
continue
|
|
1162
|
+
rows = []
|
|
1163
|
+
for result in results:
|
|
1164
|
+
explanation = _subsumption_explanation(result)
|
|
1165
|
+
rows.append((result.sub, result.sup, explanation or ""))
|
|
1166
|
+
parts.append("<h3>%s</h3>" % esc_html(status))
|
|
1167
|
+
parts.append(_status_table(rows, ("sub", "sup", "explanation")))
|
|
1168
|
+
|
|
1169
|
+
parts.append("</div>")
|
|
1170
|
+
return "".join(parts)
|