Using Spot for teaching LTL and ω-automata (TEAL'26 demo)¶
Quick intro¶
Spot is a C++ library for linear-time temporal logic and ω-automata.
It also comes with command-line tools, and Python bindings.
The latter allow notebooks such as this one, which can be easily used for research, prototyping, demonstration, teaching. In particular the notebook interface makes it easy to design lab sessions where students combine existing algorithms to build practical applications.
In addition to the above, the Spot website also has a web-based tool focused on LTL formulas and their mapping to ω-automata.
import spot
from spot.jupyter import display_inline
from copy import copy
spot.setup()
Simple objects: LTL, Büchi automata, words¶
f = spot.formula("F(a & X(!a & Gb))")
f
f.translate()
a = spot.translate("a & !b & X!b & G(a <-> X!a) & G(b <-> XXX!b) & GFc")
a
r = a.accepting_run()
r.highlight(5)
a
w = spot.twa_word(r)
w
w.show()
w.use_all_aps(a.ap_vars())
w.show()
Testing if two formulas are equivalent¶
f = spot.formula("a W (b R c)")
display(f)
g = f.unabbreviate("WR")
display(g)
spot.are_equivalent(f, g)
True
Enumerating words¶
ag = spot.translate('!a U (b & G(a <-> Xc))')
ag
le = spot.lasso_enumerator(ag, min_stem=0, max_stem=2, min_cycle=1, max_cycle=2)
while w := le.next_word():
print(w)
!a & b; cycle{!a & !c}
a & b; cycle{a & c}
!a & b; cycle{!a & !c; !a & !c}
!a & b; cycle{a & !c; !a & c}
a & b; cycle{!a & c; a & !c}
a & b; cycle{a & c; a & c}
!a; !a & b; cycle{!a & !c}
!a; a & b; cycle{a & c}
!a & b; !a & !c; cycle{!a & !c}
!a & b; a & !c; cycle{a & c}
a & b; !a & c; cycle{!a & !c}
a & b; a & c; cycle{a & c}
!a; !a & b; cycle{!a & !c; !a & !c}
!a; !a & b; cycle{a & !c; !a & c}
!a; a & b; cycle{!a & c; a & !c}
!a; a & b; cycle{a & c; a & c}
!a & b; !a & !c; cycle{!a & !c; !a & !c}
!a & b; !a & !c; cycle{a & !c; !a & c}
!a & b; a & !c; cycle{!a & c; a & !c}
!a & b; a & !c; cycle{a & c; a & c}
a & b; !a & c; cycle{!a & !c; !a & !c}
a & b; !a & c; cycle{a & !c; !a & c}
a & b; a & c; cycle{!a & c; a & !c}
a & b; a & c; cycle{a & c; a & c}
le = spot.lasso_enumerator(ag, min_stem=0, max_stem=2, min_cycle=1, max_cycle=2)
results=[]
while r := le.next_run():
r.highlight(5)
results.append(copy(ag))
ag.remove_highlight_edges()
display_inline(*results, per_row=5)
Example task: testing stutter-invariance, and returning counterexamples¶
A formula $\varphi$ is stutter-invariant iff $(w_1 \models \varphi) \iff (w_2 \models \varphi)$ for any pair of ω-words $(w_1,w_2)$ that differ only by removing duplicate letters, or by duplicating letters.
(Any $\mathsf{X}$-free LTL formula is necessarily stutter-invariant, but this is only a sufficient condition.)
f = spot.formula("F(a & X(!a & Gb))")
spot.is_stutter_invariant(f)
True
g = spot.formula('!a U (b & G(a <-> Xc))')
spot.is_stutter_invariant(g)
False
Let's find a proof for the stutter-sensitivity of g.
First, here is the closure operator, that allows automata to skip duplicate letters.
pos = g.translate()
display_inline(pos.show('.t'), spot.closure(pos), per_row=2)
Using closure, we can build a stutter-invariance test using the following Theorem:
A formula $\varphi$ is stutter invariant iff $\mathcal{L}(closure(A_\varphi)) \cap \mathcal{L}(closure(A_{\lnot\varphi})) = \emptyset$
def is_stutter_invariant(g):
pos = spot.formula(g).translate()
neg = spot.formula.Not(g).translate()
word = spot.product(spot.closure(pos),
spot.closure(neg)).accepting_word()
if word is None:
print(f"{g} is stutter-invariant")
return
print(f"{g} is not stutter-invariant")
word.use_all_aps(pos.ap_vars())
waut = word.as_automaton()
if waut.intersects(pos):
word2 = spot.sl2(waut).intersecting_word(neg)
word2.simplify()
accword, rejword = word, word2
else:
word2 = spot.sl2(waut).intersecting_word(pos)
word2.simplify()
accword, rejword = word2, word
print(f"Accepted: {accword}\nRejected: {rejword}")
pos.intersecting_run(accword.as_automaton()).highlight(4)
display_inline(pos)
is_stutter_invariant(f)
F(a & X(!a & Gb)) is stutter-invariant
is_stutter_invariant(g)
!a U (b & G((a & Xc) | (!a & X!c))) is not stutter-invariant
Accepted: a & b & !c; !a & !b & c; cycle{!a & !b & !c}
Rejected: a & b & !c; a & b & !c; !a & !b & c; cycle{!a & !b & !c}
Extensions to LTL: PSL and QLTL¶
Let's build an automaton that accepts all ω-words where $a$ holds at every even position. The "at every even position" part cannot be expressed in pure LTL without introducing an atomic proposition tracking even positions.
spot.translate('!even & G(even xor Xeven)')
a = spot.translate('!even & G(even xor Xeven) & G(even -> a)')
a
remover = spot.remove_ap()
remover.add_ap('even')
a = remover.strip(a)
a
Using Quantified-LTL, we can write the quantification inside the QLTL formula:
g = spot.formula('∃even: !even & G(even xor Xeven) & G(even -> a)')
g.translate()
Using PSL we can express this property without quantification.
The syntax $\{r\}\mathrel{\Box\kern-1.7pt\raise.4pt\hbox{$\mathord{\rightarrow}$}}\alpha$ means that $\alpha$ should hold on the infinite suffix starting on the last letter of any finite prefix matching $r$. So we just need $r$ to capture all finite words of even length.
h = spot.formula('{(1;1)*}[]->a')
h.translate()
spot.are_equivalent(g, h)
True
Applications of LTL and ω-automata¶
Model Checking¶
Given a model $M$ and a formula $\varphi$, decide if $M\models \varphi$, i.e., $\mathcal{L}(M)\subseteq \mathcal{L}(\varphi)$.
import spot.ltsmin
%%pml model
active proctype P() {
int a = 0;
int b = 0;
x: if
:: a < 3 && b < 3 -> a = a + 1; goto x;
:: a < 3 && b < 3 -> b = b + 1; goto x;
fi
}
SpinS Promela Compiler - version 1.1 (3-Feb-2015) (C) University of Twente, Formal Methods and Tools group Parsing tmpsd_0g30d.pml... Parsing tmpsd_0g30d.pml done (0.0 sec) Optimizing graphs... StateMerging changed 2 states/transitions. RemoveUselessActions changed 2 states/transitions. RemoveUselessGotos changed 2 states/transitions. RenumberAll changed 1 states/transitions. Optimization done (0.0 sec) Generating next-state function ... Instantiating processes Statically binding references Creating transitions Generating next-state function done (0.0 sec) Creating state vector Creating state labels Generating transitions/state dependency matrices (2 / 3 slots) ... Found 5 / 15 ( 33.3%) Guard/slot reads Found 6 / 6 (100.0%) Transition/slot tests Found 2, 4, 4 / 18 ( 11.1%, 22.2%, 22.2%) Actions/slot r,W,w Found 2, 4, 4 / 6 ( 33.3%, 66.7%, 66.7%) Atomics/slot r,W,w Found 6, 4, 4 / 6 (100.0%, 66.7%, 66.7%) Transition/slot r,W,w Generating transition/state dependency matrices done (0.0 sec) Generating guard dependency matrices (5 guards) ... Found 3 / 12 ( 25.0%) Guard/guard dependencies Found 8 / 10 ( 80.0%) Transition/guard writes Found 4 / 4 (100.0%) Transition/transition writes Found 2 / 12 ( 16.7%) !MCE guards Found 1 / 2 ( 50.0%) !MCE transitions Found 7 / 25 ( 28.0%) !ICE guards Found 10 / 10 (100.0%) !NES guards Found 4 / 4 (100.0%) !NES transitions Found 8 / 10 ( 80.0%) !NDS guards Found 0 / 10 ( 0.0%) MDS guards Found 4 / 10 ( 40.0%) MES guards Found 0 / 4 ( 0.0%) !NDS transitions Found 0 / 2 ( 0.0%) !DNA transitions Found 2 / 2 (100.0%) Commuting actions Generating guard dependency matrices done (0.0 sec) Written C code to /home/adl/git/spot/tests/tmpsd_0g30d.pml.spins.c Compiled C code to PINS library tmpsd_0g30d.pml.spins
model
ltsmin model with the following variables: P_0._pc: pc P_0.a: int P_0.b: int
k = model.kripke(['P_0.a < 2', 'P_0.b > 1']); k.show(".<5")
k.show('.1K<5')
To test if $M \models \varphi$, we verify that $\mathcal{L}(M\otimes A_{\lnot\varphi})=\emptyset$.
def model_check(M, φ):
notφ = spot.formula.Not(φ)
ss = M.kripke(spot.atomic_prop_collect(notφ))
return spot.otf_product(ss, notφ.translate()).is_empty() #.accepting_run().project(ss).as_twa(True)
model_check(model, 'F("P_0.b > 2")')
Reactive Synthesis¶
Given a partition of atomic propositions as inputs and output ($AP=\mathcal{I}\uplus\mathcal{O}$) decide if it is possible to build a reactive controller, that assigns values to output propositions based on the history of input values, in such a way that an LTL specification over both types of propositions is satisfied.
import buddy
spec = spot.formula("(F(i0 & X(i1)) <-> Fo)")
spec
dpa = spec.translate('parity', 'det') # in the general case, we want a DPA
dpa
bddout = buddy.bdd_ithvar(dpa.register_ap('o'))
game = spot.split_2step(dpa, bddout)
game
spot.solve_game(game)
True
spot.highlight_strategy(game)
game
m = spot.solved_game_to_separated_mealy(game)
m
spot.reduce_mealy_here(m)
m
spot.mealy_machine_to_aig(m, "isop").show("h")
Interactive visualisations¶
a = spot.automaton('colorful2.hoa')
a
spot.acd(a)
Protyping with graphical logs¶
def print_scc_info(si, log=""):
n = si.scc_count()
print(log, "Number of SCCs:", n)
for s in range(n):
print("{} - SCC{}: {}{}".format(log, s, si.states_of(s), " trivial" if si.is_trivial(s) else ""))
def display_scc(si, sccnum):
display(si.split_on_sets(sccnum, [], True)[0])
def log_is_empty1(log, g):
print(log, "is_empty() running SCC decomposition")
# We need to pass the TRACK_STATE option for print_scc_info() to work
si = spot.scc_info_with_options(g, spot.scc_info_options_TRACK_STATES)
print_scc_info(si, log)
for scc_num in range(si.scc_count()):
if si.is_trivial(scc_num):
continue
if not log_is_scc_empty1("{}SCC{}:".format(log, scc_num), si, scc_num):
return False
return True
def log_is_scc_empty1(log, si, scc_num, acc=None):
if acc is None:
acc = si.get_aut().acc()
print("{} is_scc_empty() on".format(log))
display_scc(si, scc_num)
occur, common = si.acc_sets_of(scc_num), si.common_sets_of(scc_num)
print("{} occur={}, common={}".format(log, occur, common))
acc = acc.restrict_to(occur)
acc = acc.remove(common, False)
print("{} acceptance reduced to {}".format(log, acc.get_acceptance()))
if acc.is_t(): return False
if acc.is_f(): return True
if acc.accepting(occur): return False
for idx, cl in enumerate(acc.top_disjuncts()):
print("{}clause{}: checking clause {}".format(log, idx, cl.get_acceptance()))
fu = cl.fin_unit() # Is there Fin at the top level
if fu:
print("{}clause{}: unit-Fin(s) present {}".format(log, idx, fu))
with spot.scc_and_mark_filter(si, scc_num, fu) as filt:
filt.override_acceptance(cl.remove(fu, True))
if not log_is_empty1("{}clause{}:Fin{}=1:".format(log, idx, fu), filt):
return False
else:
# Pick some Fin term anywhere in the formula
fo = cl.fin_one()
print("{}clause{}: arbritrary Fin selected {}".format(log, idx, fo))
# Try to solve assuming Fin(fo)=True
with spot.scc_and_mark_filter(si, scc_num, [fo]) as filt:
filt.override_acceptance(cl.remove([fo], True))
if not log_is_empty1("{}clause{}:Fin({})=1:".format(log, idx, fo), filt):
return False
# Try to solve assuming Fin(fo)=False
if not log_is_scc_empty1("{}clause{}:Fin({})=0:".format(log, idx, fo),
si, scc_num, acc.force_inf([fo])):
return False
return True
log_is_empty1("", a)
Running other tools and explaining their output¶
def ltl2dela(f):
f = spot.formula(f)
return spot.automaton(f"owl ltl2dela -f {f:q} |")
f = "(a U b) & (GFa -> FGb)"
aut = ltl2dela(f)
aut
spot.match_states_decorate(aut, f)
aut