Files
sanderling/cmd/internal-tools/oracle-reduction/oracle_test.go
T
pj a45ba76d8e 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.
2026-08-16 17:45:41 +05:30

434 lines
12 KiB
Go

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,
)
}
}