mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
reduce decremented StepBound into the residual, so the trace reported the remaining window rather than the authored one: a within(1915, "steps") showed up as 1875 after 40 steps, and the replay UI renders that string verbatim. The duration case was fixed when bounded windows were made to serialize their resolved deadline; the step case was not, and withinFor's comment claimed otherwise. The window is now immutable and the closing observation is resolved once, which mirrors Deadline exactly. A step counts observations the evaluator reduced, not steps the runner executed, because a skipped step gave the property no chance to discharge and transitional-step rate is itself policy-dependent. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
196 lines
7.6 KiB
Go
196 lines
7.6 KiB
Go
package ltl
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func alwaysFalse() func() (bool, error) {
|
|
return func() (bool, error) { return false, nil }
|
|
}
|
|
|
|
// A step-bounded window counts the observations the evaluator reduced, and the
|
|
// residual has to keep saying which window the spec authored rather than the
|
|
// part of it that is left. Bug class: the replay UI renders "within N steps"
|
|
// straight off the residual, so a shrinking N tells the reader the spec asked
|
|
// for a window it never asked for.
|
|
func TestStepBoundedEventually_ResidualKeepsAuthoredWindow(t *testing.T) {
|
|
evaluator := NewEvaluator(EventuallyWithinSteps(ThunkNamed("p", alwaysFalse()), 5))
|
|
for index := range 3 {
|
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
|
t.Fatalf("observation %d: got %v, want pending", index+1, got)
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(evaluator.Residual())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), `"unit":"steps"`) || !strings.Contains(string(body), `"amount":5`) {
|
|
t.Errorf("authored window lost after reduction: %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `"expiresAtObservation":5`) {
|
|
t.Errorf("resolved expiry missing: %s", body)
|
|
}
|
|
}
|
|
|
|
// Two obligations spawned at different observations from one `within(n,
|
|
// "steps")` window close at different observations, and the serialized AST has
|
|
// to keep them apart the way a resolved deadline keeps two duration-bounded
|
|
// ones apart. Bug class: the trace shows one node where the evaluator holds
|
|
// several distinct obligations.
|
|
func TestStepBoundedEventually_ObligationsSerializeApart(t *testing.T) {
|
|
evaluator := NewEvaluator(Always(EventuallyWithinSteps(ThunkNamed("p", alwaysFalse()), 3)))
|
|
for index := range 2 {
|
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
|
t.Fatalf("observation %d: got %v, want pending", index+1, got)
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(evaluator.Residual())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(body)
|
|
if strings.Count(text, `"amount":3`) != 2 {
|
|
t.Errorf("both obligations should report the authored window of 3: %s", text)
|
|
}
|
|
if !strings.Contains(text, `"expiresAtObservation":3`) || !strings.Contains(text, `"expiresAtObservation":4`) {
|
|
t.Errorf("obligations armed at different observations share a closing observation: %s", text)
|
|
}
|
|
}
|
|
|
|
// A bounded Always is the dual of a bounded Eventually, so its window resolves
|
|
// and serializes the same way.
|
|
func TestStepBoundedAlways_ResidualKeepsAuthoredWindow(t *testing.T) {
|
|
formula := AlwaysFormula{
|
|
Inner: ThunkNamed("p", func() (bool, error) { return true, nil }),
|
|
StepBound: 4,
|
|
HasStepBound: true,
|
|
}
|
|
evaluator := NewEvaluator(formula)
|
|
for index := range 2 {
|
|
if got := evaluator.ObserveAt(time.Unix(int64(index), 0)); got != VerdictPending {
|
|
t.Fatalf("observation %d: got %v, want pending", index+1, got)
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(evaluator.Residual())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), `"amount":4`) {
|
|
t.Errorf("authored window lost after reduction: %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `"expiresAtObservation":4`) {
|
|
t.Errorf("resolved expiry missing: %s", body)
|
|
}
|
|
}
|
|
|
|
// A step the verifier skipped (a transitional tree, an empty hierarchy) never
|
|
// reached the evaluator, so the property was given no chance to discharge
|
|
// there and the window must not charge for it. The runner's step numbering
|
|
// only labels the witness; it does not drive the window.
|
|
func TestStepBoundedEventually_SkippedRunnerStepsDoNotConsumeWindow(t *testing.T) {
|
|
observed := 0
|
|
inner := ThunkNamed("p", func() (bool, error) {
|
|
observed++
|
|
return observed == 3, nil
|
|
})
|
|
evaluator := NewEvaluator(EventuallyWithinSteps(inner, 3))
|
|
|
|
var verdict Verdict
|
|
for _, runnerStep := range []int{1, 7, 19} {
|
|
verdict = evaluator.ObserveAtStep(time.Unix(int64(runnerStep), 0), runnerStep)
|
|
}
|
|
if verdict != VerdictHolds {
|
|
t.Errorf("three observations inside a three-observation window: got %v, want holds", verdict)
|
|
}
|
|
}
|
|
|
|
// The witness still carries the runner's numbering, so a report names the step
|
|
// that armed the obligation even though the window counted observations.
|
|
func TestStepBoundedEventually_WitnessCarriesRunnerStep(t *testing.T) {
|
|
evaluator := NewEvaluator(EventuallyWithinSteps(ThunkNamed("p", alwaysFalse()), 2))
|
|
for _, runnerStep := range []int{4, 11} {
|
|
evaluator.ObserveAtStep(time.Unix(int64(runnerStep), 0), runnerStep)
|
|
}
|
|
|
|
witness := evaluator.Violation()
|
|
if witness == nil {
|
|
t.Fatal("no violation recorded")
|
|
}
|
|
if witness.Step != 4 {
|
|
t.Errorf("witness Step = %d, want the runner step that armed the obligation (4)", witness.Step)
|
|
}
|
|
}
|
|
|
|
// An undischarged bounded eventually is a broken liveness promise at run end
|
|
// whatever unit bounded it. Bug class: choosing "steps" over "seconds" quietly
|
|
// turning an unmet obligation into a vacuous pass.
|
|
func TestFinalize_StepBoundedEventuallyMatchesWallClock(t *testing.T) {
|
|
byStep, stepEvaluator := runAndFinalize(EventuallyWithinSteps(ThunkNamed("p", alwaysFalse()), 50), 3)
|
|
byClock, clockEvaluator := runAndFinalize(EventuallyWithin(ThunkNamed("p", alwaysFalse()), time.Hour), 3)
|
|
|
|
if byStep != VerdictViolated || byClock != VerdictViolated {
|
|
t.Fatalf("step bound = %v, wall clock = %v, want both violated", byStep, byClock)
|
|
}
|
|
stepWitness, clockWitness := stepEvaluator.Violation(), clockEvaluator.Violation()
|
|
if stepWitness == nil || clockWitness == nil {
|
|
t.Fatal("both undischarged obligations must carry a witness")
|
|
}
|
|
if stepWitness.Reason != clockWitness.Reason {
|
|
t.Errorf("reasons diverge: step %q, wall clock %q", stepWitness.Reason, clockWitness.Reason)
|
|
}
|
|
if stepWitness.Step != clockWitness.Step {
|
|
t.Errorf("origin steps diverge: step %d, wall clock %d", stepWitness.Step, clockWitness.Step)
|
|
}
|
|
}
|
|
|
|
// The reason the unit exists. Two action-selection policies get the same
|
|
// 300-step budget and reach the same state at the same step, but the model
|
|
// policy takes 359 seconds where the seeded policy takes 47 because it makes a
|
|
// provider call per step. A wall-clock bound fails the slow policy on elapsed
|
|
// time alone; the same window written in steps decides both policies alike.
|
|
func TestStepBound_SlowPolicyDoesNotFailOnTimeAlone(t *testing.T) {
|
|
const budget = 300
|
|
const satisfiedAtObservation = 260
|
|
seededCadence := 47 * time.Second / budget
|
|
modelCadence := 359 * time.Second / budget
|
|
|
|
run := func(cadence time.Duration, bound func(Formula) Formula) Verdict {
|
|
observed := 0
|
|
inner := ThunkNamed("someTransactionExists", func() (bool, error) {
|
|
observed++
|
|
return observed >= satisfiedAtObservation, nil
|
|
})
|
|
evaluator := NewEvaluator(bound(inner))
|
|
base := time.Unix(1780000000, 0)
|
|
for index := range budget {
|
|
verdict := evaluator.ObserveAtStep(base.Add(time.Duration(index)*cadence), index+1)
|
|
if verdict != VerdictPending {
|
|
return verdict
|
|
}
|
|
}
|
|
return evaluator.Finalize()
|
|
}
|
|
|
|
byClock := func(inner Formula) Formula { return EventuallyWithin(inner, 300*time.Second) }
|
|
bySteps := func(inner Formula) Formula { return EventuallyWithinSteps(inner, 1915) }
|
|
|
|
if got := run(seededCadence, byClock); got != VerdictHolds {
|
|
t.Errorf("wall-clock bound under the seeded policy: got %v, want holds", got)
|
|
}
|
|
if got := run(modelCadence, byClock); got != VerdictViolated {
|
|
t.Errorf("wall-clock bound under the model policy: got %v, want violated (the false positive this unit removes)", got)
|
|
}
|
|
if got := run(seededCadence, bySteps); got != VerdictHolds {
|
|
t.Errorf("step bound under the seeded policy: got %v, want holds", got)
|
|
}
|
|
if got := run(modelCadence, bySteps); got != VerdictHolds {
|
|
t.Errorf("step bound under the model policy: got %v, want holds", got)
|
|
}
|
|
}
|