Files
sanderling/internal/ltl/evaluator.go
T

465 lines
14 KiB
Go

// Package ltl evaluates linear temporal logic formulas incrementally over observed steps.
package ltl
import (
"fmt"
"time"
)
type Verdict int
const (
VerdictHolds Verdict = iota
VerdictViolated
VerdictPending
)
func (v Verdict) String() string {
switch v {
case VerdictHolds:
return "holds"
case VerdictViolated:
return "violated"
case VerdictPending:
return "pending"
default:
return fmt.Sprintf("verdict(%d)", int(v))
}
}
// Evaluator reduces a formula across observed steps using residual-formula
// semantics. Each step either resolves pending obligations (to holds or
// violated) or carries them forward as residuals. Once a single obligation
// violates, the overall verdict latches to Violated.
type Evaluator struct {
root Formula
pending []obligation
violated bool
steps int
violation *Violation
}
// obligation pairs a residual formula with the step that spawned it, so a
// deferred check (a next, a pending eventually) that fails on a later step can
// be attributed to the step that created the obligation.
type obligation struct {
formula Formula
origin int
}
// Violation is the witness for a latched verdict: the failing sub-formula, a
// human-readable reason, and the step the failed obligation originated at. For
// an immediate predicate failure that is the observation step itself; for a
// deferred obligation (next, eventually) it is the earlier step that spawned
// it, the one that caused the violation. A thrown predicate carries the goja
// error text as its reason and sets IsError; a plain false carries "predicate
// false"; Finalize fills it for liveness obligations that never discharged.
type Violation struct {
Formula Formula
Reason string
Step int
IsError bool
}
func NewEvaluator(formula Formula) *Evaluator {
return &Evaluator{root: nnf(formula)}
}
// Observe evaluates the formula against the current state and returns the
// running verdict. Uses the real wall clock for deadline-bound operators;
// callers that need reproducible time should use ObserveAt.
func (e *Evaluator) Observe() Verdict {
return e.ObserveAt(time.Now())
}
// ObserveAt is like Observe but takes the current step time explicitly. Steps
// are numbered by an internal counter starting at 1; callers whose step
// numbering can skip observations should use ObserveAtStep instead.
func (e *Evaluator) ObserveAt(now time.Time) Verdict {
return e.ObserveAtStep(now, e.steps+1)
}
// ObserveAtStep is like ObserveAt but labels the observation with the caller's
// step index, so violation witnesses carry the caller's numbering even when
// some steps were never observed (for example transitional steps the verifier
// skips).
func (e *Evaluator) ObserveAtStep(now time.Time, step int) Verdict {
if e.violated {
return VerdictViolated
}
e.steps = step
fresh := obligation{formula: rootObligation(e.root), origin: step}
obligations := append(e.pending, fresh)
e.pending = e.pending[:0]
for _, entry := range obligations {
result := reduce(entry.formula, now)
switch result.status {
case statusHolds:
// drop
case statusViolated:
e.violated = true
e.pending = nil
e.violation = result.witness
if e.violation != nil {
e.violation.Step = entry.origin
}
return VerdictViolated
case statusPending:
e.pending = append(e.pending, obligation{formula: result.formula, origin: entry.origin})
}
}
e.pending = collapse(e.pending)
if len(e.pending) > 0 {
return VerdictPending
}
return VerdictHolds
}
// collapse removes structurally-identical obligations, keeping the first
// occurrence in order so the surviving entry carries the earliest origin step.
// Distinct predicates never merge because ThunkFormula's name participates in
// its describe() key, so deduping cannot hide a violation.
func collapse(obligations []obligation) []obligation {
if len(obligations) < 2 {
return obligations
}
seen := make(map[string]struct{}, len(obligations))
result := obligations[:0]
for _, entry := range obligations {
key := entry.formula.describe()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, entry)
}
return result
}
// Finalize reports the terminal verdict for the run. Pending obligations that
// can never be discharged by a future step (an unbounded eventually that never
// fired, a strong next with no successor) resolve to Violated; safety
// obligations that were never breached resolve to Holds.
func (e *Evaluator) Finalize() Verdict {
if e.violated {
return VerdictViolated
}
for _, entry := range e.pending {
if finalize(entry.formula) == statusViolated {
e.violated = true
e.pending = nil
e.violation = &Violation{
Formula: entry.formula,
Reason: finalizeReason(entry.formula),
Step: entry.origin,
}
return VerdictViolated
}
}
return VerdictHolds
}
// Violation returns the witness for a latched violation, or nil if the
// evaluator has not violated. The witness is set by ObserveAt at the step a
// reduction first violated, or by Finalize for a liveness obligation that
// never discharged.
func (e *Evaluator) Violation() *Violation {
return e.violation
}
// finalizeReason describes why an undischarged obligation resolves to violated
// at run end.
func finalizeReason(formula Formula) string {
switch formula.(type) {
case EventuallyFormula:
return "eventually never satisfied"
case NextFormula:
return "next obligation unmet at run end"
case ThunkFormula:
return "obligation unmet at run end"
default:
return "liveness obligation unmet at run end"
}
}
// finalize collapses a pending obligation to its terminal status assuming no
// further steps will occur.
func finalize(formula Formula) residualStatus {
switch concrete := formula.(type) {
case PureFormula:
if concrete.Value {
return statusHolds
}
return statusViolated
case ThunkFormula:
return statusViolated
case EventuallyFormula:
return statusViolated
case NextFormula:
return statusViolated
case AlwaysFormula:
return statusHolds
case NowFormula:
return finalize(concrete.Inner)
case NotFormula:
switch finalize(concrete.Inner) {
case statusViolated:
return statusHolds
default:
return statusViolated
}
case AndFormula:
if finalize(concrete.Left) == statusViolated || finalize(concrete.Right) == statusViolated {
return statusViolated
}
return statusHolds
case OrFormula:
if finalize(concrete.Left) == statusHolds || finalize(concrete.Right) == statusHolds {
return statusHolds
}
return statusViolated
case ImpliesFormula:
if finalize(concrete.Antecedent) == statusViolated {
return statusHolds
}
return finalize(concrete.Consequent)
default:
return statusHolds
}
}
// Residual returns a single Formula describing what the evaluator still has
// to prove after the most recent ObserveAt. PureFormula{true} means the
// property holds for the run so far; PureFormula{false} means it has latched
// to violated. When obligations are still pending, they are folded together
// with AndFormula in the order they were registered so the JSON AST reflects
// the same order the evaluator processes them in.
func (e *Evaluator) Residual() Formula {
if e.violated {
return PureFormula{Value: false}
}
if len(e.pending) == 0 {
return PureFormula{Value: true}
}
combined := e.pending[0].formula
for _, entry := range e.pending[1:] {
combined = AndFormula{Left: combined, Right: entry.formula}
}
return combined
}
// rootObligation returns the formula to instantiate at each step. An outer
// Always is stripped so its inner is re-evaluated every step; any other root
// formula is itself re-instantiated each step (matching the v0.1 semantics
// where a bare Thunk is re-observed on every call).
func rootObligation(root Formula) Formula {
if always, ok := root.(AlwaysFormula); ok {
return always.Inner
}
return root
}
type residualStatus int
const (
statusHolds residualStatus = iota
statusViolated
statusPending
)
type reduceResult struct {
status residualStatus
formula Formula
witness *Violation
}
func holds() reduceResult { return reduceResult{status: statusHolds} }
// violatedWith reports a violation that originates at the given sub-formula
// with the given reason. The reason distinguishes a thrown predicate from a
// plain false so callers (and the replay UI) can render the cause.
func violatedWith(formula Formula, reason string) reduceResult {
return reduceResult{
status: statusViolated,
witness: &Violation{Formula: formula, Reason: reason},
}
}
// violatedByError reports a violation caused by a predicate that threw. The
// witness keeps the error text as its reason and flags IsError so callers can
// render it as a thrown-predicate error rather than a plain false.
func violatedByError(formula Formula, reason string) reduceResult {
return reduceResult{
status: statusViolated,
witness: &Violation{Formula: formula, Reason: reason, IsError: true},
}
}
// violatedFrom propagates a child violation, preferring the child's witness so
// the deepest failing leaf survives. When the child carried no witness the
// fallback formula and reason describe this level instead.
func violatedFrom(child reduceResult, fallback Formula, reason string) reduceResult {
if child.witness != nil {
return reduceResult{status: statusViolated, witness: child.witness}
}
return violatedWith(fallback, reason)
}
func pending(f Formula) reduceResult {
return reduceResult{status: statusPending, formula: f}
}
func reduce(formula Formula, now time.Time) reduceResult {
switch concrete := formula.(type) {
case PureFormula:
if concrete.Value {
return holds()
}
return violatedWith(concrete, "pure false")
case ThunkFormula:
result, err := concrete.Func()
if err != nil {
return violatedByError(concrete, err.Error())
}
if result {
return holds()
}
return violatedWith(concrete, "predicate false")
case NowFormula:
return reduce(concrete.Inner, now)
case NextFormula:
// Next defers the inner obligation to the following step without
// evaluating it now.
return pending(concrete.Inner)
case EventuallyFormula:
// First-reduction deadline resolution: if the formula was built with
// a relative duration, fix the absolute deadline to (now + duration)
// so subsequent reductions compare against a stable value.
if !concrete.HasDeadline && concrete.Duration > 0 {
concrete.Deadline = now.Add(concrete.Duration)
concrete.HasDeadline = true
}
innerResult := reduce(concrete.Inner, now)
if innerResult.status == statusHolds {
return holds()
}
if concrete.HasStepBound && concrete.StepBound <= 1 {
return violatedFrom(innerResult, concrete, "eventually bound exhausted")
}
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
return violatedFrom(innerResult, concrete, "eventually deadline reached")
}
next := concrete
if concrete.HasStepBound {
next.StepBound = concrete.StepBound - 1
}
return pending(next)
case ImpliesFormula:
// NewEvaluator runs nnf, which rewrites a -> b to (not a) or b, so this
// case is unreachable from a normal evaluator. A directly-constructed
// formula reduced here is evaluated through the same equivalence so a
// pending antecedent cannot drop the consequent.
return reduce(OrFormula{
Left: pushNot(concrete.Antecedent),
Right: nnf(concrete.Consequent),
}, now)
case OrFormula:
left := reduce(concrete.Left, now)
right := reduce(concrete.Right, now)
if left.status == statusHolds || right.status == statusHolds {
return holds()
}
if left.status == statusViolated && right.status == statusViolated {
return violatedFrom(left, concrete, "both disjuncts violated")
}
if left.status == statusViolated {
return pending(right.formula)
}
if right.status == statusViolated {
return pending(left.formula)
}
return pending(OrFormula{Left: left.formula, Right: right.formula})
case AndFormula:
left := reduce(concrete.Left, now)
right := reduce(concrete.Right, now)
if left.status == statusViolated {
return violatedFrom(left, concrete, "conjunct violated")
}
if right.status == statusViolated {
return violatedFrom(right, concrete, "conjunct violated")
}
if left.status == statusHolds && right.status == statusHolds {
return holds()
}
if left.status == statusHolds {
return pending(right.formula)
}
if right.status == statusHolds {
return pending(left.formula)
}
return pending(AndFormula{Left: left.formula, Right: right.formula})
case NotFormula:
inner := reduce(concrete.Inner, now)
switch inner.status {
case statusHolds:
return violatedWith(concrete, "negated formula held")
case statusViolated:
return holds()
case statusPending:
return pending(NotFormula{Inner: inner.formula})
}
case AlwaysFormula:
// First-reduction deadline resolution mirrors EventuallyFormula so a
// relative duration becomes a stable absolute deadline.
if !concrete.HasDeadline && concrete.Duration > 0 {
concrete.Deadline = now.Add(concrete.Duration)
concrete.HasDeadline = true
}
innerResult := reduce(concrete.Inner, now)
if innerResult.status == statusViolated {
return violatedFrom(innerResult, concrete, "always inner violated")
}
// A bounded Always is the dual of a bounded Eventually: once the window
// closes without a breach it is vacuously satisfied. A pending inner at
// the closing step is a deferred obligation (a strong next, or an inner
// liveness that has not discharged); it must be carried so a later step
// or Finalize resolves it, never dropped to holds.
if concrete.HasStepBound && concrete.StepBound <= 1 {
if innerResult.status == statusHolds {
return holds()
}
return pending(innerResult.formula)
}
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
if innerResult.status == statusHolds {
return holds()
}
return pending(innerResult.formula)
}
next := concrete
next.Inner = concrete.Inner
if concrete.HasStepBound {
next.StepBound = concrete.StepBound - 1
}
if innerResult.status == statusHolds {
return pending(next)
}
return pending(AndFormula{Left: innerResult.formula, Right: next})
}
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
}