fix(trace): an action names the generator that produced it

The setup exclusion landed for the model arm only, because only a model
pick stamped a source. A seeded run returned setup's action through the
same entry with no marker, so its denominator still counted the login
while the model arm's did not, and the two are compared.

serializeAction names setup and seeded on the wire, so both arms are
counted by one rule. An already-recorded trace names nothing and keeps
exactly the count it was reported with; unattributed_actions counts those
steps so the old denominator cannot pass as the new one. TraceVersion is
deliberately unbumped: oracle-reduction refuses a differing version, and
a bump would make all 169 recorded runs unreplayable.
This commit is contained in:
pj committed 2026-08-18 17:58:38 +05:30
1 parent 66fd5bce5d
commit 454988fbc8
19 files changed
+377 -82

No files matched your search

+13
View File
@@ -611,6 +611,10 @@ type wireAction struct {
ToX int `json:"toX"`
ToY int `json:"toY"`
DurationMillis int `json:"durationMillis"`
// Source names the generator that produced the action: the spec's setup or
// the action root. Empty from the candidate enumeration, which serializes
// actions nothing has chosen yet.
Source string `json:"source"`
}
// DecodeAction turns one serialized action (the flat camelCase wire contract)
@@ -623,6 +627,15 @@ func DecodeAction(raw json.RawMessage) (Action, error) {
if err := json.Unmarshal(raw, &wire); err != nil {
return Action{}, fmt.Errorf("decode action: %w", err)
}
action, err := actionFromWire(wire)
if err != nil {
return Action{}, err
}
action.Source = wire.Source
return action, nil
}
func actionFromWire(wire wireAction) (Action, error) {
switch wire.Kind {
case "Tap":
return Action{Kind: ActionKindTap, On: wire.Selector, X: wire.X, Y: wire.Y}, nil
+5 -2
View File
@@ -184,12 +184,15 @@ func modelOfferedActions(t *testing.T, verb string) map[string]Action {
// actionIdentity keys an action by everything except the values the policy owns
// rather than the candidate set: the typed text, which the seeded arm draws from
// the edge-case corpus and the model writes itself, and a swipe's drag distance,
// which the seeded arm draws and the enumeration lists at a nominal length.
// the edge-case corpus and the model writes itself, a swipe's drag distance,
// which the seeded arm draws and the enumeration lists at a nominal length, and
// the source, which names who produced an action rather than what it does (the
// enumeration names nobody, because the model has not chosen yet).
// Comparing those would compare policies instead of action spaces. A swipe's
// direction is NOT policy-owned, so it survives as the sign of the drag.
func actionIdentity(action Action) string {
action.Text = ""
action.Source = ""
if action.Kind == ActionKindSwipe {
action.ToX = sign(action.ToX - action.FromX)
action.ToY = sign(action.ToY - action.FromY)
+52
View File
@@ -96,6 +96,58 @@ export const actionsRoot = taps;
}
}
// TestNextActionNamesTheGeneratorThatProducedIt is the native half of the
// cross-host marker contract: for the same spec shape, a setup-driven step and
// an action-root step must name different producers. The web half asserts the
// same two names in pkg/spec/test/web-runtime.test.ts, so a match on both sides
// proves the engines agree without either invoking the other. A per-action rate
// divides by the root's steps only, and an unnamed producer inflates it by
// however many steps the login consumed.
func TestNextActionNamesTheGeneratorThatProducedIt(t *testing.T) {
spec := `
import { Tap, actions, extract, taps } from "@sanderling/spec";
const signIn = extract("signIn", state => state.ax.find("id:SignIn"));
export const setup = actions(() => (signIn.current ? [Tap({ on: "id:SignIn" })] : []));
export const actionsRoot = taps;
`
v := loadBundled(t, spec, enumTreeJSON)
action, err := v.NextAction()
if err != nil {
t.Fatalf("NextAction: %v", err)
}
if action.On != "id:SignIn" || action.Source != "setup" {
t.Errorf("NextAction = %+v, want the Tap on id:SignIn named setup", action)
}
pushTree(t, v, policyTreeJSON)
action, err = v.NextAction()
if err != nil {
t.Fatalf("NextAction after setup went quiet: %v", err)
}
if action.Source != "seeded" {
t.Errorf("NextAction = %+v, want the action root's tap named seeded", action)
}
}
// TestSetupActionNamesSetup: the model policy reaches setup through its own
// entry, which must name the producer the same way the seeded entry does, or
// the two arms' per-action rates divide by different things.
func TestSetupActionNamesSetup(t *testing.T) {
spec := `
import { Tap, actions, taps } from "@sanderling/spec";
export const setup = actions(() => [Tap({ on: "id:SignIn" })]);
export const actionsRoot = taps;
`
v := loadBundled(t, spec, enumTreeJSON)
action, err := v.SetupAction()
if err != nil {
t.Fatalf("SetupAction: %v", err)
}
if action.Source != "setup" {
t.Errorf("SetupAction = %+v, want it named setup", action)
}
}
// TestSetupGeneratorDrawsUnderTheModelPolicy: setup walks through the picker
// with its rng under both policies, so a generator there is not the divergence
// the enumeration refuses and must keep drawing. Enumerating before every setup
+4
View File
@@ -38,6 +38,10 @@ type Action struct {
// when the apply call failed and nothing can say whether the action
// reached the app. The spec is told which of the two it is.
Applied bool
// Source names the generator that produced this action, "setup" or
// "seeded", as the runtime entry tagged it. Empty on an action the runner
// built itself (the model policy's pick), which the runner names instead.
Source string
// Relaunched, like Applied, is meaningful only on state.lastAction: the
// runner brought the app back to the foreground after this action, so the
// two readings the spec compares straddle a restart. The action still