mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat: LTL operators, sampling, and default generators (#17)
* feat(ltl): add Now/Next/Eventually/Implies/Or/And/Not formulas Replace the fold-with-latch evaluator with a residual-formula reducer. Each Observe() instantiates a fresh obligation from the root (stripping an outer Always), reduces each pending obligation against current state, latches Violated on first failure, and surfaces Pending verdicts for deferred obligations. Existing Always/Pure/Thunk tests continue to pass. * feat(ltl): support relative duration for eventually().within() * feat(proto): add Swipe, PressKey, RecentLogs RPCs * feat(verifier,runner): formula handles, new action kinds, rich state - verifier: add formula-spec registry; bindNow/bindNext/bindEventually with chainable .implies/.or/.and/.not and .within(n,unit) on eventually; bindFrom for uniform sampling. bindAlways keeps accepting plain predicates. - verifier: store lastTree, lastAction, step time, logs, exceptions on the Verifier; SnapshotInput replaces the (snapshots, tree) pair. stateObject now produces state.lastAction/time/logs/exceptions matching the TS State type. - verifier: make taps/swipes/waitOnce/pressKey built-in generators actually fire; taps picks a clickable, enabled element from the last hierarchy. - agent: add exceptions field to Message wire format. - driver: add Swipe/PressKey/RecentLogs to Driver interface; wire maestro client and mock driver. LogEntry exposed for runner consumption. - runner: apply Swipe/PressKey/Wait actions; collect logcat and exceptions; pass lastAction and step time into PushSnapshot. * feat(spec-api): LTL operators, new actions, richer State - ltl.ts exports now/next/eventually; always overload accepts a Formula - types.ts: Formula gains implies/or/and/not; EventuallyFormula adds .within; State gains lastAction/time/logs/exceptions; Swipe/PressKey/Wait action types - actions.ts: Swipe/PressKey/Wait/from constructors; waitOnce + pressKey default generators - tests exercise the chaining, sampling, and new actions through a recorded fake runtime * feat(sidecar): add swipe, pressKey, recentLogs RPC handlers * feat(sdk-android): capture uncaught exceptions Install a default uncaught handler on Uatu.start, chained with any existing handler so Android's crash reporter still runs. Expose Uatu.reportError for callers to forward caught throwables. A bounded circular buffer (default 50) drains into each STATE message's new exceptions field. Protocol.kt serializes/deserializes the field, matching the Go wire format added to internal/agent/protocol.go. * feat(spec-api): add @uatu/spec/defaults/properties bundle * feat(sample-app): exercise new LTL operators + defaults spec.ts now imports eventually/next/now/from from @uatu/spec and noUncaughtExceptions from @uatu/spec/defaults/properties. It declares three properties that exercise the new surface: - accountCountNonNegative: plain always() safety - addAccountAdvances: always(now(x).implies(next(y))) - eventuallyLoggedIn: eventually(p).within(30, "seconds") - noUncaughtExceptions: imported default The weighted actions root uses from() for random phone/name sampling and entries for taps/swipes/waitOnce/pressKey built-ins. SampleApplication gains a debug hook gated on the system property uatu.inject_error so the e2e run can synthesize an Uatu.reportError and verify noUncaughtExceptions violates. cmd/uatu/test_run.go adds a subpath alias so specs importing "@uatu/spec/defaults/properties" resolve against the in-tree source when running from the uatu checkout. The spec-integration tests swap the old click-counter fixtures for the new login hierarchy. * feat(trace): record swipe/key/wait details + exceptions trace.Step gains an Exceptions array so the trace captures the class/message/stackTrace for each SDK-reported throwable in a step. trace.Action gains FromX/FromY/ToX/ToY/Key/DurationMillis so the full payload of Swipe/PressKey/Wait actions is visible in trace.jsonl. sample-app's debug error hook now gates on ApplicationInfo.DEBUGGABLE instead of a system property (adb setprop fails on non-rooted emulators).
This commit is contained in:
40 files changed
+2980
-262
No files matched your search
+189
-21
@@ -1,12 +1,16 @@
|
||||
package ltl
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Verdict int
|
||||
|
||||
const (
|
||||
VerdictHolds Verdict = iota
|
||||
VerdictViolated
|
||||
VerdictPending
|
||||
)
|
||||
|
||||
func (v Verdict) String() string {
|
||||
@@ -15,46 +19,210 @@ func (v Verdict) String() string {
|
||||
return "holds"
|
||||
case VerdictViolated:
|
||||
return "violated"
|
||||
case VerdictPending:
|
||||
return "pending"
|
||||
default:
|
||||
return fmt.Sprintf("verdict(%d)", int(v))
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluator folds a formula across observed steps. v0.1 semantics:
|
||||
// Always(P) is satisfied if P held at every observed step; once P is false,
|
||||
// the verdict latches to Violated.
|
||||
// Evaluator reduces a formula across observed steps using residual-formula
|
||||
// semantics. Each step either resolves pending obligations (to holds or
|
||||
// violated) or carries them forward as residuals. Once a single obligation
|
||||
// violates, the overall verdict latches to Violated.
|
||||
type Evaluator struct {
|
||||
formula Formula
|
||||
root Formula
|
||||
pending []Formula
|
||||
violated bool
|
||||
}
|
||||
|
||||
func NewEvaluator(formula Formula) *Evaluator {
|
||||
return &Evaluator{formula: formula}
|
||||
return &Evaluator{root: formula}
|
||||
}
|
||||
|
||||
// Observe evaluates the formula against the current state and returns the
|
||||
// running verdict. Once Violated, subsequent calls keep returning Violated
|
||||
// regardless of what later observations look like.
|
||||
// running verdict. Uses the real wall clock for deadline-bound operators;
|
||||
// callers that need reproducible time should use ObserveAt.
|
||||
func (e *Evaluator) Observe() Verdict {
|
||||
return e.ObserveAt(time.Now())
|
||||
}
|
||||
|
||||
// ObserveAt is like Observe but takes the current step time explicitly.
|
||||
func (e *Evaluator) ObserveAt(now time.Time) Verdict {
|
||||
if e.violated {
|
||||
return VerdictViolated
|
||||
}
|
||||
if !holdsAtCurrentStep(e.formula) {
|
||||
e.violated = true
|
||||
return VerdictViolated
|
||||
|
||||
fresh := rootObligation(e.root)
|
||||
obligations := append(e.pending, fresh)
|
||||
e.pending = e.pending[:0]
|
||||
|
||||
for _, obligation := range obligations {
|
||||
result := reduce(obligation, now)
|
||||
switch result.status {
|
||||
case statusHolds:
|
||||
// drop
|
||||
case statusViolated:
|
||||
e.violated = true
|
||||
e.pending = nil
|
||||
return VerdictViolated
|
||||
case statusPending:
|
||||
e.pending = append(e.pending, result.formula)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.pending) > 0 {
|
||||
return VerdictPending
|
||||
}
|
||||
return VerdictHolds
|
||||
}
|
||||
|
||||
func holdsAtCurrentStep(formula Formula) bool {
|
||||
switch concrete := formula.(type) {
|
||||
case AlwaysFormula:
|
||||
return holdsAtCurrentStep(concrete.Inner)
|
||||
case PureFormula:
|
||||
return concrete.Value
|
||||
case ThunkFormula:
|
||||
return concrete.Func()
|
||||
default:
|
||||
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
|
||||
// rootObligation returns the formula to instantiate at each step. An outer
|
||||
// Always is stripped so its inner is re-evaluated every step; any other root
|
||||
// formula is itself re-instantiated each step (matching the v0.1 semantics
|
||||
// where a bare Thunk is re-observed on every call).
|
||||
func rootObligation(root Formula) Formula {
|
||||
if always, ok := root.(AlwaysFormula); ok {
|
||||
return always.Inner
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
type residualStatus int
|
||||
|
||||
const (
|
||||
statusHolds residualStatus = iota
|
||||
statusViolated
|
||||
statusPending
|
||||
)
|
||||
|
||||
type reduceResult struct {
|
||||
status residualStatus
|
||||
formula Formula
|
||||
}
|
||||
|
||||
func holds() reduceResult { return reduceResult{status: statusHolds} }
|
||||
func violated() reduceResult { return reduceResult{status: statusViolated} }
|
||||
func pending(f Formula) reduceResult {
|
||||
return reduceResult{status: statusPending, formula: f}
|
||||
}
|
||||
|
||||
func reduce(formula Formula, now time.Time) reduceResult {
|
||||
switch concrete := formula.(type) {
|
||||
case PureFormula:
|
||||
if concrete.Value {
|
||||
return holds()
|
||||
}
|
||||
return violated()
|
||||
|
||||
case ThunkFormula:
|
||||
if concrete.Func() {
|
||||
return holds()
|
||||
}
|
||||
return violated()
|
||||
|
||||
case NowFormula:
|
||||
return reduce(concrete.Inner, now)
|
||||
|
||||
case NextFormula:
|
||||
// Next defers the inner obligation to the following step without
|
||||
// evaluating it now.
|
||||
return pending(concrete.Inner)
|
||||
|
||||
case EventuallyFormula:
|
||||
// First-reduction deadline resolution: if the formula was built with
|
||||
// a relative duration, fix the absolute deadline to (now + duration)
|
||||
// so subsequent reductions compare against a stable value.
|
||||
if !concrete.HasDeadline && concrete.Duration > 0 {
|
||||
concrete.Deadline = now.Add(concrete.Duration)
|
||||
concrete.HasDeadline = true
|
||||
}
|
||||
innerResult := reduce(concrete.Inner, now)
|
||||
if innerResult.status == statusHolds {
|
||||
return holds()
|
||||
}
|
||||
if concrete.HasStepBound && concrete.StepBound <= 1 {
|
||||
return violated()
|
||||
}
|
||||
if concrete.HasDeadline && !now.Before(concrete.Deadline) {
|
||||
return violated()
|
||||
}
|
||||
next := concrete
|
||||
if concrete.HasStepBound {
|
||||
next.StepBound = concrete.StepBound - 1
|
||||
}
|
||||
return pending(next)
|
||||
|
||||
case ImpliesFormula:
|
||||
antecedent := reduce(concrete.Antecedent, now)
|
||||
switch antecedent.status {
|
||||
case statusHolds:
|
||||
return reduce(concrete.Consequent, now)
|
||||
case statusViolated:
|
||||
return holds()
|
||||
case statusPending:
|
||||
return pending(ImpliesFormula{
|
||||
Antecedent: antecedent.formula,
|
||||
Consequent: concrete.Consequent,
|
||||
})
|
||||
}
|
||||
|
||||
case OrFormula:
|
||||
left := reduce(concrete.Left, now)
|
||||
right := reduce(concrete.Right, now)
|
||||
if left.status == statusHolds || right.status == statusHolds {
|
||||
return holds()
|
||||
}
|
||||
if left.status == statusViolated && right.status == statusViolated {
|
||||
return violated()
|
||||
}
|
||||
if left.status == statusViolated {
|
||||
return pending(right.formula)
|
||||
}
|
||||
if right.status == statusViolated {
|
||||
return pending(left.formula)
|
||||
}
|
||||
return pending(OrFormula{Left: left.formula, Right: right.formula})
|
||||
|
||||
case AndFormula:
|
||||
left := reduce(concrete.Left, now)
|
||||
right := reduce(concrete.Right, now)
|
||||
if left.status == statusViolated || right.status == statusViolated {
|
||||
return violated()
|
||||
}
|
||||
if left.status == statusHolds && right.status == statusHolds {
|
||||
return holds()
|
||||
}
|
||||
if left.status == statusHolds {
|
||||
return pending(right.formula)
|
||||
}
|
||||
if right.status == statusHolds {
|
||||
return pending(left.formula)
|
||||
}
|
||||
return pending(AndFormula{Left: left.formula, Right: right.formula})
|
||||
|
||||
case NotFormula:
|
||||
inner := reduce(concrete.Inner, now)
|
||||
switch inner.status {
|
||||
case statusHolds:
|
||||
return violated()
|
||||
case statusViolated:
|
||||
return holds()
|
||||
case statusPending:
|
||||
return pending(NotFormula{Inner: inner.formula})
|
||||
}
|
||||
|
||||
case AlwaysFormula:
|
||||
innerResult := reduce(concrete.Inner, now)
|
||||
if innerResult.status == statusViolated {
|
||||
return violated()
|
||||
}
|
||||
next := AlwaysFormula{Inner: concrete.Inner}
|
||||
if innerResult.status == statusHolds {
|
||||
return pending(next)
|
||||
}
|
||||
return pending(AndFormula{Left: innerResult.formula, Right: next})
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("ltl: unsupported formula type %T", formula))
|
||||
}
|
||||
Reference in new issue
Block a user