feat(ltl): attribute violations to the obligation origin step

This commit is contained in:
pj committed 2026-06-05 22:29:09 +05:30
1 parent ce65cc32a4
commit dc7d23eaa2
3 files changed
+131 -38

No files matched your search

+47 -31
View File
@@ -33,17 +33,27 @@ func (v Verdict) String() string {
// violates, the overall verdict latches to Violated.
type Evaluator struct {
root Formula
pending []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 observation step it fired at. 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.
// 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
@@ -62,19 +72,29 @@ func (e *Evaluator) Observe() Verdict {
return e.ObserveAt(time.Now())
}
// ObserveAt is like Observe but takes the current step time explicitly.
// 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++
e.steps = step
fresh := rootObligation(e.root)
fresh := obligation{formula: rootObligation(e.root), origin: step}
obligations := append(e.pending, fresh)
e.pending = e.pending[:0]
for _, obligation := range obligations {
result := reduce(obligation, now)
for _, entry := range obligations {
result := reduce(entry.formula, now)
switch result.status {
case statusHolds:
// drop
@@ -83,11 +103,11 @@ func (e *Evaluator) ObserveAt(now time.Time) Verdict {
e.pending = nil
e.violation = result.witness
if e.violation != nil {
e.violation.Step = e.steps
e.violation.Step = entry.origin
}
return VerdictViolated
case statusPending:
e.pending = append(e.pending, result.formula)
e.pending = append(e.pending, obligation{formula: result.formula, origin: entry.origin})
}
}
@@ -100,21 +120,22 @@ func (e *Evaluator) ObserveAt(now time.Time) Verdict {
}
// collapse removes structurally-identical obligations, keeping the first
// occurrence in order. Distinct predicates never merge because ThunkFormula's
// name participates in its describe() key, so deduping cannot hide a violation.
func collapse(obligations []Formula) []Formula {
// 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 _, obligation := range obligations {
key := obligation.describe()
for _, entry := range obligations {
key := entry.formula.describe()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, obligation)
result = append(result, entry)
}
return result
}
@@ -127,14 +148,14 @@ func (e *Evaluator) Finalize() Verdict {
if e.violated {
return VerdictViolated
}
for _, obligation := range e.pending {
if finalize(obligation) == statusViolated {
for _, entry := range e.pending {
if finalize(entry.formula) == statusViolated {
e.violated = true
e.pending = nil
e.violation = &Violation{
Formula: obligation,
Reason: finalizeReason(obligation),
Step: e.steps,
Formula: entry.formula,
Reason: finalizeReason(entry.formula),
Step: entry.origin,
}
return VerdictViolated
}
@@ -224,9 +245,9 @@ func (e *Evaluator) Residual() Formula {
if len(e.pending) == 0 {
return PureFormula{Value: true}
}
combined := e.pending[0]
for _, formula := range e.pending[1:] {
combined = AndFormula{Left: combined, Right: formula}
combined := e.pending[0].formula
for _, entry := range e.pending[1:] {
combined = AndFormula{Left: combined, Right: entry.formula}
}
return combined
}
@@ -258,11 +279,6 @@ type reduceResult struct {
func holds() reduceResult { return reduceResult{status: statusHolds} }
// violated reports a violation without an attached witness. Used where the
// failing sub-formula is recovered from a child result whose own witness is
// carried up by violatedFrom.
func violated() reduceResult { return reduceResult{status: statusViolated} }
// 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.