feat(oracle-reduction): replay stored traces under four reduced oracles

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.
This commit is contained in:
pj committed 2026-08-16 17:45:41 +05:30
1 parent a0a8c9c710
commit a45ba76d8e
7 files changed
+2182

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
const maxStepBytes = 64 * 1024 * 1024
// loadedRun is one run directory: its meta, the steps in file order, and the
// synthetic end-of-run record the runner writes when Finalize convicted a
// liveness obligation.
type loadedRun struct {
Directory string
Meta trace.Meta
Steps []trace.Step
Finalize *trace.Step
}
// loadRun reads a run directory and refuses anything E3 cannot replay. A step
// written before the format change carries no element depths, so its hierarchy
// decodes with a nil root and every selector resolves to nothing: the refusal
// has to name the version rather than let the replay report an empty screen.
func loadRun(directory string) (loadedRun, error) {
metaBody, err := os.ReadFile(filepath.Join(directory, "meta.json"))
if err != nil {
return loadedRun{}, fmt.Errorf("read meta: %w", err)
}
var meta trace.Meta
if err := json.Unmarshal(metaBody, &meta); err != nil {
return loadedRun{}, fmt.Errorf("decode meta: %w", err)
}
file, err := os.Open(filepath.Join(directory, "trace.jsonl"))
if err != nil {
return loadedRun{}, fmt.Errorf("open trace: %w", err)
}
defer file.Close()
run := loadedRun{Directory: directory, Meta: meta}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1024*1024), maxStepBytes)
line := 0
for scanner.Scan() {
line++
if len(scanner.Bytes()) == 0 {
continue
}
var step trace.Step
if err := json.Unmarshal(scanner.Bytes(), &step); err != nil {
return loadedRun{}, fmt.Errorf(
"decode step on line %d: %w",
line,
err,
)
}
if step.TraceVersion != trace.TraceVersion {
return loadedRun{}, fmt.Errorf(
"step %d is trace_version %d, and E3 replays version %d only: "+
"an older step stores no element depths, so its hierarchy decodes "+
"with a nil root and every selector resolves to nothing on replay",
step.Index, step.TraceVersion, trace.TraceVersion)
}
run.Steps = append(run.Steps, step)
}
if err := scanner.Err(); err != nil {
return loadedRun{}, fmt.Errorf("read trace: %w", err)
}
if len(run.Steps) == 0 {
return loadedRun{}, fmt.Errorf("trace has no steps")
}
if last := run.Steps[len(run.Steps)-1]; len(run.Steps) > 1 &&
last.Hierarchy == nil &&
len(last.Violations) > 0 {
run.Finalize = &last
run.Steps = run.Steps[:len(run.Steps)-1]
}
if meta.Seed == 0 {
return loadedRun{}, fmt.Errorf(
"meta records no seed, so the run's bundle cannot be reproduced",
)
}
return run, nil
}
// finalizeIndex is the step index the runner gives its end-of-run record: one
// past the last step it wrote.
func (r loadedRun) finalizeIndex() int {
return r.Steps[len(r.Steps)-1].Index + 1
}
// extractorFold reconstructs every extractor's value at each step from the
// recorded per-step diffs. A run's trace stores what changed, so the value an
// extractor held at a step is the last change at or before it; an extractor
// that never changed held JSON null throughout, which is what an unwritten
// diff means.
func extractorFold(
steps []trace.Step,
names []string,
) ([]map[int]json.RawMessage, error) {
index := make(map[string]int, len(names))
for position, name := range names {
index[name] = position
}
current := make(map[int]json.RawMessage, len(names))
for position := range names {
current[position] = json.RawMessage("null")
}
folded := make([]map[int]json.RawMessage, len(steps))
for step := range steps {
for name, change := range steps[step].ExtractorChanges {
position, ok := index[name]
if !ok {
return nil, fmt.Errorf(
"step %d records extractor %q, which the spec does not register; "+
"the trace and the spec are not the same bundle",
steps[step].Index,
name,
)
}
current[position] = change.Curr
}
snapshot := make(map[int]json.RawMessage, len(current))
for position, value := range current {
snapshot[position] = value
}
folded[step] = snapshot
}
return folded, nil
}
// lastActionFor rebuilds the action the runner had applied before the next
// step observed. An action the runner chose but never dispatched left
// state.lastAction null, and the recorded skip reason is what says so.
func lastActionFor(step trace.Step) *verifier.Action {
if step.NextAction == nil || step.ActionSkipped != "" {
return nil
}
recorded := *step.NextAction
action := verifier.Action{
Kind: verifier.ActionKind(recorded.Kind),
On: recorded.Selector,
Text: recorded.Text,
X: recorded.X,
Y: recorded.Y,
FromX: recorded.FromX,
FromY: recorded.FromY,
ToX: recorded.ToX,
ToY: recorded.ToY,
Key: recorded.Key,
DurationMillis: recorded.DurationMillis,
}
return &action
}
func traceLogs(entries []trace.LogEntry) []verifier.LogEntry {
if len(entries) == 0 {
return nil
}
logs := make([]verifier.LogEntry, 0, len(entries))
for _, entry := range entries {
logs = append(logs, verifier.LogEntry{
UnixMillis: entry.UnixMillis,
Level: entry.Level,
Tag: entry.Tag,
Message: entry.Message,
})
}
return logs
}
func traceExceptions(entries []trace.Exception) []verifier.Exception {
if len(entries) == 0 {
return nil
}
exceptions := make([]verifier.Exception, 0, len(entries))
for _, entry := range entries {
exceptions = append(exceptions, verifier.Exception{
Class: entry.Class,
Message: entry.Message,
StackTrace: entry.StackTrace,
UnixMillis: entry.UnixMillis,
})
}
return exceptions
}
// discoverRuns finds every run directory at or below root, a run directory
// being one holding both meta.json and trace.jsonl.
func discoverRuns(root string) ([]string, error) {
var directories []string
err := filepath.Walk(
root,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
return nil
}
if _, statErr := os.Stat(filepath.Join(path, "trace.jsonl")); statErr != nil {
return nil
}
if _, statErr := os.Stat(filepath.Join(path, "meta.json")); statErr != nil {
return nil
}
directories = append(directories, path)
return nil
},
)
if err != nil {
return nil, err
}
sort.Strings(directories)
return directories, nil
}
@@ -0,0 +1,162 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
func writeRunFiles(t *testing.T, directory, meta string, steps ...string) {
t.Helper()
if err := os.WriteFile(filepath.Join(directory, "meta.json"), []byte(meta), 0o644); err != nil {
t.Fatal(err)
}
body := strings.Join(steps, "\n") + "\n"
if err := os.WriteFile(filepath.Join(directory, "trace.jsonl"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoadRunRefusesAStepFromBeforeTheFormatChange(t *testing.T) {
directory := t.TempDir()
writeRunFiles(
t,
directory,
`{"seed": 3}`,
`{"step":1,"timestamp":"2026-08-15T12:00:00Z"}`,
)
_, err := loadRun(directory)
if err == nil {
t.Fatal("a version-0 step must be refused")
}
if !strings.Contains(err.Error(), "trace_version 0") ||
!strings.Contains(err.Error(), "depths") {
t.Errorf(
"the refusal must name the version and why it cannot replay: %v",
err,
)
}
}
func TestLoadRunRefusesARunWithNoSeed(t *testing.T) {
directory := t.TempDir()
writeRunFiles(
t,
directory,
`{}`,
`{"step":1,"trace_version":1,"timestamp":"2026-08-15T12:00:00Z"}`,
)
_, err := loadRun(directory)
if err == nil || !strings.Contains(err.Error(), "seed") {
t.Errorf("a run without a seed cannot be bundled as it was: %v", err)
}
}
func TestLoadRunSplitsOffTheEndOfRunRecord(t *testing.T) {
directory := t.TempDir()
writeRunFiles(
t,
directory,
`{"seed": 3}`,
`{"step":1,"trace_version":1,"timestamp":"2026-08-15T12:00:00Z","hierarchy":{"elements":[],"depths":[]}}`,
`{"step":2,"trace_version":1,"timestamp":"2026-08-15T12:00:01Z","violations":["reachable"]}`,
)
run, err := loadRun(directory)
if err != nil {
t.Fatal(err)
}
if len(run.Steps) != 1 {
t.Fatalf("observation steps: got %d, want 1", len(run.Steps))
}
if run.Finalize == nil || run.Finalize.Index != 2 {
t.Fatalf("finalize record: %+v", run.Finalize)
}
if run.finalizeIndex() != 2 {
t.Errorf("finalize index: got %d, want 2", run.finalizeIndex())
}
}
func TestExtractorFoldCarriesAValueForwardUntilItChanges(t *testing.T) {
steps := []trace.Step{
{Index: 1, ExtractorChanges: map[string]trace.ExtractorChange{
"count": {
Prev: json.RawMessage("null"),
Curr: json.RawMessage("1"),
},
}},
{Index: 2},
{Index: 3, ExtractorChanges: map[string]trace.ExtractorChange{
"count": {Prev: json.RawMessage("1"), Curr: json.RawMessage("4")},
}},
}
folded, err := extractorFold(steps, []string{"count", "unseen"})
if err != nil {
t.Fatal(err)
}
want := []string{"1", "1", "4"}
for position, expected := range want {
if got := string(folded[position][0]); got != expected {
t.Errorf(
"count at step %d: got %s, want %s",
position+1,
got,
expected,
)
}
if got := string(folded[position][1]); got != "null" {
t.Errorf(
"an extractor that never changed must fold to null, got %s",
got,
)
}
}
}
func TestExtractorFoldRefusesAValueTheSpecCannotPlace(t *testing.T) {
steps := []trace.Step{
{Index: 1, ExtractorChanges: map[string]trace.ExtractorChange{
"gone": {Curr: json.RawMessage("1")},
}},
}
_, err := extractorFold(steps, []string{"count"})
if err == nil || !strings.Contains(err.Error(), "gone") {
t.Errorf("an unplaceable extractor value must be refused: %v", err)
}
}
func TestLastActionIsEmptyWhenTheRunnerNeverDispatchedIt(t *testing.T) {
dispatched := trace.Step{
NextAction: &trace.Action{Kind: "Tap", Selector: "id:save", X: 4, Y: 5},
}
skipped := trace.Step{
NextAction: &trace.Action{Kind: "Tap", Selector: "id:save"},
ActionSkipped: foregroundLossReason,
}
action := lastActionFor(dispatched)
if action == nil || action.Kind != verifier.ActionKindTap ||
action.On != "id:save" ||
action.X != 4 {
t.Fatalf("dispatched action: %+v", action)
}
if lastActionFor(skipped) != nil {
t.Error(
"an action the runner threw away never reached state.lastAction",
)
}
}
+312
View File
@@ -0,0 +1,312 @@
// Command oracle-reduction re-evaluates stored traces offline under four
// oracles and reports what each one refutes: the full engine, a crash-only
// detector, a single-state check, and a single-step property triple. The
// oracles vary while the traces stay fixed, which is what separates a defect an
// oracle cannot express from one an explorer never reached.
//
// The offline engine has to reproduce the verdicts each run recorded. A
// disagreement is a bug here or a gap in the trace, so it is reported as a
// mismatch and exits nonzero rather than being counted as a finding.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/priyanshujain/sanderling/internal/testrun"
)
const usage = `oracle-reduction replays stored traces under reduced oracles.
Usage:
oracle-reduction --runs <dir> [--output <path>] [--spec <path>]
--runs is scanned recursively; every directory holding meta.json and
trace.jsonl is one trace. Each trace's spec is bundled from the path its
meta.json records unless --spec overrides it.
Exit status is 2 when the offline engine disagreed with any run's recorded
verdicts, which blocks the experiment rather than reporting the difference as
noise.
A reduced oracle whose rewrite no longer states a property reports
cannot_express for it rather than a verdict, and the property is temporal-only
when the other reductions also fail to state or refute it. A property whose
window is longer than the two observations a triple spans is reported with
single_step_truncates_window, which is the form that decides it.
`
type config struct {
runsRoot string
outputPath string
specPath string
// hostExtractors re-runs the spec's extractor getters over each stored
// hierarchy instead of replaying the values a web run's page computed. It
// asks a different question of the same trace: whether the stored tree
// alone carries what the properties read.
hostExtractors bool
}
func parseArguments(arguments []string, stderr io.Writer) (config, error) {
flagSet := flag.NewFlagSet("oracle-reduction", flag.ContinueOnError)
flagSet.SetOutput(stderr)
flagSet.Usage = func() {
fmt.Fprint(stderr, usage)
flagSet.PrintDefaults()
}
var configuration config
flagSet.StringVar(
&configuration.runsRoot,
"runs",
"",
"directory tree holding the run directories to replay (required)",
)
flagSet.StringVar(
&configuration.outputPath,
"output",
"",
"file to write the per-trace JSONL to (default stdout)",
)
flagSet.StringVar(
&configuration.specPath,
"spec",
"",
"spec to bundle instead of the one each meta.json records",
)
flagSet.BoolVar(
&configuration.hostExtractors,
"host-extractors",
false,
"re-run the spec's extractors over each stored hierarchy instead of replaying the values a web run's page computed",
)
if err := flagSet.Parse(arguments); err != nil {
return config{}, err
}
if configuration.runsRoot == "" {
return config{}, fmt.Errorf("--runs is required")
}
return configuration, nil
}
func main() {
configuration, err := parseArguments(os.Args[1:], os.Stderr)
if err != nil {
fmt.Fprintf(os.Stderr, "oracle-reduction: %v\n", err)
os.Exit(1)
}
code, err := run(configuration, os.Stdout, os.Stderr)
if err != nil {
fmt.Fprintf(os.Stderr, "oracle-reduction: %v\n", err)
os.Exit(1)
}
os.Exit(code)
}
func run(configuration config, stdout, stderr io.Writer) (int, error) {
directories, err := discoverRuns(configuration.runsRoot)
if err != nil {
return 1, err
}
if len(directories) == 0 {
return 1, fmt.Errorf(
"no run directories under %s",
configuration.runsRoot,
)
}
output := stdout
if configuration.outputPath != "" {
file, createErr := os.Create(configuration.outputPath)
if createErr != nil {
return 1, createErr
}
defer file.Close()
output = file
}
encoder := json.NewEncoder(output)
var reports []runReport
rejected := 0
for _, directory := range directories {
loaded, loadErr := loadRun(directory)
if loadErr != nil {
rejected++
fmt.Fprintf(stderr, "skipped %s: %v\n", directory, loadErr)
continue
}
specPath := loaded.Meta.SpecPath
if configuration.specPath != "" {
specPath = configuration.specPath
}
bundle, bundleErr := testrun.BundleSpec(specPath, loaded.Meta.Seed)
if bundleErr != nil {
return 1, fmt.Errorf(
"%s: bundle %s: %w",
directory,
specPath,
bundleErr,
)
}
if loaded.Meta.BundleSHA256 != "" &&
bundle.SHA256 != loaded.Meta.BundleSHA256 {
// The bundler writes each module's path into the output relative to
// the working directory, so this differs whenever the replay is
// invoked from somewhere else than the run was. What a changed spec
// would actually break is caught by the property set, the extractor
// names and the residual comparison below.
fmt.Fprintf(stderr,
"note: %s bundles to %s here and the run recorded %s\n",
directory, bundle.SHA256[:12], loaded.Meta.BundleSHA256[:12])
}
report, replayErr := replay(
loaded,
string(bundle.JavaScript),
configuration.hostExtractors,
)
if replayErr != nil {
return 1, fmt.Errorf("%s: %w", directory, replayErr)
}
if err := encoder.Encode(report); err != nil {
return 1, err
}
reports = append(reports, report)
}
summarize(reports, rejected, stderr)
for _, report := range reports {
if !report.Valid {
return 2, nil
}
}
if len(reports) == 0 {
return 1, fmt.Errorf(
"every run directory was rejected; nothing was replayed",
)
}
return 0, nil
}
func summarize(reports []runReport, rejected int, out io.Writer) {
invalid := 0
crashed := 0
weakest := map[string]int{}
byClass := map[string]map[string]int{}
inexpressible := map[string]map[string]bool{}
unmatched := 0
engineRefutations := 0
for _, report := range reports {
if !report.Valid {
invalid++
}
if report.CrashOnly.Fired {
crashed++
}
for _, property := range report.Properties {
recordInexpressible(
inexpressible,
"single-state",
property.SingleState,
property.Property,
)
recordInexpressible(
inexpressible,
"single-step",
property.SingleStep,
property.Property,
)
if !property.Engine.Refuted {
if property.SingleState.Refuted || property.SingleStep.Refuted {
unmatched++
}
continue
}
engineRefutations++
weakest[property.Weakest]++
if byClass[property.Class] == nil {
byClass[property.Class] = map[string]int{}
}
byClass[property.Class][property.Weakest]++
}
}
fmt.Fprintf(
out,
"\ntraces replayed: %d (rejected: %d)\n",
len(reports),
rejected,
)
fmt.Fprintf(
out,
"validity: %d of %d reproduced the recorded verdicts exactly\n",
len(reports)-invalid,
len(reports),
)
fmt.Fprintf(out, "traces where crash-only fired: %d\n", crashed)
fmt.Fprintf(out, "engine refutations: %d\n", engineRefutations)
if engineRefutations > 0 {
fmt.Fprintf(out, "weakest refuting oracle: %s\n", counts(weakest))
for _, class := range sortedKeys(byClass) {
fmt.Fprintf(out, " %s: %s\n", class, counts(byClass[class]))
}
fmt.Fprintf(out, "temporal-only fraction: %.3f\n",
float64(weakest["temporal-only"])/float64(engineRefutations))
}
fmt.Fprintf(
out,
"properties a reduced oracle cannot express: %s\n",
counts(distinct(inexpressible)),
)
fmt.Fprintf(
out,
"reduced-oracle refutations the engine did not make: %d\n",
unmatched,
)
}
// recordInexpressible counts a property once per oracle however many traces it
// appears on, because whether an oracle can state a property is a fact about
// the property and not about the run.
func recordInexpressible(
seen map[string]map[string]bool,
oracle string,
finding refutation,
property string,
) {
if !finding.CannotExpress {
return
}
if seen[oracle] == nil {
seen[oracle] = map[string]bool{}
}
seen[oracle][property] = true
}
func distinct(seen map[string]map[string]bool) map[string]int {
sizes := map[string]int{}
for oracle, properties := range seen {
sizes[oracle] = len(properties)
}
return sizes
}
func counts(values map[string]int) string {
parts := make([]string, 0, len(values))
for _, key := range sortedKeys(values) {
parts = append(parts, fmt.Sprintf("%s=%d", key, values[key]))
}
return strings.Join(parts, " ")
}
func sortedKeys[V any](values map[string]V) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
@@ -0,0 +1,549 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"github.com/priyanshujain/sanderling/internal/ltl"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// foregroundLossReason is the skip reason internal/runner records when the app
// was no longer the foreground process at action time. It is the only
// foreground signal a stored trace carries, and it is what the crash-only
// oracle reads for "the application is no longer the foreground process".
const foregroundLossReason = "app_left_foreground"
// refutation is one oracle's finding for one property on one trace. A reduced
// oracle has three of them and not two: CannotExpress says its rewrite no
// longer states the property, which is neither a refutation nor a clean bill.
type refutation struct {
Refuted bool `json:"refuted"`
CannotExpress bool `json:"cannot_express,omitempty"`
// Step is the observation whose evaluation produced the violation;
// OriginStep is the observation whose obligation failed, which for a
// deferred one is earlier.
Step int `json:"step,omitempty"`
OriginStep int `json:"origin_step,omitempty"`
Reason string `json:"reason,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
type propertyReport struct {
Property string `json:"property"`
Class string `json:"class"`
TopLevel string `json:"top_level"`
Engine refutation `json:"engine"`
SingleState refutation `json:"single_state"`
SingleStep refutation `json:"single_step"`
// SingleStepTruncatesWindow marks a property whose window the triple had to
// shorten to two observations, so what its single-step column refutes is a
// stronger property than the one the author wrote.
SingleStepTruncatesWindow bool `json:"single_step_truncates_window,omitempty"`
// Weakest names the weakest oracle that refutes this property on this
// trace, and is empty unless the engine refuted it. "temporal-only" means
// no reduced oracle did.
Weakest string `json:"weakest_refuting_oracle,omitempty"`
}
type crashReport struct {
Fired bool `json:"fired"`
FirstStep int `json:"first_step,omitempty"`
ExceptionSteps []int `json:"exception_steps,omitempty"`
ForegroundLossSteps []int `json:"foreground_loss_steps,omitempty"`
ErrorLogSteps []int `json:"error_log_steps,omitempty"`
}
// mismatch is one disagreement between the offline engine and the verdicts the
// run recorded. Any of these blocks the trace's result.
type mismatch struct {
Property string `json:"property"`
Field string `json:"field"`
Online string `json:"online"`
Offline string `json:"offline"`
}
type runReport struct {
Run string `json:"run"`
Seed int64 `json:"seed"`
Platform string `json:"platform"`
Arm string `json:"arm,omitempty"`
Generator string `json:"generator,omitempty"`
// ReplayMode says where the extractor values under replay came from:
// "page-extractor-values" reuses what the page computed in V8 and the run
// evaluated against, "host-extractors" re-runs the spec's getters over the
// stored hierarchy.
ReplayMode string `json:"replay_mode"`
StepsObserved int `json:"steps_observed"`
StepsSkipped int `json:"steps_skipped"`
Valid bool `json:"valid"`
Mismatches []mismatch `json:"mismatches,omitempty"`
// ResidualMismatches counts every step whose replayed residual formula
// differed from the recorded one, of which Mismatches carries the first
// few. The recorded residual is the engine's whole pending state, so
// agreement on it is a stronger claim than agreement on the verdicts.
ResidualMismatches int `json:"residual_mismatches"`
CrashOnly crashReport `json:"crash_only"`
Properties []propertyReport `json:"properties"`
ActionsUnbounded int `json:"actions_without_resolved_bounds"`
ScrollActions int `json:"scroll_last_actions"`
}
// witnessRecord is one violation as either side reports it: the step it was
// recorded at, plus the witness the engine attached.
type witnessRecord struct {
RecordedStep int
OriginStep int
DetectedStep int
Reason string
IsError bool
}
// replay re-evaluates one run offline under all four oracles. The engine's
// offline verdicts are compared against the ones the run recorded, and the
// report is marked invalid on any disagreement: a mismatch is a bug here or a
// gap in the trace, not a finding.
func replay(
run loadedRun,
bundleJavaScript string,
hostExtractors bool,
) (runReport, error) {
engine, err := verifier.New(
verifier.WithSeed(uint64(run.Meta.Seed)),
verifier.WithPlatform(run.Meta.Platform),
verifier.WithAppPackage(run.Meta.BundleID),
)
if err != nil {
return runReport{}, fmt.Errorf("verifier: %w", err)
}
if err := engine.Load(bundleJavaScript); err != nil {
return runReport{}, fmt.Errorf("load spec: %w", err)
}
formulas, err := engine.PropertyFormulas()
if err != nil {
return runReport{}, err
}
singleState := map[string]*ltl.Evaluator{}
singleStep := map[string]*ltl.Evaluator{}
for name, formula := range formulas {
singleState[name] = ltl.NewEvaluator(singleStateFormula(formula))
singleStep[name] = ltl.NewEvaluator(singleStepFormula(formula))
}
usePageValues := run.Meta.Platform == "web" && !hostExtractors
report := runReport{
Run: run.Directory,
Seed: run.Meta.Seed,
Platform: run.Meta.Platform,
Arm: run.Meta.Arm,
Generator: run.Meta.Generator,
ReplayMode: "host-extractors",
}
var folded []map[int]json.RawMessage
if usePageValues {
report.ReplayMode = "page-extractor-values"
folded, err = extractorFold(run.Steps, engine.ExtractorNames())
if err != nil {
return runReport{}, err
}
}
offline := map[string]witnessRecord{}
stateFired := map[string]int{}
stepFired := map[string]int{}
var residualMismatches []mismatch
var lastAction *verifier.Action
for position, step := range run.Steps {
if step.NextAction != nil && step.NextAction.Selector != "" &&
step.NextAction.ResolvedBounds == nil {
report.ActionsUnbounded++
}
if step.SkippedVerification {
report.StepsSkipped++
residualMismatches = append(
residualMismatches,
compareResiduals(step, engine.Residuals())...)
lastAction = lastActionFor(step)
continue
}
if lastAction != nil && lastAction.Kind == verifier.ActionKindScroll {
report.ScrollActions++
}
if err := engine.PushSnapshot(verifier.SnapshotInput{
Tree: step.Hierarchy,
LastAction: lastAction,
StepTime: step.Timestamp,
StepIndex: step.Index,
RunStart: run.Meta.StartedAt,
Logs: traceLogs(step.Logs),
Exceptions: traceExceptions(step.Exceptions),
}); err != nil {
return runReport{}, fmt.Errorf("step %d push: %w", step.Index, err)
}
if usePageValues {
skipped, overrideErr := engine.OverrideExtractorValues(
folded[position],
)
if overrideErr != nil {
return runReport{}, fmt.Errorf(
"step %d override: %w",
step.Index,
overrideErr,
)
}
if skipped > 0 {
return runReport{}, fmt.Errorf(
"step %d: %d recorded extractor values fell outside the spec's extractor list",
step.Index,
skipped,
)
}
}
engine.EvaluateProperties()
for _, name := range engine.NewlyViolatedProperties() {
offline[name] = witnessFrom(engine.Witness(name), step.Index)
}
residualMismatches = append(
residualMismatches,
compareResiduals(step, engine.Residuals())...)
for name := range formulas {
recordFiring(
stateFired,
name,
singleState[name].ObserveAtStep(step.Timestamp, step.Index),
step.Index,
)
recordFiring(
stepFired,
name,
singleStep[name].ObserveAtStep(step.Timestamp, step.Index),
step.Index,
)
}
report.StepsObserved++
lastAction = lastActionFor(step)
}
finalizeIndex := run.finalizeIndex()
for _, name := range engine.Finalize() {
offline[name] = witnessFrom(engine.Witness(name), finalizeIndex)
}
for name := range formulas {
recordFiring(
stateFired,
name,
singleState[name].Finalize(),
finalizeIndex,
)
recordFiring(
stepFired,
name,
singleStep[name].Finalize(),
finalizeIndex,
)
}
report.CrashOnly = crashOnly(run)
report.Mismatches = compareVerdicts(onlineVerdicts(run), offline)
report.ResidualMismatches = len(residualMismatches)
if len(residualMismatches) > reportedResiduals {
residualMismatches = residualMismatches[:reportedResiduals]
}
report.Mismatches = append(report.Mismatches, residualMismatches...)
report.Valid = len(report.Mismatches) == 0
names := make([]string, 0, len(formulas))
for name := range formulas {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
property := propertyReport{
Property: name,
Class: propertyClass(formulas[name]),
TopLevel: topLevelForm(formulas[name]),
Engine: engineRefutation(offline, name),
SingleState: reducedRefutation(
singleStateExpresses(formulas[name]),
singleState[name],
stateFired[name],
),
SingleStep: reducedRefutation(
singleStepExpresses(formulas[name]),
singleStep[name],
stepFired[name],
),
SingleStepTruncatesWindow: truncatesWindow(formulas[name]),
}
property.Weakest = weakestOracle(property, report.CrashOnly)
report.Properties = append(report.Properties, property)
}
return report, nil
}
// reportedResiduals caps how many residual differences one trace lists. The
// count of all of them is reported alongside; the first few are what says
// where the two engines parted.
const reportedResiduals = 5
// compareResiduals checks the replayed pending state against the one the step
// recorded. A step the verifier skipped still recorded the residual it was
// holding, so those steps assert that a skipped step advanced nothing.
func compareResiduals(
step trace.Step,
replayed map[string]ltl.Formula,
) []mismatch {
if len(step.Residuals) == 0 {
return nil
}
var mismatches []mismatch
names := make([]string, 0, len(step.Residuals))
for name := range step.Residuals {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
formula, ok := replayed[name]
if !ok {
mismatches = append(mismatches, mismatch{
Property: name,
Field: fmt.Sprintf("residual at step %d", step.Index),
Online: string(step.Residuals[name]),
Offline: "property not registered",
})
continue
}
encoded, err := json.Marshal(formula)
if err != nil {
encoded = []byte(fmt.Sprintf("%q", err.Error()))
}
if !bytes.Equal(encoded, step.Residuals[name]) {
mismatches = append(mismatches, mismatch{
Property: name,
Field: fmt.Sprintf("residual at step %d", step.Index),
Online: string(step.Residuals[name]),
Offline: string(encoded),
})
}
}
return mismatches
}
func witnessFrom(witness *verifier.Witness, recordedStep int) witnessRecord {
record := witnessRecord{RecordedStep: recordedStep}
if witness == nil {
return record
}
record.OriginStep = witness.Step
record.DetectedStep = witness.DetectedStep
record.Reason = witness.Reason
record.IsError = witness.IsError
return record
}
// onlineVerdicts reads the violations the run recorded, including the
// end-of-run record a finalized liveness obligation is written to.
func onlineVerdicts(run loadedRun) map[string]witnessRecord {
recorded := map[string]witnessRecord{}
steps := run.Steps
if run.Finalize != nil {
steps = append(append([]trace.Step(nil), steps...), *run.Finalize)
}
for _, step := range steps {
for _, name := range step.Violations {
record := witnessRecord{RecordedStep: step.Index}
if witness, ok := step.Witnesses[name]; ok {
record.OriginStep = witness.Step
record.DetectedStep = witness.DetectedStep
record.Reason = witness.Reason
record.IsError = witness.IsError
}
recorded[name] = record
}
}
return recorded
}
func compareVerdicts(online, offline map[string]witnessRecord) []mismatch {
var mismatches []mismatch
names := map[string]bool{}
for name := range online {
names[name] = true
}
for name := range offline {
names[name] = true
}
ordered := make([]string, 0, len(names))
for name := range names {
ordered = append(ordered, name)
}
sort.Strings(ordered)
for _, name := range ordered {
recorded, wasRecorded := online[name]
replayed, wasReplayed := offline[name]
switch {
case wasRecorded && !wasReplayed:
mismatches = append(mismatches, mismatch{
Property: name, Field: "violated",
Online: fmt.Sprintf(
"violated at step %d",
recorded.RecordedStep,
),
Offline: "not violated",
})
continue
case !wasRecorded && wasReplayed:
mismatches = append(mismatches, mismatch{
Property: name, Field: "violated",
Online: "not violated",
Offline: fmt.Sprintf(
"violated at step %d",
replayed.RecordedStep,
),
})
continue
case !wasRecorded:
continue
}
for _, field := range []struct {
name string
online string
offline string
}{
{"step", fmt.Sprint(recorded.RecordedStep), fmt.Sprint(replayed.RecordedStep)},
{"origin_step", fmt.Sprint(recorded.OriginStep), fmt.Sprint(replayed.OriginStep)},
{"detected_step", fmt.Sprint(recorded.DetectedStep), fmt.Sprint(replayed.DetectedStep)},
{"reason", recorded.Reason, replayed.Reason},
} {
if field.online != field.offline {
mismatches = append(mismatches, mismatch{
Property: name, Field: field.name,
Online: field.online, Offline: field.offline,
})
}
}
}
return mismatches
}
// crashOnly fires where the application left the foreground or an error
// surface was recorded. Error-level log lines are reported alongside rather
// than folded in: a console error is not a crash, and an analysis that wants
// the looser detector can read the steps from here.
func crashOnly(run loadedRun) crashReport {
report := crashReport{}
for _, step := range run.Steps {
if len(step.Exceptions) > 0 {
report.ExceptionSteps = append(report.ExceptionSteps, step.Index)
}
if step.ActionSkipped == foregroundLossReason {
report.ForegroundLossSteps = append(
report.ForegroundLossSteps,
step.Index,
)
}
for _, entry := range step.Logs {
if entry.Level == "E" || entry.Level == "F" {
report.ErrorLogSteps = append(report.ErrorLogSteps, step.Index)
break
}
}
}
first := 0
for _, step := range append(append([]int(nil), report.ExceptionSteps...), report.ForegroundLossSteps...) {
if first == 0 || step < first {
first = step
}
}
report.Fired = first != 0
report.FirstStep = first
return report
}
func engineRefutation(
offline map[string]witnessRecord,
name string,
) refutation {
record, ok := offline[name]
if !ok {
return refutation{}
}
return refutation{
Refuted: true,
Step: record.RecordedStep,
OriginStep: record.OriginStep,
Reason: record.Reason,
IsError: record.IsError,
}
}
// reducedRefutation reports a reduced oracle's finding, or its inability to
// state the property at all. The evaluator is driven either way so that the
// verdict a reduction would have reached is never what decides whether it was
// entitled to reach one.
func reducedRefutation(
expresses bool,
evaluator *ltl.Evaluator,
firedAt int,
) refutation {
if !expresses {
return refutation{CannotExpress: true}
}
return evaluatorRefutation(evaluator, firedAt)
}
func evaluatorRefutation(evaluator *ltl.Evaluator, firedAt int) refutation {
violation := evaluator.Violation()
if violation == nil {
return refutation{}
}
return refutation{
Refuted: true,
Step: firedAt,
OriginStep: violation.Step,
Reason: violation.Reason,
IsError: violation.IsError,
}
}
// recordFiring keeps the first observation at which a reduced oracle latched,
// which the evaluator itself does not carry.
func recordFiring(
fired map[string]int,
name string,
verdict ltl.Verdict,
step int,
) {
if verdict != ltl.VerdictViolated {
return
}
if _, ok := fired[name]; ok {
return
}
fired[name] = step
}
// weakestOracle names the weakest oracle that refutes a defect the engine
// refuted, in the order a crash detector, a single-state check and a
// single-step triple are weaker than the engine.
func weakestOracle(property propertyReport, crash crashReport) string {
if !property.Engine.Refuted {
return ""
}
switch {
case crash.Fired:
return "crash-only"
case property.SingleState.Refuted:
return "single-state"
case property.SingleStep.Refuted:
return "single-step"
default:
return "temporal-only"
}
}
@@ -0,0 +1,433 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// fixtureSpec counts rows on screen and asserts three things about them: a
// bound a single observation can check, a reachability goal, and a growth step
// that only two consecutive observations can see.
const fixtureSpec = `
const rows = __sanderling__.extract((s) => s.ax.findAll({ text: "row" }).length).named("rows");
globalThis.properties = {
fewRows: __sanderling__.always(() => rows.current < 3),
rowsAppear: __sanderling__.eventually(() => rows.current > 0).within(500, "steps"),
rowsKeepGrowing: __sanderling__.always(
__sanderling__.now(() => rows.current === 1).implies(
__sanderling__.next(() => rows.current === 2))),
};
`
func rowTree(t *testing.T, rows int) *hierarchy.Tree {
t.Helper()
children := make([]string, 0, rows)
for range rows {
children = append(
children,
`{"attributes": {"text": "row", "bounds": "[0,0,10,10]"}, "children": []}`,
)
}
source := fmt.Sprintf(
`{"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"}, "children": [%s]}`,
strings.Join(children, ","),
)
tree, err := hierarchy.Parse(source)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
return tree
}
// writeFixtureRun records a run the way internal/runner does: every step's
// hierarchy, the violations that fired at it, their witnesses, and the residual
// each property was left holding.
func writeFixtureRun(t *testing.T, directory string, rowCounts []int) {
t.Helper()
engine, err := verifier.New(verifier.WithPlatform("android"))
if err != nil {
t.Fatalf("verifier: %v", err)
}
if err := engine.Load(fixtureSpec); err != nil {
t.Fatalf("load spec: %v", err)
}
writer, err := trace.NewWriter(directory)
if err != nil {
t.Fatalf("trace writer: %v", err)
}
defer writer.Close()
runStart := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
if err := writer.WriteMeta(trace.Meta{
Seed: 11,
SpecPath: "fixture.ts",
Platform: "android",
BundleID: "com.example.fixture",
StartedAt: runStart,
}); err != nil {
t.Fatalf("meta: %v", err)
}
lastIndex := 0
for position, rows := range rowCounts {
index := position + 1
lastIndex = index
stepTime := runStart.Add(time.Duration(index) * time.Second)
tree := rowTree(t, rows)
if err := engine.PushSnapshot(verifier.SnapshotInput{
Tree: tree,
StepTime: stepTime,
StepIndex: index,
RunStart: runStart,
}); err != nil {
t.Fatalf("push step %d: %v", index, err)
}
engine.EvaluateProperties()
violations := engine.NewlyViolatedProperties()
step := trace.Step{
Index: index,
Timestamp: stepTime,
Hierarchy: tree,
Violations: violations,
Witnesses: fixtureWitnesses(engine, violations, index),
Residuals: fixtureResiduals(t, engine),
}
if err := writer.WriteStep(step); err != nil {
t.Fatalf("write step %d: %v", index, err)
}
}
if ended := engine.Finalize(); len(ended) > 0 {
if err := writer.WriteStep(trace.Step{
Index: lastIndex + 1,
Timestamp: runStart.Add(time.Duration(lastIndex+1) * time.Second),
Violations: ended,
Witnesses: fixtureWitnesses(engine, ended, lastIndex+1),
}); err != nil {
t.Fatalf("write finalize step: %v", err)
}
}
}
func fixtureWitnesses(
engine *verifier.Verifier,
properties []string,
index int,
) map[string]trace.Witness {
if len(properties) == 0 {
return nil
}
witnesses := map[string]trace.Witness{}
for _, name := range properties {
witness := engine.Witness(name)
if witness == nil {
continue
}
detected := witness.DetectedStep
if detected == 0 {
detected = index
}
witnesses[name] = trace.Witness{
Reason: witness.Reason,
IsError: witness.IsError,
Step: witness.Step,
DetectedStep: detected,
Extractors: witness.Extractors,
}
}
return witnesses
}
func fixtureResiduals(
t *testing.T,
engine *verifier.Verifier,
) map[string]json.RawMessage {
t.Helper()
residuals := map[string]json.RawMessage{}
for name, formula := range engine.Residuals() {
body, err := json.Marshal(formula)
if err != nil {
t.Fatalf("marshal residual %q: %v", name, err)
}
residuals[name] = body
}
return residuals
}
func replayFixture(t *testing.T, directory string) runReport {
t.Helper()
loaded, err := loadRun(directory)
if err != nil {
t.Fatalf("load run: %v", err)
}
report, err := replay(loaded, fixtureSpec, false)
if err != nil {
t.Fatalf("replay: %v", err)
}
return report
}
func propertyByName(
t *testing.T,
report runReport,
name string,
) propertyReport {
t.Helper()
for _, property := range report.Properties {
if property.Property == name {
return property
}
}
t.Fatalf("property %q missing from the report", name)
return propertyReport{}
}
func TestReplayReproducesTheRecordedVerdicts(t *testing.T) {
directory := t.TempDir()
writeFixtureRun(t, directory, []int{0, 1, 1, 3})
report := replayFixture(t, directory)
if !report.Valid {
t.Fatalf("replay disagreed with the run: %+v", report.Mismatches)
}
if report.ResidualMismatches != 0 {
t.Errorf("residual mismatches: %d", report.ResidualMismatches)
}
if report.StepsObserved != 4 {
t.Errorf("steps observed: got %d, want 4", report.StepsObserved)
}
growth := propertyByName(t, report, "rowsKeepGrowing")
if !growth.Engine.Refuted || growth.Engine.Step != 3 {
t.Errorf("engine on rowsKeepGrowing: %+v", growth.Engine)
}
if growth.SingleState.Refuted {
t.Errorf(
"single-state refuted a growth step it cannot see: %+v",
growth.SingleState,
)
}
if !growth.SingleStep.Refuted {
t.Error("single-step did not refute a one-step obligation")
}
if growth.Weakest != "single-step" {
t.Errorf("weakest oracle for rowsKeepGrowing: got %q", growth.Weakest)
}
bound := propertyByName(t, report, "fewRows")
if !bound.SingleState.Refuted || bound.Weakest != "single-state" {
t.Errorf(
"fewRows: single-state %+v weakest %q",
bound.SingleState,
bound.Weakest,
)
}
}
func TestReplayReportsAViolationTheRunNeverRecorded(t *testing.T) {
directory := t.TempDir()
writeFixtureRun(t, directory, []int{0, 1, 1, 3})
dropRecordedViolation(t, directory, "rowsKeepGrowing")
report := replayFixture(t, directory)
if report.Valid {
t.Fatal("a trace missing a recorded violation must not replay as valid")
}
var found bool
for _, entry := range report.Mismatches {
if entry.Property == "rowsKeepGrowing" && entry.Field == "violated" {
found = true
}
}
if !found {
t.Errorf(
"no mismatch named the dropped violation: %+v",
report.Mismatches,
)
}
}
func TestReplayReportsAResidualTheRunNeverHeld(t *testing.T) {
directory := t.TempDir()
writeFixtureRun(t, directory, []int{0, 1, 1, 3})
rewriteResidual(t, directory, 1, "fewRows", `{"op":"false"}`)
report := replayFixture(t, directory)
if report.Valid {
t.Fatal(
"a trace whose recorded residual differs must not replay as valid",
)
}
if report.ResidualMismatches != 1 {
t.Errorf(
"residual mismatches: got %d, want 1",
report.ResidualMismatches,
)
}
}
// dropRecordedViolation removes one property from every step's violations,
// which is what a trace that failed to record a verdict looks like.
func dropRecordedViolation(t *testing.T, directory, property string) {
t.Helper()
rewriteSteps(t, directory, func(step *trace.Step) {
kept := step.Violations[:0]
for _, name := range step.Violations {
if name != property {
kept = append(kept, name)
}
}
step.Violations = kept
delete(step.Witnesses, property)
})
}
func rewriteResidual(
t *testing.T,
directory string,
index int,
property, residual string,
) {
t.Helper()
rewriteSteps(t, directory, func(step *trace.Step) {
if step.Index == index {
step.Residuals[property] = json.RawMessage(residual)
}
})
}
func rewriteSteps(t *testing.T, directory string, edit func(*trace.Step)) {
t.Helper()
path := filepath.Join(directory, "trace.jsonl")
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read trace: %v", err)
}
var rewritten strings.Builder
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
var step trace.Step
if err := json.Unmarshal([]byte(line), &step); err != nil {
t.Fatalf("decode step: %v", err)
}
edit(&step)
encoded, err := json.Marshal(step)
if err != nil {
t.Fatalf("encode step: %v", err)
}
rewritten.Write(encoded)
rewritten.WriteByte('\n')
}
if err := os.WriteFile(path, []byte(rewritten.String()), 0o644); err != nil {
t.Fatalf("write trace: %v", err)
}
}
func TestCrashOnlyFiresOnAnErrorSurfaceAndOnLeavingTheForeground(t *testing.T) {
run := loadedRun{Steps: []trace.Step{
{Index: 1, Logs: []trace.LogEntry{{Level: "E", Message: "noisy"}}},
{Index: 2, ActionSkipped: foregroundLossReason},
{Index: 3, Exceptions: []trace.Exception{{Class: "TypeError"}}},
}}
report := crashOnly(run)
if !report.Fired || report.FirstStep != 2 {
t.Errorf("crash-only: %+v", report)
}
if len(report.ExceptionSteps) != 1 || report.ExceptionSteps[0] != 3 {
t.Errorf("exception steps: %v", report.ExceptionSteps)
}
if len(report.ErrorLogSteps) != 1 || report.ErrorLogSteps[0] != 1 {
t.Errorf("error log steps: %v", report.ErrorLogSteps)
}
}
func TestCrashOnlyIgnoresAnErrorLogOnItsOwn(t *testing.T) {
run := loadedRun{
Steps: []trace.Step{{Index: 1, Logs: []trace.LogEntry{{Level: "E"}}}},
}
if crashOnly(run).Fired {
t.Error("an error-level log line is not a crash")
}
}
// A reachability goal the run reaches at the fourth observation is clean under
// the window its author wrote and refuted by the same window shortened to two
// observations. Reporting that shortened refutation would convict every clean
// trace, so the single-step column has to admit it cannot state the property.
func TestSingleStepDoesNotConvictACleanTraceOfATruncatedWindow(t *testing.T) {
directory := t.TempDir()
writeFixtureRun(t, directory, []int{0, 0, 0, 1})
report := replayFixture(t, directory)
if !report.Valid {
t.Fatalf("replay disagreed with the run: %+v", report.Mismatches)
}
appear := propertyByName(t, report, "rowsAppear")
if appear.Engine.Refuted {
t.Fatalf(
"the trace reaches the goal inside the 500-step window: %+v",
appear.Engine,
)
}
if appear.SingleStep.Refuted {
t.Errorf(
"single-step refuted a clean trace by shortening the window: %+v",
appear.SingleStep,
)
}
if !appear.SingleStep.CannotExpress {
t.Error(
"single-step must record a window it cannot state as inexpressible",
)
}
if !appear.SingleStepTruncatesWindow {
t.Error("the truncated-window marker must stay visible on the property")
}
}
func TestSingleStateAdmitsWhatOneObservationCannotState(t *testing.T) {
directory := t.TempDir()
writeFixtureRun(t, directory, []int{0, 1, 1, 3})
report := replayFixture(t, directory)
growth := propertyByName(t, report, "rowsKeepGrowing")
if !growth.SingleState.CannotExpress {
t.Errorf(
"single-state kept a next obligation it erases to nothing: %+v",
growth.SingleState,
)
}
appear := propertyByName(t, report, "rowsAppear")
if !appear.SingleState.CannotExpress {
t.Errorf(
"single-state kept a reachability goal it erases to nothing: %+v",
appear.SingleState,
)
}
bound := propertyByName(t, report, "fewRows")
if bound.SingleState.CannotExpress {
t.Error("single-state states a bound read at one observation")
}
if !bound.SingleState.Refuted || bound.Weakest != "single-state" {
t.Errorf(
"fewRows: single-state %+v weakest %q",
bound.SingleState,
bound.Weakest,
)
}
}
@@ -0,0 +1,281 @@
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 ""
}
}
@@ -0,0 +1,222 @@
package main
import (
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/ltl"
)
// observe drives an evaluator over a fixed sequence of predicate readings and
// returns the observation at which it latched, or 0 if it never did.
func observe(formula ltl.Formula, steps int) int {
evaluator := ltl.NewEvaluator(formula)
base := time.Unix(0, 0)
for step := 1; step <= steps; step++ {
if evaluator.ObserveAtStep(
base.Add(time.Duration(step)*time.Second),
step,
) == ltl.VerdictViolated {
return step
}
}
if evaluator.Finalize() == ltl.VerdictViolated {
return steps + 1
}
return 0
}
func constant(value bool) ltl.Formula {
return ltl.ThunkNamed("p", func() (bool, error) { return value, nil })
}
func TestSingleStateHoldsWhereRefutationNeedsTheNextObservation(t *testing.T) {
property := ltl.Always(
ltl.Implies(ltl.Now(constant(true)), ltl.Next(constant(false))),
)
if engine := observe(property, 4); engine == 0 {
t.Fatal(
"the engine was expected to refute a next obligation that never held",
)
}
if reduced := observe(singleStateFormula(property), 4); reduced != 0 {
t.Errorf(
"single-state refuted at observation %d, and one observation cannot see a next",
reduced,
)
}
if reduced := observe(singleStepFormula(property), 4); reduced == 0 {
t.Error("single-step was expected to refute a one-step obligation")
}
}
func TestSingleStateRefutesAPredicateReadAtOneObservation(t *testing.T) {
property := ltl.Always(constant(false))
if reduced := observe(singleStateFormula(property), 3); reduced != 1 {
t.Errorf(
"single-state latched at %d, want the first observation",
reduced,
)
}
}
func TestSingleStateKeepsANegatedTemporalHarmless(t *testing.T) {
property := ltl.Always(ltl.Not(ltl.Eventually(constant(false))))
if reduced := observe(singleStateFormula(property), 3); reduced != 0 {
t.Errorf(
"single-state refuted at observation %d; erasing a negated eventually must not manufacture a violation",
reduced,
)
}
}
func TestSingleStepShrinksAReachabilityGoalToTwoObservations(t *testing.T) {
property := ltl.EventuallyWithinSteps(constant(false), 500)
if engine := observe(property, 4); engine != 5 {
t.Fatalf(
"a 500-step window closes only at run end, latched at %d",
engine,
)
}
if reduced := observe(singleStepFormula(property), 4); reduced != 2 {
t.Errorf(
"single-step latched at %d, want the second observation",
reduced,
)
}
if !truncatesWindow(property) {
t.Error(
"a 500-step window shortened to two observations must be reported as truncated",
)
}
}
// sequence returns a predicate that reads the given values, one per call, so
// each evaluator gets its own reading counter.
func sequence(readings []bool) ltl.Formula {
position := 0
return ltl.ThunkNamed("p", func() (bool, error) {
value := readings[position]
position++
return value, nil
})
}
func TestSingleStepConvictsAGoalTheEngineSeesReached(t *testing.T) {
readings := []bool{false, false, true, true}
if engine := observe(ltl.EventuallyWithinSteps(sequence(readings), 4), 4); engine != 0 {
t.Fatalf(
"the engine latched at %d, and the goal is reached inside its window",
engine,
)
}
if reduced := observe(singleStepFormula(ltl.EventuallyWithinSteps(sequence(readings), 4)), 4); reduced != 2 {
t.Errorf(
"single-step latched at %d; an obligation armed at the first observation must not reach the third",
reduced,
)
}
}
func TestTruncatesWindowIgnoresAnObligationThatAlreadyFits(t *testing.T) {
property := ltl.Always(
ltl.Implies(ltl.Now(constant(true)), ltl.Next(constant(true))),
)
if truncatesWindow(property) {
t.Error("a next spans two observations already and is not truncated")
}
}
func TestPropertyClassAndFormComeFromTheTopLevel(t *testing.T) {
safety := ltl.Always(constant(true))
liveness := ltl.EventuallyWithinSteps(constant(true), 575)
if got := propertyClass(safety); got != "safety" {
t.Errorf("class of an always: got %q", got)
}
if got := propertyClass(liveness); got != "liveness" {
t.Errorf("class of an eventually: got %q", got)
}
if got := topLevelForm(liveness); got != "eventually within 575 steps" {
t.Errorf("form: got %q", got)
}
if got := topLevelForm(ltl.EventuallyWithin(constant(true), 3*time.Second)); got != "eventually within 3s" {
t.Errorf("form: got %q", got)
}
}
func TestSingleStepCannotExpressAWindowLongerThanATriple(t *testing.T) {
for _, testCase := range []struct {
name string
property ltl.Formula
expresses bool
}{
{"reachability goal in steps", ltl.EventuallyWithinSteps(constant(true), 575), false},
{"reachability goal in time", ltl.EventuallyWithin(constant(true), 3*time.Second), false},
{"unbounded reachability goal", ltl.Eventually(constant(true)), false},
{"window a triple spans exactly", ltl.EventuallyWithinSteps(constant(true), tripleWindow), true},
{"deadline nested under an always", ltl.Always(ltl.Implies(
ltl.Now(constant(true)), ltl.EventuallyWithin(constant(true), 3*time.Second))), false},
{"one-step obligation under an always", ltl.Always(ltl.Implies(
ltl.Now(constant(true)), ltl.Next(constant(true)))), true},
{"predicate under an always", ltl.Always(constant(true)), true},
} {
t.Run(testCase.name, func(t *testing.T) {
if singleStepExpresses(testCase.property) != testCase.expresses {
t.Errorf("single-step expresses %s: got %t, want %t",
testCase.name, !testCase.expresses, testCase.expresses)
}
})
}
}
// A shorter window under a negation asks for less rather than more, so the
// triple's refutations of it stay sound and the property stays expressible.
// Reporting it as inexpressible would push a defect the single-step oracle can
// genuinely catch into the temporal-only column.
func TestSingleStepStillExpressesANegatedWindow(t *testing.T) {
property := ltl.Always(
ltl.Not(ltl.EventuallyWithinSteps(constant(false), 575)),
)
if !singleStepExpresses(property) {
t.Error(
"a window shortened under a negation is weakened, not strengthened",
)
}
if truncatesWindow(property) {
t.Error(
"the truncation marker must not fire where shortening only weakens",
)
}
}
func TestSingleStateCannotExpressWhatItsRewriteEmpties(t *testing.T) {
for _, testCase := range []struct {
name string
property ltl.Formula
expresses bool
}{
{"predicate at one observation", ltl.Always(constant(true)), true},
{"reachability goal", ltl.EventuallyWithinSteps(constant(true), 575), false},
{"next obligation under an always", ltl.Always(ltl.Implies(
ltl.Now(constant(true)), ltl.Next(constant(true)))), false},
{"deadline under an always", ltl.Always(ltl.Implies(
ltl.Now(constant(true)), ltl.EventuallyWithin(constant(true), 3*time.Second))), false},
{"negated eventually under an always", ltl.Always(ltl.Not(ltl.Eventually(constant(false)))), false},
{"predicate conjoined with a next", ltl.Always(ltl.And(constant(true), ltl.Next(constant(true)))), true},
} {
t.Run(testCase.name, func(t *testing.T) {
if singleStateExpresses(testCase.property) != testCase.expresses {
t.Errorf("single-state expresses %s: got %t, want %t",
testCase.name, !testCase.expresses, testCase.expresses)
}
})
}
}