Mix.install([
{:shot_to, "~> 0.2"},
{:kino, "~> 0.19"}
])Setup
ShotDs supplies the term representation; ShotTo the order on top of it.
import ShotDs.Hol.Dsl
import ShotDs.Hol.Definitions
import ShotDs.Hol.Sigils
alias ShotDs.Data.Type
alias ShotDs.Stt.TermFactory, as: TF
alias ShotDs.Util.LatexFormatter, as: LF
alias ShotTo.{Parameters, TypeOrder}Every comparison below is reported as a table of live results, so a small helper that turns a list of term pairs into typeset rows saves a lot of repetition.
defmodule Show do
@moduledoc "Rendering helpers for this notebook."
alias ShotDs.Util.LatexFormatter, as: LF
@doc "Renders `s > t` decisions for a list of `{s, t}` pairs."
def gt(pairs, params) do
rows =
Enum.map_join(pairs, "\n", fn {s, t} ->
"| $#{LF.format!(s)}$ | $#{LF.format!(t)}$ | `#{ShotTo.gt?(s, t, params)}` |"
end)
Kino.Markdown.new("| $s$ | $t$ | $s >^1_\\tau t$ |\n| :-- | :-- | :-- |\n" <> rows)
end
@doc "Renders `ShotTo.compare/3` verdicts for a list of `{s, t}` pairs."
def compare(pairs, params) do
rows =
Enum.map_join(pairs, "\n", fn {s, t} ->
"| $#{LF.format!(s)}$ | $#{LF.format!(t)}$ | `#{inspect(ShotTo.compare(s, t, params))}` |"
end)
Kino.Markdown.new("| $s$ | $t$ | `compare/3` |\n| :-- | :-- | :-- |\n" <> rows)
end
@doc "Renders `TypeOrder.type_gt?/3` decisions for a list of `{T, U}` pairs."
def type_gt(pairs, params) do
rows =
Enum.map_join(pairs, "\n", fn {t, u} ->
"| $#{LF.format!(t)}$ | $#{LF.format!(u)}$ | `#{TypeOrder.type_gt?(t, u, params)}` |"
end)
Kino.Markdown.new(
"| $T$ | $U$ | $T \\succ_{\\mathcal{T}} U$ |\n| :-- | :-- | :-- |\n" <> rows
)
end
endWhat ShotTo decides
ShotTo implements NCPO-LNF, the βη-long-normal computability path order of
Niederhauser and Middeldorp,
NCPO goes Beta-Eta-Long Normal Form
(WST 2025). That order is in turn the long-normal adaptation of NCPO from
The Computability Path Order for Beta-Eta-Normal Higher-Order Rewriting,
which lifts the computability path order (CPO) of Blanqui, Jouannaud and Rubio
from plain to normal higher-order rewriting.
The point of the construction is that a well-founded order compatible with βη cannot also be monotone: take $a > b$ and close under contexts to get $(\lambda y. c)\,a > (\lambda y. c)\,b$, i.e. $c > c$ after normalisation. The orders in this family therefore come in a pair $(>_\tau, \sqsupset_\tau)$ — the syntax-directed order $>_\tau$ lives on normal forms and orients rules, while its plain cousin $\sqsupset_\tau$ (CPO) carries monotonicity and well-foundedness, the two being tied together by βη-normal stability. Orienting $\ell >_\tau r$ for every rule of a higher-order rewrite system then proves that system terminating.
ShotTo differs from the authors' Haskell prototype in one deliberate way. There,
the parameters of the order — precedence, statuses, accessible positions, basic
sorts — are unknowns handed to an SMT solver, which searches for an assignment
that orients a whole rewrite system at once. Here they are fixed inputs supplied
by the caller, which turns $s >^1_\tau t$ into a plain decidable boolean predicate:
the shape a tableau prover needs when it has to decide, right now, which way to
orient a single equation.
Terms are already βη-long normal
ShotDsstores terms in βη-long normal form and maintains it as an invariant, so the LNF restriction costs nothing here. Two consequences are worth internalising before reading any output below: heads are always saturated (a constant of type $\iota\to\iota$ is stored as $\lambda x.\, \mathrm{f}\,x$, never bare), and applications are β-reduced on construction.
f = const "f", type_ii()
c = const "c", type_i()
Kino.Markdown.new("""
| built with | stored as |
| :-- | :-- |
| `const "f", type_ii()` | $#{LF.format!(f)}$ |
| `app f, c` | $#{LF.format! app f, c}$ |
""")The rules of NCPO-LNF
Below is Figure 1 of the NCPO-LNF paper, the inductive definition of
$>^{b,X}$. The relation is parameterised by a finite set $X$ of variables and a
level $b \in \{0, 1\}$ that keeps $\langle\mathcal{FV}\rangle$ from recurring into itself;
$s >^{b,X}_\tau t$ additionally demands $\tau(s) \succeq_{\mathcal{T}} \tau(t)$.
NCPO-LNF is $>^1_\tau$, i.e. $>^{1,\emptyset}_\tau$ — exactly what ShotTo.gt?/3
decides.
| rule | conclusion | side conditions |
|---|---|---|
| $\langle \mathcal{F}\triangleright\rangle$ | $f(\bar t) >^{b,X} v$ | $t_i \trianglerighteq^{\mathsf{nv}}_b \cdot \trianglerighteq_a \cdot \geqslant^b_\tau v$ for some $i$ |
| $\langle \mathcal{F}{=}_\mathsf{mul}\rangle$ | $f(\bar t) >^{b,X} g(\bar u)$ | $f \simeq_{\mathcal F} g$, $\mathsf{stat}(f) = \mathsf{mul}$, $\bar t \; (>^b_\tau \cup \vartriangleright^X_@ \cdot \to^!_\beta)_{\mathsf{mul}} \; \bar u$ |
| $\langle \mathcal{F}{=}_\mathsf{lex}\rangle$ | $f(\bar t) >^{b,X} g(\bar u)$ | $f \simeq_{\mathcal F} g$, $\mathsf{stat}(f) = \mathsf{lex}$, $\exists i.\; t_i >^b_\tau \cup \vartriangleright^X_@ \cdot \to^!_\beta\; u_i$, $\;\forall j < i.\; t_j = u_j$, $\;\forall j > i.\; f(\bar t) >^{b,X} t_j$ |
| $\langle \mathcal{F}{\succ}\rangle$ | $f(\bar t) >^{b,X} g(\bar u)$ | $f \succ_{\mathcal F} g$ and $f(\bar t) >^{b,X} u_i$ for all $i$ |
| $\langle \mathcal{FV}\rangle$ | $f(\bar t) >^{1,X} y(\bar u)$ | $f(\bar t) >^{0,X} y{\uparrow^\eta}$ and $f(\bar t) >^{1,X} u_i$ for all $i$ |
| $\langle \mathcal{F}\lambda\rangle$ | $f(\bar t) >^{b,X} \lambda y.v$ | $f(\bar t) >^{b,X \cup \{z\}} v[y/z]$, $\tau(y) = \tau(z)$, $z$ fresh |
| $\langle \mathcal{FX}\rangle$ | $f(\bar t) >^{b,X} y{\uparrow^\eta}$ | $y \in X$ |
| $\langle \lambda\triangleright\rangle$ | $\lambda x.t >^{b,X} v$ | $t[x/z] \geqslant^b_\tau v$, $\tau(x) = \tau(z)$, $z$ fresh |
| $\langle \lambda{=}\rangle$ | $\lambda x.t >^{b,X} \lambda y.v$ | $t[x/z] >^{b,X} v[y/z]$, $\tau(x) = \tau(y) = \tau(z)$, $z$ fresh |
| $\langle \lambda{\neq}\rangle$ | $\lambda x.t >^{b,X} \lambda y.v$ | $\lambda x.t >^{b,X} v[y/z]$, $\tau(x) \neq \tau(y) = \tau(z)$, $z$ fresh |
Two families of rules from NCPO are simply gone. There are no $\langle @\cdots\rangle$ rules, because a term $y(\bar t)$ with a variable head is versatile and NCPO never compares from a versatile left-hand side; and the small symbols extension was dropped, because its motivation — applicative, uncurried systems — disappears once everything is η-long. The rules $\langle \lambda{\triangleright}\eta\rangle$ and $\langle \lambda \mathcal{V}\rangle$ of NCPO are likewise absent.
Where the rules live
ShotTo.Ncpo.ncpo/6is the direct transcription of Figure 1: the arguments are $b$, $X$, $s$, $t$, a boolean toggling the $\tau$-check, and the parameters.ShotTo.gt?/3enters it at $b = 1$, $X = \varnothing$ with the check on. The auxiliary relations get their own private helpers —bawo/awofor $\trianglerighteq^{\mathsf{nv}}_b \cdot \trianglerighteq_a \cdot \geqslant^b_\tau$ of $\langle \mathcal{F}\triangleright\rangle$, andsswo?for $>^b_\tau \cup \vartriangleright^X_@ \cdot \to^!_\beta$ of the two $\langle \mathcal{F}{=}\rangle$ rules. Note thatlex_ext?/4discharges the trailing condition of $\langle \mathcal{F}{=}_\mathsf{lex}\rangle$ against the remaining right-hand arguments.
Ingredient 1: the admissible type order
Higher-order path orders lack the subterm property: $f(g(x)) > x$ cannot hold in general, since with $f_{a\to a\to a}$ and $g_{(a\to a)\to a}$ that is an encoding of the untyped lambda calculus. Recursive descent into a subterm therefore has to be paid for with a weak decrease in an order on types.
ShotTo.TypeOrder implements the concrete admissible order of Lemma 4 of the paper:
the smallest order containing the sort precedence $\succ_{\mathcal{S}}$ and the
right-argument relation $T \to U \vartriangleright_r U$, closed under
right-congruence.
params_t = Parameters.new(sort_precedence: %{a: 2, b: 1, c: 0})
[
{~t"a", ~t"b"},
{~t"b", ~t"c"},
{~t"a>b", ~t"b"},
{~t"a>b", ~t"a>c"},
{~t"a>b", ~t"b>c"},
{~t"c", ~t"b>b"},
{~t"a", ~t"b>b"}
]
|> Show.type_gt(params_t)Row four is right-congruence doing its job: $a \to b \succ_{\mathcal{T}} a \to c$ because $b \succ_{\mathcal{S}} c$ and the left components agree. Row five shows that a mismatched left component kills it.
The last two rows are the interesting ones. $c \succ_{\mathcal{T}} b \to b$ must fail on pain of unsoundness: $b \to b \vartriangleright_r b$, so it would give $c \succ_{\mathcal{T}} b$, contradicting $b \succ_{\mathcal{S}} c$. And $a \succ_{\mathcal{T}} b \to b$ fails too, even though $a$ is the largest sort — this particular admissible order never lets a base sort exceed an arrow, though admissibility in general does permit that.
Uncurried representation
ShotDs.Data.Typeis uncurried: $T_1 \to \cdots \to T_n \to a$ is%Type{goal: a, args: [t1, ..., tn]}.TypeOrderis written directly against that shape, so the right-argument relation is "drop the first element ofargs" rather than a peel of a curried spine.
Ingredient 2: the parameters
Definition (parameters). An instance of NCPO-LNF is fixed by a sort precedence $\succ_{\mathcal{S}}$, a precedence $\succeq_{\mathcal{F}}$ on constants with strict part $\succ_{\mathcal{F}}$ and equivalence $\simeq_{\mathcal{F}}$, a status $\mathsf{stat}(f) \in \{\mathsf{mul}, \mathsf{lex}\}$ per constant, a predicate selecting the basic sorts, and an accessibility predicate $\mathsf{Acc}(f, i)$ on argument positions.
ShotTo.Parameters takes each of these as a map, a MapSet, :all, or a function.
Precedences are given as integer ranks: equal rank means $\simeq_{\mathcal{F}}$,
greater rank means $\succ_{\mathcal{F}}$.
params_demo =
Parameters.new(
sort_precedence: %{o: 1, i: 0},
const_precedence: %{"f" => 2, "g" => 1, "c" => 0},
status: %{"f" => :lex, "g" => :mul},
basic_sorts: :all,
accessible: :all
)
Kino.Markdown.new("""
| query | result |
| :-- | :-- |
| `prec(params, "f")` | `#{Parameters.prec(params_demo, "f")}` |
| `gt?(params, "f", "g")` | `#{Parameters.gt?(params_demo, "f", "g")}` |
| `prec(params, "h")` | `#{Parameters.prec(params_demo, "h")}` |
| `equiv?(params, "h", "c")` | `#{Parameters.equiv?(params_demo, "h", "c")}` |
| `status(params, "g")` | `#{inspect(Parameters.status(params_demo, "g"))}` |
| `status(params, "h")` | `#{inspect(Parameters.status(params_demo, "h"))}` |
| `accessible?(params, "f", 1)` | `#{Parameters.accessible?(params_demo, "f", 1)}` |
| `basic?(params, :i)` | `#{Parameters.basic?(params_demo, :i)}` |
""")"h" was never mentioned, so it falls back to rank 0 — as does "c", which makes
the two equivalent. Unlisted constants are not "smaller than everything"; they collect
at rank zero, and unlisted statuses default to :lex.
Parameters.from_precedence_list/2 builds the ranks from an ordered list of tiers,
most-greater first, with inner lists grouping equivalent symbols.
tiered = Parameters.from_precedence_list([["f"], ["g", "h"], ["c"]])
Kino.Markdown.new("""
| query | result |
| :-- | :-- |
| `gt?(p, "f", "g")` | `#{Parameters.gt?(tiered, "f", "g")}` |
| `equiv?(p, "g", "h")` | `#{Parameters.equiv?(tiered, "g", "h")}` |
| `gt?(p, "h", "c")` | `#{Parameters.gt?(tiered, "h", "c")}` |
""")Validity of accessibility and basicness
Soundness of the order depends on $\mathsf{Acc}$ and basic satisfying the
compatibility conditions of Definition 5: for an accessible position $i$ of
$f : T_1 \to \cdots \to T_n \to a$ the sort $a$ must dominate every sort occurring in
$T_i$, and $a$ basic forces the sorts inside accessible arguments to be basic too.
Parameters.validate/2 is a best-effort check against a map of constant types.
const_types = %{"f" => ~t"b>a", "c" => ~t"a"}
bad = Parameters.new(sort_precedence: %{a: 0, b: 1})
good = Parameters.new(sort_precedence: %{a: 1, b: 0})
Kino.Markdown.new("""
| sort precedence | `validate/2` |
| :-- | :-- |
| $b \\succ a$ (accessible argument outranks the result) | `#{inspect(Parameters.validate(bad, const_types))}` |
| $a \\succ b$ | `#{inspect(Parameters.validate(good, const_types))}` |
""")Leaving accessible: :all together with basic_sorts: :all is vacuously sound —
the compatibility conditions become trivial — which is why those are the defaults.
The rules at work
Each rule below gets a minimal witness. The parameters are chosen per example so that exactly one rule carries the comparison.
⟨ℱ▷⟩ — an argument dominates the right-hand side
The workhorse. $f(\bar t) >^{b,X} v$ whenever some argument $t_i$ reaches $v$ through nonversatile descent, accessible subterms, and a final type-checked weak step. With the permissive defaults this collapses to the plain argument-subterm order.
f = TF.make_const_term("f", type_ii())
g = TF.make_const_term("g", type_ii())
c = TF.make_const_term("c", type_i())
[
{app(f, c), c},
{app(f, app(g, c)), app(g, c)},
{app(f, app(g, c)), c},
{c, app(f, c)}
]
|> Show.gt(Parameters.new())⟨ℱ≻⟩ — precedence on constants
$f \succ_{\mathcal{F}} g$ is not by itself enough: $f(\bar t)$ must additionally dominate every argument of the right-hand side. Flipping the precedence flips the verdict.
prec_fg = Parameters.new(const_precedence: %{"f" => 2, "g" => 1, "c" => 0})
prec_gf = Parameters.new(const_precedence: %{"f" => 1, "g" => 2, "c" => 0})
Kino.Layout.grid(
[
Kino.Markdown.new("**$f \\succ g$**"),
Show.gt([{app(f, c), app(g, c)}, {app(g, c), app(f, c)}], prec_fg),
Kino.Markdown.new("**$g \\succ f$**"),
Show.gt([{app(f, c), app(g, c)}, {app(g, c), app(f, c)}], prec_gf)
],
columns: 1
)⟨ℱ=ₗₑₓ⟩ and ⟨ℱ=ₘᵤₗ⟩ — equivalent heads
When the heads are equivalent the argument lists are compared by the head's status. Both extensions are strict: identical argument lists never yield $>$.
h = const "h", type_iii()
a = const "a", type_i()
b = const "b", type_i()
lex = Parameters.new(const_precedence: %{"h" => 2, "a" => 1, "b" => 0}, status: %{"h" => :lex})
mul = Parameters.new(const_precedence: %{"h" => 2, "a" => 1, "b" => 0}, status: %{"h" => :mul})
Kino.Layout.grid(
[
Kino.Markdown.new("**$\\mathsf{stat}(h) = \\mathsf{lex}$**"),
Show.gt(
[
{app(h, [a, a]), app(h, [a, b])},
{app(h, [a, b]), app(h, [a, a])},
{app(h, [a, a]), app(h, [a, a])}
],
lex
),
Kino.Markdown.new("**$\\mathsf{stat}(h) = \\mathsf{mul}$**"),
Show.gt(
[
{app(h, [a, a]), app(h, [a, b])},
{app(h, [a, b]), app(h, [a, a])},
{app(h, [a, a]), app(h, [a, a])}
],
mul
)
],
columns: 1
)⟨ℱ𝒱⟩ — a variable-headed right-hand side
$\langle\mathcal{FV}\rangle$ is the LNF replacement for NCPO's $\langle \mathcal{F}@\rangle$. It is the only rule that consumes the level parameter: the head check $f(\bar t) >^{0,X} y{\uparrow}\eta$ runs at $b = 0$, where $\langle FV\rangle$ itself is unavailable, so the recursion cannot spiral. Below, $Y$ occurs as an argument of $f$, which is what lets $\langle F\triangleright\rangle$ discharge the head check.
f2 = const "f", Type.new(:i, [type_ii(), :i])
y = var "Y", type_ii()
[{app(f2, [y, c]), app(y, c)}]
|> Show.gt(Parameters.new(const_precedence: %{"f" => 2, "c" => 1}))⟨ℱλ⟩ and ⟨ℱ𝒳⟩ — descending under a binder
$\langle \mathcal{F}\lambda\rangle$ opens an abstraction on the right with a fresh $z$ and records $z$ in $X$; $\langle \mathcal{FX}\rangle$ then discharges any later goal that is exactly that variable. The two are useless apart.
The comparison has to be reached from a $\tau$-free recursive call: the outer $\tau$-check would compare a base-typed left-hand side against an arrow type and fail. Here $\langle \mathcal{F}{\succ}\rangle$ provides that call.
k = const "k", type_i()
gg = const "gg", Type.new(:i, [type_ii()])
id = lambda type_i(), fn x -> x end
[{k, app(gg, id)}]
|> Show.gt(Parameters.new(const_precedence: %{"k" => 3, "gg" => 2}))graph TD
A["$$k > gg(\lambda x. x)$$"] -->|"$$\langle \mathcal{F} {\succ} \rangle: k \succ gg$$"| B["$$k >^{1,\varnothing} \lambda x. x$$"]
B -->|"$$\langle\mathcal{F}\lambda\rangle : \text{ open with fresh } z, X \gets \{z\}$$"| C["$$k >^{1,\{z\}} z$$"]
C -->|"$$\langle\mathcal{FX}\rangle : z \in X$$"| D["✓"]
classDef ok fill:#dcfce7,stroke:#16a34a;
class D ok;⟨λ▷⟩ and ⟨λ=⟩ — an abstraction on the left
$\langle \lambda\triangleright\rangle$ opens the left abstraction and asks for a type-checked weak step from its body. $\langle \lambda{=}\rangle$ applies when both sides are abstractions with binders of the same type: it reuses one fresh $z$ for both and drops the $\tau$-check on the recursive goal.
Recall that $f_{\iota\to\iota}$ is $\lambda x.\, f\,x$ in LNF, so the second row is a λ-vs-λ comparison even though it does not look like one.
lam_fc = lambda type_i(), fn _ -> app(f, c) end
lam_c = lambda type_i(), fn _ -> c end
[
{lam_fc, c},
{f, lam_c},
{lam_c, f}
]
|> Show.gt(Parameters.new(const_precedence: %{"f" => 2, "c" => 1}))$\langle \lambda{\neq}\rangle$ — the case where the two binders have different types — is the one rule no example in this notebook triggers. It is reachable only from a $\tau$-free recursive call, since the admissible type order of Lemma 4 relates two arrow types only when their argument types agree position-wise.
Case study: negation normal form
This is Example 7 of the NCPO paper: computing negation normal forms of first-order formulas, as an HRS. It is the example that motivates accessible subterms — NHORPO fails on it, with or without neutralization.
Two base sorts, $t$ for terms and $f$ for formulas; connectives $\neg_{f\to f}$, $\wedge, \vee_{f\to f\to f}$ and quantifiers $\forall, \exists_{(t\to f)\to f}$; variables $P, Q$ of sort $f$ and $R$ of type $t \to f$.
neg = const "¬", ~t"f>f"
conj = const "∧", ~t"f>f>f"
disj = const "∨", ~t"f>f>f"
all = const "∀", ~t"(t>f)>f"
ex = const "∃", ~t"(t>f)>f"
p = var "P", ~t"f"
q = var "Q", ~t"f"
r = var "R", ~t"t>f"
nnf =
Parameters.new(
sort_precedence: %{f: 1, t: 0},
const_precedence: %{"¬" => 1, "∧" => 0, "∨" => 0, "∀" => 0, "∃" => 0},
status: fn _ -> :mul end
)
[
{app(neg, app(neg, p)), p},
{app(neg, app(conj, [p, q])), app(disj, [app(neg, p), app(neg, q)])},
{app(neg, app(disj, [p, q])), app(conj, [app(neg, p), app(neg, q)])},
{app(neg, app(all, r)), app(ex, lambda(~t"t", fn x -> app(neg, app(r, x)) end))},
{app(neg, app(ex, r)), app(all, lambda(~t"t", fn x -> app(neg, app(r, x)) end))}
]
|> Show.gt(nnf)All five rules orient, so the system is terminating. Orientation is also strict: none of the five holds the other way round, and a plausible-looking non-rule is refused.
[
{p, app(neg, app(neg, p))},
{app(disj, [app(neg, p), app(neg, q)]), app(neg, app(conj, [p, q]))},
{app(ex, lambda(~t"t", fn x -> app(neg, app(r, x)) end)), app(neg, app(all, r))},
{app(neg, app(all, r)), app(ex, lambda(~t"t", fn x -> app(neg, app(neg, app(r, x))) end))}
]
|> Show.gt(nnf)The quantifier rule is where the machinery earns its keep. $\forall R >_\tau R\,x$ is hopeless — $x$ does not occur on the left at all — so the structurally-smaller relation $\vartriangleright^X_@$ has to supply the step instead.
graph TD
A["$$\neg(\forall R) > \exists(\lambda x. \neg (R x))$$"] ==>|"$$\langle\mathcal{F}{\succ}\rangle : \neg \succ \exists$$"| B["$$\neg(\forall R) >^{1,\varnothing} \lambda x. \neg(R x)$$"]
B -->|"$$\langle\mathcal{F}\lambda\rangle : \text{ fresh } z, X \gets \{z\}$$"| C["$$\neg(\forall R) >^{1,\{z\}} \neg(R z)$$"]
C ==>|"$$\langle\mathcal{F}{=_\mathsf{mul}}\rangle : \neg \simeq \neg$$"| D["$$\forall R (>_\tau \cup \rhd_@^{z} \cdot \to^!_\beta)_\mathsf{mul} R z$$"]
D -->|"$$\tau(\forall R) = \tau(R z) = f$$"| E["$$\forall R \rhd_a R \text{ via } \mathsf{Acc}(\forall) \ni 1$$"]
E -->|"$$Pos_f(t) = \varnothing\text{, so }z\text{ is admissible}$$"| F["✓"]
classDef ok fill:#dcfce7,stroke:#16a34a;
class F ok;The right branch is exactly the payoff of accessible subterms. Since $f \mathrel{\dot\geqslant_{\mathcal B}} t \to f$ and $\mathcal{P}\mathsf{os}_f(t \to f) \subseteq \mathcal{P}\mathsf{os}^+(t \to f)$, position $1$ of $\forall$ may be declared accessible, giving $\forall R \vartriangleright_a R$; and because $\mathcal{P}\mathsf{os}_f(t) = \varnothing$, the fresh $z$ collected by $\langle \mathcal{F}\lambda\rangle$ may be applied to it. That is $\vartriangleright^X_@$ in full.
Which of the four parameters actually carry this proof? Perturbing them one at a time answers that, and the answer is not the one the paper's presentation suggests.
prop_rule = {app(neg, app(neg, p)), p}
quant_rule = {app(neg, app(all, r)), app(ex, lambda(~t"t", fn x -> app(neg, app(r, x)) end))}
variants = [
{"as above", nnf},
{"`accessible: MapSet.new()`", %{nnf | accessible: MapSet.new()}},
{"`const_precedence: %{}`", %{nnf | const_precedence: %{}}},
{"`sort_precedence: %{}`", %{nnf | sort_precedence: %{}}},
{"`basic_sorts: MapSet.new()`", %{nnf | basic_sorts: MapSet.new()}}
]
rows =
Enum.map_join(variants, "\n", fn {label, ps} ->
"| #{label} | `#{ShotTo.gt?(elem(prop_rule, 0), elem(prop_rule, 1), ps)}` " <>
"| `#{ShotTo.gt?(elem(quant_rule, 0), elem(quant_rule, 1), ps)}` |"
end)
Kino.Markdown.new(
"| parameters | $\\neg\\neg P$ rule | $\\neg \\forall R$ rule |\n| :-- | :-- | :-- |\n" <> rows
)Accessibility and the constant precedence are load-bearing for the quantifier rule;
the sort precedence and the choice of basic sorts are not. The reason both survive
their removal is that the quantifier rule's type obligation is
$\tau(\forall R) = \tau(R\,x) = f$ — an equality, discharged by the reflexive part
of $\succeq_{\mathcal{T}}$ — and basic_sorts only gates the nonversatile descent in
$\langle \mathcal{F}\triangleright\rangle$, which this derivation reaches via
$\vartriangleright_a$ instead. The propositional rules need neither: pure
$\langle \mathcal{F}\triangleright\rangle$ and $\langle \mathcal{F}{=}_\mathsf{mul}\rangle$ carry them.
Case study: symbolic differentiation
Example 6 of the NCPO paper, and a good illustration of what changes when the order moves to long normal forms.
$$ \mathrm{diff}(\lambda x.\, \sin(F\,x)) \to (\lambda x.\, \cos(F\,x)) \times \mathrm{diff}(F) $$
$$ \mathrm{diff}(F \times G) \to (\mathrm{diff}(F) \times G) + (F \times \mathrm{diff}(G)) $$
The paper fixes $\mathsf{ar}(\mathrm{diff}) = 1$ and $\mathsf{ar}(+) = \mathsf{ar}(\times) = 2$, so $\mathrm{diff}(F)$ is a term of type $r \to r$. In LNF that partial application does not exist: heads are saturated, so $\mathrm{diff}$ has arity $2$, $+$ and $\times$ have arity $3$, and $\mathrm{diff}(F)$ is stored as $\lambda y.\, \mathrm{diff}(F, y)$.
diff_rules = with_context(
~e[F: r>r, G: r>r, sin: r>r, cos: r>r,
diff: (r>r)>r>r, times: (r>r)>(r>r)>r>r, plus: (r>r)>(r>r)>r>r], fn ->
[
{~g{ diff @ ^[X: r]: (sin @ (F @ X)) },
~g{ times @ (^[X: r]: (cos @ (F @ X))) @ (diff @ F) }},
{~g{ diff @ (times @ F @ G) },
~g{ plus @ (times @ (diff @ F) @ G) @ (times @ F @ (diff @ G)) }}
]
end)
Kino.Markdown.new("Left-hand side of rule 1, as stored: $#{LF.format!(elem(hd(diff_rules), 0))}$")The paper gives all symbols multiset status. That no longer works here, and the reason is the saturation: after $\langle \lambda{=}\rangle$ peels the outer binder, the recursive goal $\mathrm{diff}(\ldots, z) >^{1,X} \mathrm{diff}(F, w)$ compares argument pairs whose second components are two unrelated fresh variables, which no multiset comparison can relate. Lexicographic status walks the arguments left to right instead, decides at position 1, and is left with $\mathrm{diff}(\ldots) > w$, which $\langle \mathcal{FX}\rangle$ discharges.
prec_diff = %{"diff" => 1, "sin" => 0, "cos" => 0, "+" => 0, "×" => 0}
all_mul = Parameters.new(const_precedence: prec_diff, status: fn _ -> :mul end)
all_lex = Parameters.new(const_precedence: prec_diff, status: fn _ -> :lex end)
diff_lex =
Parameters.new(
const_precedence: prec_diff,
status: fn name -> if name == "diff", do: :lex, else: :mul end
)
Kino.Markdown.new("""
| status | rule 1 | rule 2 |
| :-- | :-- | :-- |
| all $\\mathsf{mul}$ | `#{ShotTo.gt?(elem(hd(diff_rules), 0), elem(hd(diff_rules), 1), all_mul)}` | `#{ShotTo.gt?(elem(List.last(diff_rules), 0), elem(List.last(diff_rules), 1), all_mul)}` |
| all $\\mathsf{lex}$ | `#{ShotTo.gt?(elem(hd(diff_rules), 0), elem(hd(diff_rules), 1), all_lex)}` | `#{ShotTo.gt?(elem(List.last(diff_rules), 0), elem(List.last(diff_rules), 1), all_lex)}` |
| $\\mathrm{diff}$ $\\mathsf{lex}$, rest $\\mathsf{mul}$ | `#{ShotTo.gt?(elem(hd(diff_rules), 0), elem(hd(diff_rules), 1), diff_lex)}` | `#{ShotTo.gt?(elem(List.last(diff_rules), 0), elem(List.last(diff_rules), 1), diff_lex)}` |
""")Only $\mathrm{diff}$'s status matters, which confirms the diagnosis: it is the sole symbol that ends up compared against itself with a saturated argument list.
Using ShotTo as an orientation oracle
The reason ShotTo exists is to answer one question for a higher-order prover:
given an equation, which way should I rewrite? Here is that question for the
definitional expansions of equality that a HOL prover has to choose between —
primitive equality $=^\iota$ versus its Leibniz encoding.
prim = equals_term type_i()
leibniz = leibniz_equality type_i()
Kino.Markdown.new("""
- primitive: $#{LF.format!(prim)}$
- Leibniz: $#{LF.format!(leibniz)}$
""")expand = Parameters.new(const_precedence: %{"=" => 1})
flat = Parameters.new(const_precedence: %{"=" => 0, "∀" => 1, "≡" => 2})
Kino.Layout.grid(
[
Kino.Markdown.new("**$= \\;\\succ\\; \\forall, \\equiv$ — expand equality**"),
Show.compare([{prim, leibniz}], expand),
Kino.Markdown.new("**$\\equiv \\;\\succ\\; \\forall \\;\\succ\\; =$ — no orientation**"),
Show.compare([{prim, leibniz}], flat)
],
columns: 1
)With $=$ at the top of the precedence the expansion is oriented left to right: the
prover may replace $X = Y$ by $\forall P.\; P\,X \equiv P\,Y$ and know the process
terminates. Reversing the precedence does not orient it the other way — it yields
:incomparable, since contracting the Leibniz encoding back to $=$ would have to
grow the head's rank without shrinking anything else.
Caveats
NCPO-LNF is not known to be transitive
Transitivity is stated as an open problem in both papers. It is not a theoretical worry: witnesses are easy to come by, and small.
p_a = const "p", ~t"a"
q_b = const "q", ~t"b"
lam_p = lambda ~t"b", fn _ -> p_a end
trans = Parameters.new(sort_precedence: %{a: 1, b: 0}, const_precedence: %{"p" => 1, "q" => 0})
[
{lam_p, p_a},
{p_a, q_b},
{lam_p, q_b}
]
|> Show.gt(trans)$\lambda x_b.\, p > p$ by $\langle \lambda\triangleright\rangle$, and $p > q$ by $\langle F\succ\rangle$ — but $\lambda x_b.\, p > q$ fails, and it fails in the type order rather than in the term rules: $\tau(\lambda x_b.\,p) = b \to a$ and $\tau(q) = b$, and $b \to a \succeq_{\mathcal{T}} b$ does not hold.
[{~t"b>a", ~t"b"}, {~t"b>a", ~t"a"}, {~t"a", ~t"b"}]
|> Show.type_gt(trans)The practical consequence is the one stated in the README: use ShotTo to decide the
orientation of one pair at a time. Using ShotTo.compare/3 as a sort comparator
over a collection of terms is not sound.
Incomparability is a real outcome
compare/3 has four outcomes, not three. Two constants of the same type and
equivalent rank are simply unrelated.
d = const "d", type_i()
equal_rank = Parameters.new(const_precedence: %{"c" => 1, "d" => 1})
[
{app(f, c), c},
{c, app(f, c)},
{c, c},
{c, d}
]
|> Show.compare(equal_rank)ETS hygiene
Every lambda opening allocates a fresh ShotDs free variable, and
$\langle \mathcal{F}\lambda\rangle$, $\langle \lambda{\triangleright}\rangle$,
$\langle \lambda{=}\rangle$ and $\langle \lambda{\neq}\rangle$ all open lambdas. A
single ShotTo.gt?/3 call can therefore mint a good number of them.
ShotTo.Ncpo.ncpo_gt?/3 already wraps each comparison in a scratchpad when none is
active, so the fresh variables die with the call. Callers running many comparisons in
a batch can hoist that: opening one scratchpad around the whole batch keeps every
intermediate variable out of the global term pool at once.
batch = for s <- [app(f, c), c, lam_fc, lam_c], t <- [c, app(f, c), lam_c], do: {s, t}
oriented =
TF.with_scratchpad!(fn ->
Enum.count(batch, fn {s, t} -> ShotTo.gt?(s, t, Parameters.new()) end)
end)
Kino.Markdown.new("`#{oriented}` of `#{length(batch)}` pairs oriented, one scratchpad.")