mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
1 parent
a0a8c9c710
commit
a45ba76d8e
7 files changed
+2182
No files matched your search
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user