defmodule AtpClient.ResultNormalization do @moduledoc """ Interprets output from various provers into SZS-faithful atoms. Each backend returns the same `t:atp_result/0` shape: `{:ok, status}` where `status` is the SZS Ontology verdict downcased to an Elixir atom (e.g. `:theorem`, `:unsatisfiable`, `:counter_satisfiable`, `:satisfiable`, `:gave_up`, `:timeout`, …), or `{:error, reason}` when the call could not produce any SZS verdict (network failure, malformed prover output, missing binary). The distinction between e.g. `:theorem` ("conjecture follows from the axioms") and `:unsatisfiable` ("the clause set has no model") is preserved so callers do not lose information across backends — see https://tptp.org/Seminars/SZSOntologies/Summary.html for the full ontology. TPTP-oriented prover outputs (SystemOnTPTP, StarExec, LocalExec) are classified by `interpret_result/1`; Isabelle `use_theories` payloads by `interpret_isabelle_result/1` and `per_lemma_results/3`. """ @typedoc "Standardized format for ATP outputs." @type atp_result :: {:ok, szs_status()} | {:error, failure_t()} @typedoc """ SZS Ontology status atoms surfaced to callers. The Success/NoSuccess split mirrors the SZS hierarchy. The unions below enumerate the atoms the classifier maps explicitly — i.e. the SZS verdicts a sledgehammer-targeted prover (or a contemporary TPTP-compliant system) is likely to emit. A permissive fallback in `interpret_result/1` also converts any unrecognised `% SZS status ` line to its snake-case atom (`Tautology` → `:tautology`, `EquivalentTheorem` → `:equivalent_theorem`), so the runtime shape is `atom()`; the typespec lists the well-known names for callers that pattern-match. * Success — a definite verdict: `:theorem`, `:unsatisfiable`, `:satisfiable`, `:counter_satisfiable`, `:contradictory_axioms`, `:equivalent`, `:counter_equivalent`, `:counter_theorem`, `:equivalent_counter_theorem`, `:equi_satisfiable`, `:tautology`, `:tautologous_conclusion`, `:weaker_conclusion`, `:no_consequence`. * NoSuccess — the prover finished without classifying the input as Success: `:gave_up`, `:unknown`, `:incomplete`, `:timeout`, `:resource_out`, `:memory_out`, `:forced`, `:user`, `:inappropriate`, `:error`, `:input_error`. """ @type szs_status :: szs_success() | szs_no_success() | atom() @typedoc "SZS Success statuses recognised explicitly by `interpret_result/1`." @type szs_success :: :theorem | :unsatisfiable | :satisfiable | :counter_satisfiable | :contradictory_axioms | :equivalent | :counter_equivalent | :counter_theorem | :equivalent_counter_theorem | :equi_satisfiable | :tautology | :tautologous_conclusion | :weaker_conclusion | :no_consequence @typedoc "SZS NoSuccess statuses recognised explicitly by `interpret_result/1`." @type szs_no_success :: :gave_up | :unknown | :incomplete | :timeout | :resource_out | :memory_out | :forced | :user | :inappropriate | :error | :input_error @typedoc """ Backend errors that prevented any SZS verdict from being produced. These are not SZS statuses — they describe problems on the path between the caller and the prover (no executable, output the classifier could not parse, prover crash with no SZS line, malformed input rejected before classification). """ @type failure_t :: :internal_error | :input_error | {:prover_not_found, String.t()} | {:unrecognized_output, String.t()} # Matched via word-boundary regex extraction in `szs_line_match/1`, so # entry order does not affect precedence — only the set of names matters. @known_szs_results [ # Success — counter-family before plain forms. {"ContradictoryAxioms", {:ok, :contradictory_axioms}}, {"EquivalentCounterTheorem", {:ok, :equivalent_counter_theorem}}, {"CounterEquivalent", {:ok, :counter_equivalent}}, {"CounterTheorem", {:ok, :counter_theorem}}, {"CounterSatisfiable", {:ok, :counter_satisfiable}}, {"TautologousConclusion", {:ok, :tautologous_conclusion}}, {"WeakerConclusion", {:ok, :weaker_conclusion}}, {"EquiSatisfiable", {:ok, :equi_satisfiable}}, {"Equivalent", {:ok, :equivalent}}, {"NoConsequence", {:ok, :no_consequence}}, {"Tautology", {:ok, :tautology}}, {"Theorem", {:ok, :theorem}}, {"Unsatisfiable", {:ok, :unsatisfiable}}, {"Satisfiable", {:ok, :satisfiable}}, # NoSuccess — InputError before Error (sub-status of Error). {"InputError", {:ok, :input_error}}, {"Error", {:ok, :error}}, {"GaveUp", {:ok, :gave_up}}, {"Unknown", {:ok, :unknown}}, {"Incomplete", {:ok, :incomplete}}, {"Timeout", {:ok, :timeout}}, {"ResourceOut", {:ok, :resource_out}}, {"MemoryOut", {:ok, :memory_out}}, {"Forced", {:ok, :forced}}, {"User", {:ok, :user}}, {"Inappropriate", {:ok, :inappropriate}} ] # Prover output snippets emitted by solvers that do not (always) print an # SZS status line. Each pattern is mapped to the most faithful SZS atom the # classifier can determine from the output alone. Where a verdict is only # sound under specific prover strategy flags that the classifier cannot see # (e.g. SPASS saturation under `-SOS`), the conservative `:gave_up` is # returned; callers who control the prover's flags can use `raw: true` and # interpret the raw output themselves. @prover_specific_results [ # Alt-Ergo (Why3-wrapped: ": Valid", ": Timeout"; native: "I don't know") {": Valid", {:ok, :theorem}}, {": Timeout", {:ok, :timeout}}, {": Unknown", {:ok, :unknown}}, {"I don't know", {:ok, :gave_up}}, # E {"Failure: Resource limit exceeded (time)", {:ok, :timeout}}, {"Failure: Resource limit exceeded (memory)", {:ok, :memory_out}}, {"time limit exceeded", {:ok, :timeout}}, # LEO-II {"CPU time limit exceeded, terminating", {:ok, :timeout}}, {"No.of.Axioms", {:ok, :gave_up}}, # SPASS — input-error patterns are prover verdicts (SZS InputError), # so they go in the {:ok, _} channel; "Please report this error" is a # SPASS internal failure that we cannot trust to be a verdict. # "Completion found" means the clause set was saturated, which is only a # sound (counter)satisfiability witness under a refutationally complete # strategy; since the classifier cannot verify which strategy was used, # the conservative :gave_up is returned. {"SPASS beiseite: Completion found", {:ok, :gave_up}}, {"SPASS beiseite: Ran out of time", {:ok, :timeout}}, {"SPASS beiseite: Maximal number of loops exceeded", {:ok, :resource_out}}, {"No formulae and clauses found in input file", {:ok, :input_error}}, {"Undefined symbol", {:ok, :input_error}}, {"Free Variable", {:ok, :input_error}}, {"Please report this error", {:error, :internal_error}}, # Vampire — saturation patterns are conservative :gave_up because the # classifier cannot tell whether a conjecture was present; in SZS mode # Vampire emits the correct status line itself (handled by layer 1). {"UNPROVABLE", {:ok, :gave_up}}, {"CANNOT PROVE", {:ok, :gave_up}}, {"Satisfiability detected", {:ok, :gave_up}}, {"Termination reason: Satisfiable", {:ok, :gave_up}}, {"Time limit reached!", {:ok, :timeout}}, {"Aborted by signal SIGINT", {:ok, :forced}}, # Waldmeister — same channel split: input rejection is a verdict, # a SegFault is not. "Unexpected end of file" typically means non-UEQ # input handed to a UEQ-only prover → SZS Inappropriate. {"Too many function symbols", {:ok, :resource_out}}, {"**** Unexpected end of file.", {:ok, :inappropriate}}, {"Unrecoverable Segmentation Fault", {:error, :internal_error}} ] @doc """ Interprets the output of provers that follow the SZS ontology (including those available on SystemOnTPTP) into the standardized representation. Three layers run in order: 1. The explicit SZS table (`@known_szs_results`) recognises every status in `t:szs_success/0` and `t:szs_no_success/0` from a `"SZS status "` or `" says "` substring. An explicit SZS line always wins over any prover-specific pattern. 2. A permissive fallback converts any remaining `"SZS status "` (or `" says "`) line to its snake-case atom, so SZS additions like `EquivalentTheorem` become `{:ok, :equivalent_theorem}` without a code change. 3. Prover-specific patterns for solvers that do not (always) emit an SZS status line — Alt-Ergo, E, LEO-II, SPASS, Vampire, Waldmeister. Mirrors `sledgehammer_atp_systems.ML` (seL4/isabelle mirror, verified 2026-07-14). iProver always emits a proper SZS status line and classifies via layer 1. Output that none of the three layers matches comes back as `{:error, {:unrecognized_output, res_str}}`. """ @spec interpret_result(String.t()) :: atp_result() def interpret_result(res_str) do szs_line_match(res_str) || prover_specific_match(res_str) || {:error, {:unrecognized_output, res_str}} end defp prover_specific_match(res_str) do Enum.find_value(@prover_specific_results, fn {pattern, result} -> if String.contains?(res_str, pattern), do: result end) end # SZS status / says lines are extracted by regex with a word boundary # after the captured name, so e.g. "EquivalentTheorem" does not collapse # onto the "Equivalent" entry. Unknown names fall through to a # CamelCase → snake_case atom (permissive pass-through), bounded in # length to keep the atom table safe against pathological input. @szs_line_pattern ~r/(?:SZS status |\bsays )([A-Z][A-Za-z]{2,39})\b/ defp szs_line_match(res_str) do case Regex.run(@szs_line_pattern, res_str) do [_, name] -> Map.get(known_szs_map(), name) || {:ok, String.to_atom(Macro.underscore(name))} _ -> nil end end defp known_szs_map do Map.new(@known_szs_results, fn {name, result} -> {name, result} end) end @doc """ Interprets the `result` payload of a finished `use_theories` task — the map returned in `%IsabelleClient.Task{status: :finished, result: ...}`, with top-level keys `"ok"`, `"errors"`, `"nodes"`, etc. Classification is driven by the messages Isabelle emits, not by whether the task finished without errors. Each message is classified individually to avoid false positives from cross-message substring matches. See `per_lemma_results/3` for the verdict precedence; this function applies the same logic treating all messages as a single bucket. Always returns `{:ok, szs_status()}` — no payload shape currently causes the classifier to bail out with `{:error, _}`. Callers that also handle transport / session failures should catch those at the `AtpClient.Isabelle` layer instead. Note: payloads with `"ok": false` (proof errors) are still handled here — the proof-method failure veto prevents a theorem echo from being mistaken for a proof success. """ @spec interpret_isabelle_result(map()) :: {:ok, szs_status()} def interpret_isabelle_result(payload) when is_map(payload) do messages = extract_messages(payload) {:ok, classify_lemma_bucket(messages)} end defp theorem_at_start?(text), do: String.starts_with?(text, "theorem ") or String.starts_with?(text, "theorem:") defp nitpick_found?(text, what), do: String.contains?(text, "Nitpick found a") and String.contains?(text, what) defp quickcheck_found?(text, what), do: String.contains?(text, "Quickcheck found a") and String.contains?(text, what) defp found_a_proof?(text), do: String.contains?(text, "found a proof") defp found_proof?(text), do: String.contains?(text, "Found proof") defp out_of_memory?(text), do: String.contains?(text, "Out of memory") defp gave_up?(text), do: String.contains?(text, "Nitpick found no") or String.contains?(text, "No proof found") defp timeout?(text), do: String.contains?(text, "timed out") or String.contains?(text, "TIMEOUT") @doc """ Concatenates all `"message"` strings from a `use_theories` payload (the `result` field of a finished `%IsabelleClient.Task{}`) into a single newline-separated string. Walks all nodes in the payload, not just the first — useful when a theory transitively imports others and the server reports messages against multiple nodes. """ @spec extract_isabelle_text(map()) :: String.t() def extract_isabelle_text(payload) when is_map(payload), do: extract_text(payload) @typedoc """ Specification for one lemma in a generated theory body, as computed internally by `AtpClient.Isabelle.prove_lemmas/4` / `prove_tptp/3`. * `name` — lemma name as written in the body (the same string the classifier surfaces in `t:lemma_result/0`). * `range` — body-line range (inclusive) the lemma covers, from its `lemma :` line through the line before the next `lemma` (or the end of the body). Used by `per_lemma_results/3` to bucket Isabelle messages without relying on the exact `pos.line` of each message, which is noisy for sledgehammer / nitpick output. """ @type lemma_spec :: %{name: String.t(), range: Range.t()} @typedoc """ Per-lemma result returned by `per_lemma_results/3`. Carries the lemma name from the input body (not parsed out of Isabelle messages, which omit it for `oops`-based diagnostic methods) and the classified outcome. The on-disk line number is intentionally not exposed: it refers to the generated theory file, not the caller's TPTP source, so it would mislead more often than it would help. """ @type lemma_result :: %{name: String.t() | nil, result: atp_result()} @doc """ Classifies a finished `use_theories` payload into one entry per lemma, in the order given by `lemma_specs`. Messages from each lemma's body-line range are bucketed and scanned one-at-a-time — never across the concatenated text of a bucket. The cross-message scan that earlier versions of this function used produced false `:counter_satisfiable` verdicts when one message read `Nitpick found a model` and another read `Nitpick found no counterexample` (the `"Nitpick found a" + "counterexample"` substring test fires across the two), and false `:theorem` verdicts when `by auto` failed on a False goal (Isabelle still echoes `theorem name: ` at the `by` position next to the `Failed to finish proof` error). ## Options * `:file` — keep only messages whose `pos.file` ends with the given suffix (typically `"/.thy"`). Messages from the bundled `TPTP.thy` and from transitively imported theories are dropped. Defaults to no filter. * `:line_offset` — number to subtract from each Isabelle-reported `pos.line` before comparing against `lemma_spec.range`. Auto-wrap adds one line (`theory imports … begin`) to the on-disk file, so callers that pass a body without the header set this to `1`. Defaults to `0`. ## Verdict precedence Within a lemma bucket, signals collected per-message are reconciled in this order (each step short-circuits): 1. `:counter_satisfiable` (Nitpick / Quickcheck counter-example) — disproves the goal; dominates any concurrent proof attempt. 2. `:theorem` from a sledgehammer / metis `"found a proof"` line. 3. `:satisfiable` (Nitpick / Quickcheck model). 4. Proof-method failure veto — an `error`-kind message containing `"Failed to finish proof"` cancels a `theorem name:` verdict from the same bucket. 5. `:theorem` from a `theorem name:` completion notification. 6. `:timeout`, `:memory_out`, then `:gave_up`. Lemmas whose bucket is empty come back as `{:ok, :gave_up}`. """ @spec per_lemma_results(map(), [lemma_spec()], keyword()) :: [lemma_result()] def per_lemma_results(payload, lemma_specs, opts \\ []) when is_map(payload) do file_suffix = Keyword.get(opts, :file) line_offset = Keyword.get(opts, :line_offset, 0) messages = payload |> extract_messages() |> filter_by_file(file_suffix) Enum.map(lemma_specs, fn %{name: name, range: range} -> in_range = Enum.filter(messages, &message_in_range?(&1, range, line_offset)) %{name: name, result: {:ok, classify_lemma_bucket(in_range)}} end) end defp filter_by_file(messages, nil), do: messages defp filter_by_file(messages, suffix) when is_binary(suffix) do Enum.filter(messages, fn msg -> case get_in(msg, ["pos", "file"]) do file when is_binary(file) -> String.ends_with?(file, suffix) _ -> false end end) end defp message_in_range?(msg, range, line_offset) do case get_in(msg, ["pos", "line"]) do line when is_integer(line) -> line - line_offset in range _ -> false end end defp classify_lemma_bucket(messages) do verdicts = messages |> Enum.map(&classify_lemma_message/1) |> Enum.reject(&is_nil/1) cond do :counter_satisfiable in verdicts -> :counter_satisfiable :theorem_proof in verdicts -> :theorem :satisfiable in verdicts -> :satisfiable proof_method_failure?(messages) -> :gave_up :theorem_completion in verdicts -> :theorem :timeout in verdicts -> :timeout :memory_out in verdicts -> :memory_out :gave_up_hint in verdicts -> :gave_up any_error?(messages) -> :gave_up true -> :gave_up end end defp classify_lemma_message(msg) do text = Map.get(msg, "message", "") cond do nitpick_found?(text, "counterexample") -> :counter_satisfiable quickcheck_found?(text, "counterexample") -> :counter_satisfiable nitpick_found?(text, "model") -> :satisfiable quickcheck_found?(text, "model") -> :satisfiable found_a_proof?(text) -> :theorem_proof found_proof?(text) -> :theorem_proof timeout?(text) -> :timeout out_of_memory?(text) -> :memory_out gave_up?(text) -> :gave_up_hint theorem_at_start?(text) -> :theorem_completion true -> nil end end # Isabelle echoes `theorem name: ` at the `by` position even for a # tactic that fails to discharge the goal — the message is just the # current proof state, not a success notification. The accompanying error # message tells us the goal stayed open. defp proof_method_failure?(messages) do Enum.any?(messages, fn msg -> Map.get(msg, "kind") == "error" and msg |> Map.get("message", "") |> then( &(String.contains?(&1, "Failed to finish proof") or String.contains?(&1, "Failed to apply initial proof method")) ) end) end defp any_error?(messages), do: Enum.any?(messages, &(Map.get(&1, "kind") == "error")) defp extract_text(payload) do payload |> extract_messages() |> Enum.map_join("\n", fn msg -> Map.get(msg, "message", "") end) end defp extract_messages(%{"nodes" => nodes}) when is_list(nodes) do Enum.flat_map(nodes, fn node -> Map.get(node, "messages", []) end) end defp extract_messages(_), do: [] end