Files
sanderling/cmd/internal-tools/oracle-reduction/oracle.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

550 lines
16 KiB
Go

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"
}
}