mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
re-evaluates each trace offline under the full engine, a crash-only detector, a single-state check and a single-step property triple, and reports what each refutes: the oracles vary while the traces stay fixed, which separates a defect an oracle cannot express from one an explorer never reached. a disagreement with the verdicts a run recorded exits nonzero rather than being counted as a finding.
282 lines
9.8 KiB
Go
282 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/ltl"
|
|
)
|
|
|
|
// tripleWindow is the horizon a property triple has: an obligation armed at one
|
|
// observation must discharge at the next, and nothing outlives that.
|
|
const tripleWindow = 2
|
|
|
|
// singleStateFormula is what a checker holding one observation can refute.
|
|
// Every operator that defers or repeats an obligation is replaced by the
|
|
// constant that makes the formula around it trivially satisfied, so the only
|
|
// refutation left is a predicate read at a single observation. The outermost
|
|
// always survives because it is what says "check this at each observation",
|
|
// which costs no history.
|
|
func singleStateFormula(formula ltl.Formula) ltl.Formula {
|
|
if always, ok := formula.(ltl.AlwaysFormula); ok {
|
|
always.Inner = stateless(always.Inner, true)
|
|
return always
|
|
}
|
|
return stateless(formula, true)
|
|
}
|
|
|
|
// singleStepFormula is the property triple: one step of history, and no
|
|
// obligation surviving past the next observation. A next keeps its one-step
|
|
// deferral, and an eventually of any window shrinks to the two observations a
|
|
// triple spans.
|
|
func singleStepFormula(formula ltl.Formula) ltl.Formula {
|
|
if always, ok := formula.(ltl.AlwaysFormula); ok {
|
|
always.Inner = oneStep(always.Inner, true)
|
|
return always
|
|
}
|
|
return oneStep(formula, true)
|
|
}
|
|
|
|
// singleStateExpresses and singleStepExpresses decide, from the property's form
|
|
// alone, whether the reduced oracle can still state the property after its
|
|
// rewrite. An oracle that cannot state a property does not get to refute it,
|
|
// and is reported as silent on it rather than as either verdict.
|
|
//
|
|
// Two shapes defeat a reduction. The rewrite shortens an obligation window the
|
|
// oracle's horizon cannot hold, leaving it to refute a property strictly
|
|
// stronger than the one the author wrote. Or the rewrite leaves a formula whose
|
|
// verdict no longer depends on the trace, leaving it to report the same answer
|
|
// everywhere. Either way the column would carry a verdict about a property
|
|
// nobody wrote, which is worth less than an admission that the oracle is out of
|
|
// its depth.
|
|
func singleStateExpresses(formula ltl.Formula) bool {
|
|
return dependsOnTrace(singleStateFormula(formula))
|
|
}
|
|
|
|
func singleStepExpresses(formula ltl.Formula) bool {
|
|
return !truncatesWindow(formula) &&
|
|
dependsOnTrace(singleStepFormula(formula))
|
|
}
|
|
|
|
// truncatesWindow reports whether the single-step rewrite had to shorten a
|
|
// window to fit a triple. Where it did, the reduced oracle is checking a
|
|
// stronger property than the author wrote, so a refutation of it is not the
|
|
// same event as a refutation of the property.
|
|
func truncatesWindow(formula ltl.Formula) bool {
|
|
if always, ok := formula.(ltl.AlwaysFormula); ok {
|
|
return shortensWindow(always.Inner, true)
|
|
}
|
|
return shortensWindow(formula, true)
|
|
}
|
|
|
|
// shortensWindow walks the same shape oneStep rewrites, and reports only the
|
|
// shortening that strengthens the formula. Polarity is what separates the two:
|
|
// a shorter window under an even number of negations demands the same thing
|
|
// sooner, so a refutation of it need not be a refutation of the property, while
|
|
// under an odd number it asks for less and its refutations stay sound. The
|
|
// operators oneStep erases rather than shortens are erased to the constant its
|
|
// position is satisfied by, which also only asks for less.
|
|
func shortensWindow(formula ltl.Formula, positive bool) bool {
|
|
switch concrete := formula.(type) {
|
|
case ltl.EventuallyFormula:
|
|
return positive && windowOutlastsTriple(concrete)
|
|
case ltl.NowFormula:
|
|
return shortensWindow(concrete.Inner, positive)
|
|
case ltl.NotFormula:
|
|
return shortensWindow(concrete.Inner, !positive)
|
|
case ltl.AndFormula:
|
|
return shortensWindow(concrete.Left, positive) || shortensWindow(concrete.Right, positive)
|
|
case ltl.OrFormula:
|
|
return shortensWindow(concrete.Left, positive) || shortensWindow(concrete.Right, positive)
|
|
case ltl.ImpliesFormula:
|
|
return shortensWindow(concrete.Antecedent, !positive) || shortensWindow(concrete.Consequent, positive)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// windowOutlastsTriple asks whether an obligation can still be open after the
|
|
// two observations a triple spans. A window counted in time is outside what a
|
|
// triple can state whatever its length, because a triple has no clock: it can
|
|
// say "at the next observation" and nothing about when that arrives.
|
|
func windowOutlastsTriple(formula ltl.EventuallyFormula) bool {
|
|
if formula.HasStepBound {
|
|
return formula.StepBound > tripleWindow
|
|
}
|
|
return true
|
|
}
|
|
|
|
// dependsOnTrace reports whether a rewritten formula can still read the trace.
|
|
// Constants fold through the connectives, and one that folds away to a constant
|
|
// answers the same on every trace: silence that reads as "did not refute", or a
|
|
// refutation of everything. Neither is a verdict about the run.
|
|
func dependsOnTrace(formula ltl.Formula) bool {
|
|
_, constant := fold(formula).(ltl.PureFormula)
|
|
return !constant
|
|
}
|
|
|
|
// fold propagates the constants the rewrites substituted for erased operators.
|
|
// An obligation whose inner formula folded to false keeps its shape, because a
|
|
// deferred false is a refutation still owed; only the side that can no longer
|
|
// fail folds away.
|
|
func fold(formula ltl.Formula) ltl.Formula {
|
|
switch concrete := formula.(type) {
|
|
case ltl.AlwaysFormula:
|
|
concrete.Inner = fold(concrete.Inner)
|
|
if pure, ok := concrete.Inner.(ltl.PureFormula); ok {
|
|
return pure
|
|
}
|
|
return concrete
|
|
case ltl.EventuallyFormula:
|
|
concrete.Inner = fold(concrete.Inner)
|
|
if isPure(concrete.Inner, true) {
|
|
return ltl.Pure(true)
|
|
}
|
|
return concrete
|
|
case ltl.NextFormula:
|
|
inner := fold(concrete.Inner)
|
|
if isPure(inner, true) {
|
|
return ltl.Pure(true)
|
|
}
|
|
return ltl.Next(inner)
|
|
case ltl.NowFormula:
|
|
inner := fold(concrete.Inner)
|
|
if _, ok := inner.(ltl.PureFormula); ok {
|
|
return inner
|
|
}
|
|
return ltl.Now(inner)
|
|
case ltl.NotFormula:
|
|
inner := fold(concrete.Inner)
|
|
if pure, ok := inner.(ltl.PureFormula); ok {
|
|
return ltl.Pure(!pure.Value)
|
|
}
|
|
return ltl.Not(inner)
|
|
case ltl.AndFormula:
|
|
left, right := fold(concrete.Left), fold(concrete.Right)
|
|
switch {
|
|
case isPure(left, false) || isPure(right, false):
|
|
return ltl.Pure(false)
|
|
case isPure(left, true):
|
|
return right
|
|
case isPure(right, true):
|
|
return left
|
|
}
|
|
return ltl.And(left, right)
|
|
case ltl.OrFormula:
|
|
left, right := fold(concrete.Left), fold(concrete.Right)
|
|
switch {
|
|
case isPure(left, true) || isPure(right, true):
|
|
return ltl.Pure(true)
|
|
case isPure(left, false):
|
|
return right
|
|
case isPure(right, false):
|
|
return left
|
|
}
|
|
return ltl.Or(left, right)
|
|
case ltl.ImpliesFormula:
|
|
antecedent, consequent := fold(concrete.Antecedent), fold(concrete.Consequent)
|
|
switch {
|
|
case isPure(antecedent, false) || isPure(consequent, true):
|
|
return ltl.Pure(true)
|
|
case isPure(antecedent, true):
|
|
return consequent
|
|
}
|
|
return ltl.Implies(antecedent, consequent)
|
|
default:
|
|
return formula
|
|
}
|
|
}
|
|
|
|
func isPure(formula ltl.Formula, value bool) bool {
|
|
pure, ok := formula.(ltl.PureFormula)
|
|
return ok && pure.Value == value
|
|
}
|
|
|
|
// stateless erases every temporal operator. The replacement constant follows
|
|
// the position's polarity: under an even number of negations a temporal
|
|
// sub-formula is dropped as satisfied, and under an odd number as failed, so
|
|
// that in both cases its negation cannot refute anything either.
|
|
func stateless(formula ltl.Formula, positive bool) ltl.Formula {
|
|
switch concrete := formula.(type) {
|
|
case ltl.AlwaysFormula, ltl.NextFormula, ltl.EventuallyFormula:
|
|
return ltl.Pure(positive)
|
|
case ltl.NowFormula:
|
|
return ltl.Now(stateless(concrete.Inner, positive))
|
|
case ltl.NotFormula:
|
|
return ltl.Not(stateless(concrete.Inner, !positive))
|
|
case ltl.AndFormula:
|
|
return ltl.And(stateless(concrete.Left, positive), stateless(concrete.Right, positive))
|
|
case ltl.OrFormula:
|
|
return ltl.Or(stateless(concrete.Left, positive), stateless(concrete.Right, positive))
|
|
case ltl.ImpliesFormula:
|
|
return ltl.Implies(
|
|
stateless(concrete.Antecedent, !positive),
|
|
stateless(concrete.Consequent, positive),
|
|
)
|
|
default:
|
|
return formula
|
|
}
|
|
}
|
|
|
|
func oneStep(formula ltl.Formula, positive bool) ltl.Formula {
|
|
switch concrete := formula.(type) {
|
|
case ltl.AlwaysFormula:
|
|
return ltl.Pure(positive)
|
|
case ltl.NextFormula:
|
|
return ltl.Next(stateless(concrete.Inner, positive))
|
|
case ltl.EventuallyFormula:
|
|
return ltl.EventuallyWithinSteps(stateless(concrete.Inner, positive), tripleWindow)
|
|
case ltl.NowFormula:
|
|
return ltl.Now(oneStep(concrete.Inner, positive))
|
|
case ltl.NotFormula:
|
|
return ltl.Not(oneStep(concrete.Inner, !positive))
|
|
case ltl.AndFormula:
|
|
return ltl.And(oneStep(concrete.Left, positive), oneStep(concrete.Right, positive))
|
|
case ltl.OrFormula:
|
|
return ltl.Or(oneStep(concrete.Left, positive), oneStep(concrete.Right, positive))
|
|
case ltl.ImpliesFormula:
|
|
return ltl.Implies(
|
|
oneStep(concrete.Antecedent, !positive),
|
|
oneStep(concrete.Consequent, positive),
|
|
)
|
|
default:
|
|
return formula
|
|
}
|
|
}
|
|
|
|
// propertyClass splits safety from liveness by the property's top-level form:
|
|
// a reachability goal is liveness, everything else is a safety obligation
|
|
// re-asserted at each observation.
|
|
func propertyClass(formula ltl.Formula) string {
|
|
if _, ok := formula.(ltl.EventuallyFormula); ok {
|
|
return "liveness"
|
|
}
|
|
return "safety"
|
|
}
|
|
|
|
func topLevelForm(formula ltl.Formula) string {
|
|
switch concrete := formula.(type) {
|
|
case ltl.AlwaysFormula:
|
|
return "always" + boundSuffix(concrete.HasStepBound, concrete.StepBound, concrete.Duration)
|
|
case ltl.EventuallyFormula:
|
|
return "eventually" + boundSuffix(concrete.HasStepBound, concrete.StepBound, concrete.Duration)
|
|
default:
|
|
return "predicate"
|
|
}
|
|
}
|
|
|
|
func boundSuffix(
|
|
hasStepBound bool,
|
|
stepBound int,
|
|
duration time.Duration,
|
|
) string {
|
|
switch {
|
|
case hasStepBound:
|
|
return fmt.Sprintf(" within %d steps", stepBound)
|
|
case duration > 0:
|
|
return fmt.Sprintf(" within %s", duration)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|