Files
pj 9958b0ddc8 Attribute violations to the causing step and render witness evidence (#59)
* feat(ltl): attribute violations to the obligation origin step

* feat(verifier): label evaluator observations with the runner step index

* feat(trace): carry the causing step in violation witnesses and summary

* feat(replay): move the violation marker to the causing step

* feat(replay-ui): render witness evidence in the violations panel

* feat(replay-ui): wire witnesses and step jump into violation panels

* fix(ltl): treat next obligations as vacuous at run end

* fix(runner): give the finalize trace record its own step index
2026-06-05 23:46:36 +05:30

158 lines
5.0 KiB
Go

package ltl
import (
"errors"
"testing"
"time"
)
func TestViolation_PredicateFalseCarriesReasonAndStep(t *testing.T) {
values := []bool{true, false}
step := 0
evaluator := NewEvaluator(Always(ThunkNamed("p", func() (bool, error) {
current := values[step]
step++
return current, nil
})))
if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictHolds {
t.Fatalf("step 1: got %v, want holds", got)
}
if got := evaluator.ObserveAt(time.Unix(1, 0)); got != VerdictViolated {
t.Fatalf("step 2: got %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil, want non-nil")
}
if witness.Reason != "predicate false" {
t.Errorf("Reason = %q, want %q", witness.Reason, "predicate false")
}
if witness.Step != 2 {
t.Errorf("Step = %d, want 2", witness.Step)
}
if witness.IsError {
t.Errorf("IsError = true, want false for a plain false")
}
}
func TestViolation_ThrownPredicateSetsIsError(t *testing.T) {
evaluator := NewEvaluator(Always(ThunkNamed("p", func() (bool, error) {
return false, errors.New("boom")
})))
if got := evaluator.Observe(); got != VerdictViolated {
t.Fatalf("got %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil, want non-nil")
}
if !witness.IsError {
t.Errorf("IsError = false, want true for a thrown predicate")
}
if witness.Reason != "boom" {
t.Errorf("Reason = %q, want %q", witness.Reason, "boom")
}
}
func TestViolation_FinalizeFillsWitness(t *testing.T) {
evaluator := NewEvaluator(Eventually(ThunkNamed("p", func() (bool, error) {
return false, nil
})))
evaluator.ObserveAt(time.Unix(0, 0))
if got := evaluator.Finalize(); got != VerdictViolated {
t.Fatalf("Finalize = %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil after Finalize, want non-nil")
}
if witness.Reason != "eventually never satisfied" {
t.Errorf("Reason = %q, want %q", witness.Reason, "eventually never satisfied")
}
}
func TestViolation_NextAttributesOriginStep(t *testing.T) {
// always(next(p)): the obligation spawned at step 2 is checked against
// step 3's state; the violation belongs to step 2, the step that caused it.
values := []bool{true, false}
step := 0
evaluator := NewEvaluator(Always(Next(ThunkNamed("p", func() (bool, error) {
current := values[step]
step++
return current, nil
}))))
if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictPending {
t.Fatalf("step 1: got %v, want pending", got)
}
if got := evaluator.ObserveAt(time.Unix(1, 0)); got != VerdictPending {
t.Fatalf("step 2: got %v, want pending", got)
}
if got := evaluator.ObserveAt(time.Unix(2, 0)); got != VerdictViolated {
t.Fatalf("step 3: got %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil, want non-nil")
}
if witness.Step != 2 {
t.Errorf("Step = %d, want 2 (the step that spawned the next obligation)", witness.Step)
}
}
func TestViolation_FinalizeAttributesOriginStep(t *testing.T) {
// An eventually that never fires is reported by Finalize at the step the
// obligation was first spawned (collapse keeps the earliest origin).
evaluator := NewEvaluator(Eventually(ThunkNamed("p", func() (bool, error) {
return false, nil
})))
evaluator.ObserveAt(time.Unix(0, 0))
evaluator.ObserveAt(time.Unix(1, 0))
evaluator.ObserveAt(time.Unix(2, 0))
if got := evaluator.Finalize(); got != VerdictViolated {
t.Fatalf("Finalize = %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil after Finalize, want non-nil")
}
if witness.Step != 1 {
t.Errorf("Step = %d, want 1 (the step the eventually obligation was spawned)", witness.Step)
}
}
func TestViolation_ObserveAtStepUsesCallerNumbering(t *testing.T) {
// The caller skips step 5 (a transitional step the verifier never saw); the
// origin must carry the caller's labels, not a contiguous internal count.
values := []bool{true, false}
step := 0
evaluator := NewEvaluator(Always(Next(ThunkNamed("p", func() (bool, error) {
current := values[step]
step++
return current, nil
}))))
if got := evaluator.ObserveAtStep(time.Unix(0, 0), 3); got != VerdictPending {
t.Fatalf("step 3: got %v, want pending", got)
}
if got := evaluator.ObserveAtStep(time.Unix(1, 0), 4); got != VerdictPending {
t.Fatalf("step 4: got %v, want pending", got)
}
if got := evaluator.ObserveAtStep(time.Unix(2, 0), 6); got != VerdictViolated {
t.Fatalf("step 6: got %v, want violated", got)
}
witness := evaluator.Violation()
if witness == nil {
t.Fatal("Violation = nil, want non-nil")
}
if witness.Step != 4 {
t.Errorf("Step = %d, want 4 (caller-labeled origin, not detection step 6)", witness.Step)
}
}
func TestViolation_NilBeforeViolation(t *testing.T) {
evaluator := NewEvaluator(Always(Pure(true)))
evaluator.Observe()
if got := evaluator.Violation(); got != nil {
t.Errorf("Violation = %+v, want nil for a holding run", got)
}
}