2023.7: Finite Traces: Doing Nothing (Productively)
THESE NOTES ARE BEING MODIFIED UP UNTIL CLASS; EXPECT CHANGES
The Forge homeworks have at least three learning goals, each of which represents a different layer of the course content:
- getting you practice with the low-level details of using Forge (e.g., predicates, sigs, etc.);
- familiarizing you with a number of modeling techniques (e.g., transition predicates, traces, assertions and examples, etc.); and
- giving you an opportunity to experiment with and critique different modeling choices (e.g., should this constraint be included in that predicate? What should we decide a well-formed state looks like? Is this set of sigs and fields right for our modeling goals? What should we interpret this English sentence as asking for?)
All of these will be vital when we get to the self-directed projects (and the first is coming fairly soon).
Today we'll return to tic-tac-toe to do more advanced modeling with traces. We'll also use asserts more generally.
Back To Tic-Tac-Toe: Ending Games
When we stopped last time, we'd written this run
command:
run {
wellformed
traces
} for exactly 10 State for {next is linear}
Reminder: Without a run
, an example
, or a similar command, running a Forge model will do nothing.
From this run
command, Forge will find traces of the system (here, games of Tic-Tac-Toe) represented as a linear sequence of exactly 10 State
objects.
Do you have any worries about the way this is set up?
Think, then click!
Are all Tic-Tac-Toe games 10 states long?Well, maybe; it depends on how we define a game. If we want a game to stop as soon as nobody can win, our exactly 10 State
bound is going to prevent us from finding games that are won before the final cell of the board is filled.
Let's add the following guard constraint to the move
transition, which forces games to end as soon as somebody wins.
all p: Player | not winner[pre, p]
Now we've got problems. Normally we could fix the issue by getting rid of the exactly
. Unfortunately, there's a hidden snag: when we tell Forge in this way that that next
is linear, it automatically makes the bound exact.
This behavior, which I admit is bizarre at first, exists for two reasons:
- historical reasons: we inherit
is linear
from Alloy, which uses somewhat different syntax to mean the same thing; and - performance reasons: since the
is linear
annotation is almost always used for trace-generation, and trace-generation solving time grows (in the worst case) exponentially in the length of the trace, we will almost always want to reduce unnecessary uncertainty. Forcing the trace length to always be the same reduces the load on the solver, and makes trace-generation somewhat more efficient.
But now we need to work around that constraint. Any ideas? Hint: do we need to have only one kind of transition in our system?
Think, then click!
No. A common way to allow trace length to vary is by adding a "do nothing" transition. (In the literature, this is called a stutter transition.)
The trick is in how to add it without also allowing a "game" to consist of nobody doing anything. To do that requires some more careful modeling.
Let's add an additional transition that does nothing. We can't "do nothing" in the predicate body, though -- an empty predicate body would just mean anything could happen. What we mean to say is that the state of the board remains the same, even if the before and after State
objects differ.
pred doNothing[pre: State, post: State] {
all row2: Int, col2: Int |
post.board[row2][col2] = pre.board[row2][col2]
}
We also need to edit the traces
predicate to allow doNothing
to take place:
pred traces {
-- The trace starts with an initial state
starting[Game.initialState]
no sprev: State | sprev.next = Game.initialState
-- Every transition is a valid move
all s: State | some Game.next[s] implies {
some row, col: Int, p: Player | {
move[s, row, col, p, Game.next[s]]
}
or
doNothing[s, Game.next[s]]
}
}
As it stands, this fix solves the overconstraint problem of never seeing an early win, but introduces a new underconstraint problem: we don't want doNothing
transitions to happen just anywhere!
Here's how I like to fix it:
gameOver[s: State] {
some p: Player | winner[s, p]
}
Why a new predicate? Because I want to use different predicates to represent different concepts.
When should a doNothing
transition be possible? When the game is over!
pred doNothing[pre: State, post: State] {
gameOver[pre] -- guard of the transition
pre.board = post.board -- effect of the transition
}
If we wanted to, we could add a not gameOver[pre]
to the move
predicate, enforcing that nobody can move at all after someone has won.
Do The Rules Allow Cheating?
Let's ask Forge whether a cheating
state is possible under the rules.
run {
wellformed
traces
some bad: State | cheating[bad]
} for exactly 10 State for {next is linear}
This should work---assuming we don't drop the is linear
annotation. Without it, nothing says that every state must be in the trace, and so Forge could produce an instance with an "unused" cheating state.
Checking Conjectures
When I was very small, I thought that moving in the middle of the board would guarantee a win at Tic-Tac-Toe. Now I know that isn't true. But could I have used Forge to check my conjecture (if I'd taken 1710 at that point in life)?
Think, then Click!
Here's how I did it:run {
wellformed
traces
-- "let" just lets us locally define an expression
-- good for clarity in the model!
-- here we say that X first moved in the middle
let second = Trace.next[Trace.initialState] |
second.board[1][1] = X
-- ...but X didn't win
all s: State | not winner[s, X]
} for exactly 10 State for {next is linear}
We could also write this using an assertion (which would fail):
pred moveInMiddle {
wellformed
traces
let second = Trace.next[Trace.initialState] |
second.board[1][1] = X
}
pred xWins {
all s: State | not winner[s, X]
}
assert moveInMiddle is sufficient for xWins
for exactly 10 State for {next is linear}
You might wonder how assert
can be used for predicates that take arguments. For example, suppose we had defined wellformed
to take a board, rather than quantifying over all
boards in its body. The assert
syntax can take (one layer of) quantification. Would move
preserve wellformed
-ness?
Here's how we'd write that. Notice we don't even need to use the Game
here (and thus don't need to give the is linear
annotation)! We're just asking Forge about 2 boards at a time:
pred someMoveFromWF[pre, post: Board] {
wellformed[pre]
some r, c: Int, p: Player | move[pre, r, c, p, post]
}
assert all pre,post: Board | move[pre,post] is sufficient for wellformed[post]
Reminder: The Evaluator
If you're viewing an instance, you can always select the evaluator tray and enter Forge syntax to see what it evaluates to in the instance shown. You can enter both formulas and expressions. We also have the ability to refer to objects in the world directly. E.g., we could try:
all s: State | not winner[s, X]
but also (assuming Board0
is an object):
winner[Board0, X]
Going Further
This illustrates a new class of queries we can ask Forge. Given parties following certain strategies, is it possible to find a trace where one strategy fails to succeed vs. another?
Challenge exercise: Write a run
that searches for a game where both parties always block immediate wins by their opponent. Is it ever possible for one party to win, if both will act to prevent a 3-in-a-row on the next turn?
Will This Always Work?
Let's say you're checking properties for a real system. A distributed-systems algorithm, maybe, or a new processor. Even a more complex version of Tic-Tac-Toe!
Next time, we'll talk about the problems with traces, which turn out to be central challenges in software and hardware verification.
(Optional) Modeling Tip: Dealing with Unsatisfiability
Many of you have had your first encounter with an over-constraint bug this week. Maybe you wrote an assert
and it seemed to never stop. Maybe you wrote a run
command and Sterling just produced an UNSAT0
result.
Getting back an unsat result can take a long time. Why? Think of the search process. If there is a satisfying instance, the solver can find it early. If there isn't, the solver needs to explore the entire space of possibilities. Yeah, there are smart algorithms for this, and it's not really enumerating the entire space of instances, but the general idea holds.
So if you run Forge and it doesn't seem to ever terminate, it's not necessary a Forge problem---surprising over-constraint bugs can produce this behavior.
So, how do you debug a problem like this? The first thing I like to do is reduce the bounds (if possible) and, if I still get unsat, I'll use that smaller, faster run to debug. But at that point, we're kind of stuck. UNSAT0
isn't very helpful.
Today I want to show you a very useful technique for discovering the problem. There are more advanced approaches we'll get to later in the course, but for now this one should serve you well.
The core idea is: encode an instance you'd expect to see as a set of constraints, run those constraints only, and then use the evaluator to explore why it fails your other constraints. Let's do an example!
#lang forge/bsl
-- NOT THE EXACT STENCIL!
sig State {
top: lone Element
}
sig Element {
next: lone Element
}
pred buggy {
all s: State | all e: Element {
s.top = e or reachable[e, s.top, next]
}
some st1, st2: State | st1.top != st2.top
all e: Element | not reachable[e, e, next]
}
test expect {
exampleDebug: {buggy} is sat
}
This test fails. But why?
run {
some st1, st2: State |
some ele1, ele2: Element | {
st1.top = ele1
st2.top = ele2
ele1.next = ele2
no ele2.next
}
} for exactly 2 State, exactly 2 Element
Given this instance, the question is: why didn't Forge accept it? There must be some constraint, or constraints, that it violates. Let's find out which one. We'll paste them into the evaluator...
some st1, st2: State | st1.top != st2.top
? This evaluates to#t
(true). No problem there.all s: State | all e: Element { s.top = e or reachable[e, s.top, next] }
? This evaluates to#f
(false). So this is a problem.
Now we proceed by breaking down the constraint. The outer shell is an all
, so let's plug in a concrete value:
all e: Element { State0.top = e or reachable[e, State0.top, next] }
? This evaluates to#f
. So the constraint fails forState0
.
Important: Don't try to name specific states in your model. They don't exist at that point.
Which element does the constraint fail on? Again, we'll substitute concrete values and experiment:
State0.top = Element0 or reachable[Element0, State0.top, next]
? This evaluates to#t
. What aboutState0.top = Element1 or reachable[Element1, State0.top, next]
?
Following this process very often leads to discovering an over-constraint bug, or a misconception the author had about the goals of the model or the meaning of the constraints.
Question: What's the problem here?
Think, then click!
Since the next
field never changes with time, the all
constraint doesn't allow states to vary the top
of the stack. Instead, we need a weaker constraint to enforce that the stack is shaped like a state.