From 028534cadce6757e8f3155499f9a2694399653d8 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 12 Aug 2026 18:35:23 +0530 Subject: [PATCH] feat(campaign): sweep seeds for one experiment cell campaign.json lists the seeds a sweep intended to run and is written before the first run, so a host that dropped runs shows up as missing seeds rather than as a smaller sample. Seed 0 is rejected: sanderling test reads it as "derive a seed from the clock", which is why conformance/gates.sh controls nothing today. Each run contributes one runs.jsonl line carrying steps to first violation by origin step, the step that armed the failed obligation, so the survival analysis never reopens a trace. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX --- cmd/internal-tools/campaign/campaign.go | 219 ++++++++++++ cmd/internal-tools/campaign/campaign_test.go | 317 ++++++++++++++++++ .../campaign/end_to_end_test.go | 144 ++++++++ cmd/internal-tools/campaign/main.go | 190 +++++++++++ cmd/internal-tools/campaign/main_test.go | 122 +++++++ cmd/internal-tools/campaign/manifest.go | 67 ++++ cmd/internal-tools/campaign/manifest_test.go | 70 ++++ cmd/internal-tools/campaign/seeds.go | 79 +++++ cmd/internal-tools/campaign/seeds_test.go | 49 +++ cmd/internal-tools/campaign/summary.go | 148 ++++++++ cmd/internal-tools/campaign/summary_test.go | 200 +++++++++++ 11 files changed, 1605 insertions(+) create mode 100644 cmd/internal-tools/campaign/campaign.go create mode 100644 cmd/internal-tools/campaign/campaign_test.go create mode 100644 cmd/internal-tools/campaign/end_to_end_test.go create mode 100644 cmd/internal-tools/campaign/main.go create mode 100644 cmd/internal-tools/campaign/main_test.go create mode 100644 cmd/internal-tools/campaign/manifest.go create mode 100644 cmd/internal-tools/campaign/manifest_test.go create mode 100644 cmd/internal-tools/campaign/seeds.go create mode 100644 cmd/internal-tools/campaign/seeds_test.go create mode 100644 cmd/internal-tools/campaign/summary.go create mode 100644 cmd/internal-tools/campaign/summary_test.go diff --git a/cmd/internal-tools/campaign/campaign.go b/cmd/internal-tools/campaign/campaign.go new file mode 100644 index 0000000..42cc8ca --- /dev/null +++ b/cmd/internal-tools/campaign/campaign.go @@ -0,0 +1,219 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// commandExecutor runs one sanderling invocation and returns its exit code. +// A non-nil error means the process could not be run at all, which is a +// different failure from a run that started and exited non-zero. +type commandExecutor func(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error) + +func executeCommand(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error) { + command := exec.CommandContext(ctx, binary, arguments...) + command.Stdout = output + command.Stderr = output + err := command.Run() + if err == nil { + return 0, nil + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), nil + } + return -1, err +} + +// runRecord is one line of runs.jsonl. +type runRecord struct { + Seed int64 `json:"seed"` + Device string `json:"device,omitempty"` + ExitCode int `json:"exit_code"` + LaunchError string `json:"launch_error,omitempty"` + StartedAt time.Time `json:"started_at"` + DurationMillis int64 `json:"duration_millis"` + RunDirectory string `json:"run_directory,omitempty"` + TraceError string `json:"trace_error,omitempty"` + traceSummary +} + +type campaign struct { + configuration config + executor commandExecutor + stdout io.Writer + records io.Writer + mutex sync.Mutex + failures int + unreadable int +} + +func runCampaign(ctx context.Context, configuration config, executor commandExecutor, stdout io.Writer) error { + if _, err := os.Stat(filepath.Join(configuration.outputDirectory, manifestFileName)); err == nil { + return fmt.Errorf("%s already exists in %s: pick a fresh --output so two campaigns do not share a directory", + manifestFileName, configuration.outputDirectory) + } + if err := os.MkdirAll(configuration.outputDirectory, 0o755); err != nil { + return fmt.Errorf("create campaign dir: %w", err) + } + binaryPath := configuration.sanderlingPath + if resolved, err := exec.LookPath(binaryPath); err == nil { + if absolute, err := filepath.Abs(resolved); err == nil { + binaryPath = absolute + } + } + version, err := probeVersion(ctx, configuration.sanderlingPath, executor) + if err != nil { + return err + } + host, _ := os.Hostname() + if err := writeManifest(configuration.outputDirectory, buildManifest(configuration, host, binaryPath, version, time.Now().UTC())); err != nil { + return fmt.Errorf("write %s: %w", manifestFileName, err) + } + + recordsFile, err := os.OpenFile(filepath.Join(configuration.outputDirectory, recordsFileName), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open %s: %w", recordsFileName, err) + } + defer recordsFile.Close() + + sweep := &campaign{configuration: configuration, executor: executor, stdout: stdout, records: recordsFile} + fmt.Fprintf(stdout, "campaign %s: %d seeds, %d worker(s), %s\n", + configuration.arm, len(configuration.seeds), len(workerDevices(configuration.devices)), configuration.outputDirectory) + sweep.sweep(ctx) + + fmt.Fprintf(stdout, "campaign complete: %d of %d runs failed, %d produced an unreadable trace\n", + sweep.failures, len(configuration.seeds), sweep.unreadable) + if sweep.failures > 0 { + return fmt.Errorf("%d of %d runs failed", sweep.failures, len(configuration.seeds)) + } + if sweep.unreadable > 0 { + // A run that exits 0 and leaves a trace the analysis cannot read is a + // lost cell, not a successful campaign, and an unattended sweep has to + // say so rather than reporting no failures. + return fmt.Errorf("%d of %d runs produced an unreadable trace", sweep.unreadable, len(configuration.seeds)) + } + return nil +} + +func probeVersion(ctx context.Context, binary string, executor commandExecutor) (string, error) { + var output bytes.Buffer + code, err := executor(ctx, binary, []string{"version"}, &output) + if err != nil { + return "", fmt.Errorf("run %s version: %w", binary, err) + } + if code != 0 { + return "", fmt.Errorf("%s version exited %d: %s", binary, code, strings.TrimSpace(output.String())) + } + return strings.TrimSpace(output.String()), nil +} + +// workerDevices returns one entry per concurrent worker. With no --devices +// there is a single worker and no device to name. +func workerDevices(devices []string) []string { + if len(devices) == 0 { + return []string{""} + } + return devices +} + +func (c *campaign) sweep(ctx context.Context) { + queue := make(chan int64, len(c.configuration.seeds)) + for _, seed := range c.configuration.seeds { + queue <- seed + } + close(queue) + + var waitGroup sync.WaitGroup + for _, device := range workerDevices(c.configuration.devices) { + waitGroup.Add(1) + go func(device string) { + defer waitGroup.Done() + for seed := range queue { + if ctx.Err() != nil { + return + } + c.report(c.runSeed(ctx, seed, device)) + } + }(device) + } + waitGroup.Wait() +} + +func (c *campaign) runSeed(ctx context.Context, seed int64, device string) runRecord { + seedText := strconv.FormatInt(seed, 10) + directory := seedDirectory(c.configuration, seedText) + record := runRecord{Seed: seed, Device: device, StartedAt: time.Now().UTC()} + + if err := os.MkdirAll(directory, 0o755); err != nil { + record.ExitCode = -1 + record.LaunchError = err.Error() + return record + } + logFile, err := os.Create(filepath.Join(directory, "sanderling.log")) + if err != nil { + record.ExitCode = -1 + record.LaunchError = err.Error() + return record + } + defer logFile.Close() + + start := time.Now() + exitCode, runErr := c.executor(ctx, c.configuration.sanderlingPath, runArguments(c.configuration, seedText, device), logFile) + record.DurationMillis = time.Since(start).Milliseconds() + record.ExitCode = exitCode + if runErr != nil { + record.LaunchError = runErr.Error() + } + + name, summary, err := summarizeRun(directory) + if name != "" { + record.RunDirectory = filepath.Join(filepath.Base(directory), name) + } + if err != nil { + record.TraceError = err.Error() + return record + } + record.traceSummary = summary + return record +} + +func (c *campaign) report(record runRecord) { + c.mutex.Lock() + defer c.mutex.Unlock() + if record.ExitCode != 0 { + c.failures++ + } else if record.TraceError != "" { + c.unreadable++ + } + if err := json.NewEncoder(c.records).Encode(record); err != nil { + fmt.Fprintf(c.stdout, "warning: seed %d record: %v\n", record.Seed, err) + } + fmt.Fprintf(c.stdout, "seed=%d device=%q outcome=%s steps=%d exit=%d duration=%s\n", + record.Seed, record.Device, outcome(record), record.Steps, record.ExitCode, + time.Duration(record.DurationMillis)*time.Millisecond) +} + +func outcome(record runRecord) string { + switch { + case record.ExitCode != 0: + return "failed" + case record.FirstViolationOriginStep != nil: + return fmt.Sprintf("violation@%d(%s)", *record.FirstViolationOriginStep, + strings.Join(record.FirstViolationProperties, ",")) + default: + return "clean" + } +} diff --git a/cmd/internal-tools/campaign/campaign_test.go b/cmd/internal-tools/campaign/campaign_test.go new file mode 100644 index 0000000..59cc4f1 --- /dev/null +++ b/cmd/internal-tools/campaign/campaign_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/priyanshujain/sanderling/internal/trace" +) + +func testConfiguration(t *testing.T, outputDirectory string, extra ...string) config { + t.Helper() + arguments := append(baseArguments(), "--output", outputDirectory) + configuration, err := parseArguments(append(arguments, extra...), io.Discard) + if err != nil { + t.Fatal(err) + } + return configuration +} + +// versionAnswering wraps a test executor so every fake answers `sanderling +// version`, which the campaign probes before it writes the manifest. +func versionAnswering(executor commandExecutor) commandExecutor { + return func(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error) { + if len(arguments) > 0 && arguments[0] == "version" { + fmt.Fprintln(output, "stub-version") + return 0, nil + } + return executor(ctx, binary, arguments, output) + } +} + +func readRecords(t *testing.T, campaignDirectory string) []runRecord { + t.Helper() + file, err := os.Open(filepath.Join(campaignDirectory, recordsFileName)) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var records []runRecord + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var record runRecord + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + t.Fatalf("decode %q: %v", scanner.Text(), err) + } + records = append(records, record) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return records +} + +func writeFakeRun(t *testing.T, arguments []string, steps []trace.Step) { + t.Helper() + writeRunDirectory(t, argumentValue(arguments, "--output"), "20260101-000000", steps) +} + +func TestRunCampaign_WritesManifestBeforeAnyRun(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory) + var stdout bytes.Buffer + + executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) { + if _, err := os.Stat(filepath.Join(directory, manifestFileName)); err != nil { + t.Errorf("manifest missing when the first run started: %v", err) + } + writeFakeRun(t, arguments, []trace.Step{observedStep(1)}) + return 0, nil + }) + if err := runCampaign(context.Background(), configuration, executor, &stdout); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(filepath.Join(directory, manifestFileName)) + if err != nil { + t.Fatal(err) + } + var recorded manifest + if err := json.Unmarshal(body, &recorded); err != nil { + t.Fatal(err) + } + if !slices.Equal(recorded.Seeds, []int64{1, 2, 3}) { + t.Errorf("intended seeds: got %v", recorded.Seeds) + } + if recorded.SanderlingVersion != "stub-version" { + t.Errorf("version: got %q", recorded.SanderlingVersion) + } + if recorded.Host == "" { + t.Error("host was not recorded") + } +} + +func TestRunCampaign_RecordsPerRunSummary(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory, "--seeds", "1-3") + var stdout bytes.Buffer + + executor := versionAnswering(func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) { + fmt.Fprintln(output, "stub run log") + steps := []trace.Step{observedStep(1), observedStep(2), observedStep(3)} + if argumentValue(arguments, "--seed") == "2" { + violating := observedStep(3) + violating.Violations = []string{"listNeverEmpty"} + violating.Witnesses = map[string]trace.Witness{ + "listNeverEmpty": {Reason: "list emptied", Step: 2, DetectedStep: 3}, + } + steps[2] = violating + } + writeFakeRun(t, arguments, steps) + return 0, nil + }) + if err := runCampaign(context.Background(), configuration, executor, &stdout); err != nil { + t.Fatal(err) + } + + records := readRecords(t, directory) + if len(records) != 3 { + t.Fatalf("records: got %d, want 3", len(records)) + } + for _, record := range records { + if record.Steps != 3 { + t.Errorf("seed %d steps: got %d, want 3", record.Seed, record.Steps) + } + if record.RunDirectory != fmt.Sprintf("seed-%d/20260101-000000", record.Seed) { + t.Errorf("seed %d run directory: got %q", record.Seed, record.RunDirectory) + } + if record.TraceError != "" { + t.Errorf("seed %d trace error: %s", record.Seed, record.TraceError) + } + violated := record.FirstViolationOriginStep != nil + if violated != (record.Seed == 2) { + t.Errorf("seed %d violation: got %v", record.Seed, record.FirstViolationOriginStep) + } + if record.Seed != 2 { + continue + } + if *record.FirstViolationOriginStep != 2 || *record.FirstViolationDetectedStep != 3 { + t.Errorf("seed 2 violation steps: origin %d detected %d", + *record.FirstViolationOriginStep, *record.FirstViolationDetectedStep) + } + if !slices.Equal(record.ViolatedProperties, []string{"listNeverEmpty"}) { + t.Errorf("seed 2 properties: got %v", record.ViolatedProperties) + } + } + + log, err := os.ReadFile(filepath.Join(directory, "seed-1", "sanderling.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(log), "stub run log") { + t.Errorf("per-run log: got %q", log) + } + if !strings.Contains(stdout.String(), "seed=2") { + t.Errorf("progress output missing seed 2: %q", stdout.String()) + } +} + +func TestRunCampaign_DistributesSeedsAcrossDeviceWorkers(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory, "--seeds", "1-9", "--devices", "device-a,device-b,device-c") + + var mutex sync.Mutex + assignments := map[int64]string{} + var inFlight atomic.Int32 + var releaseOnce sync.Once + var timedOut atomic.Bool + release := make(chan struct{}) + + executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) { + if inFlight.Add(1) >= 3 { + releaseOnce.Do(func() { close(release) }) + } + select { + case <-release: + case <-time.After(5 * time.Second): + timedOut.Store(true) + } + seed, err := strconv.ParseInt(argumentValue(arguments, "--seed"), 10, 64) + if err != nil { + t.Errorf("seed argument: %v", err) + } + mutex.Lock() + if previous, seen := assignments[seed]; seen { + t.Errorf("seed %d ran twice (%s then %s)", seed, previous, argumentValue(arguments, "--device")) + } + assignments[seed] = argumentValue(arguments, "--device") + mutex.Unlock() + writeFakeRun(t, arguments, []trace.Step{observedStep(1)}) + return 0, nil + }) + if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil { + t.Fatal(err) + } + if timedOut.Load() { + t.Fatal("three device workers never ran concurrently") + } + if len(assignments) != 9 { + t.Fatalf("seeds run: got %d, want 9", len(assignments)) + } + used := map[string]int{} + for seed, device := range assignments { + if !slices.Contains(configuration.devices, device) { + t.Errorf("seed %d ran on unknown device %q", seed, device) + } + used[device]++ + } + if len(used) != 3 { + t.Errorf("devices used: got %v, want all three", used) + } + if got := len(readRecords(t, directory)); got != 9 { + t.Errorf("records: got %d, want 9", got) + } +} + +func TestRunCampaign_ContinuesAfterFailingRun(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory, "--seeds", "1-3") + + executor := versionAnswering(func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) { + if argumentValue(arguments, "--seed") == "2" { + fmt.Fprintln(output, "error: device offline") + return 1, nil + } + writeFakeRun(t, arguments, []trace.Step{observedStep(1)}) + return 0, nil + }) + err := runCampaign(context.Background(), configuration, executor, io.Discard) + if err == nil || !strings.Contains(err.Error(), "1 of 3 runs failed") { + t.Fatalf("expected a failure summary, got %v", err) + } + + records := readRecords(t, directory) + if len(records) != 3 { + t.Fatalf("a failing run must not abort the campaign: got %d records", len(records)) + } + for _, record := range records { + if record.Seed == 2 { + if record.ExitCode != 1 { + t.Errorf("seed 2 exit code: got %d, want 1", record.ExitCode) + } + if record.TraceError == "" { + t.Error("seed 2 produced no trace; that should be recorded") + } + continue + } + if record.ExitCode != 0 { + t.Errorf("seed %d exit code: got %d", record.Seed, record.ExitCode) + } + } +} + +func TestRunCampaign_RefusesToReuseACampaignDirectory(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory) + if err := os.WriteFile(filepath.Join(directory, manifestFileName), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + executor := versionAnswering(func(context.Context, string, []string, io.Writer) (int, error) { + t.Error("no run should start in a directory that already holds a campaign") + return 0, nil + }) + if err := runCampaign(context.Background(), configuration, executor, io.Discard); err == nil { + t.Fatal("expected a refusal to reuse the campaign directory") + } +} + +func TestRunCampaign_AbortsWhenVersionProbeFails(t *testing.T) { + directory := t.TempDir() + configuration := testConfiguration(t, directory) + executor := func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) { + if arguments[0] != "version" { + t.Error("a run started despite an unusable binary") + } + fmt.Fprintln(output, "no such command") + return 2, nil + } + if err := runCampaign(context.Background(), configuration, executor, io.Discard); err == nil { + t.Fatal("expected the campaign to abort before writing a manifest it cannot attribute") + } + if _, err := os.Stat(filepath.Join(directory, manifestFileName)); err == nil { + t.Error("manifest was written despite an unusable binary") + } +} + +func TestRunCampaign_UnreadableTraceIsNotASuccessfulCampaign(t *testing.T) { + campaignDirectory := filepath.Join(t.TempDir(), "cell") + configuration := testConfiguration(t, campaignDirectory, "--seeds", "1-2") + + executor := versionAnswering(func(context.Context, string, []string, io.Writer) (int, error) { + return 0, nil + }) + + var stdout bytes.Buffer + err := runCampaign(context.Background(), configuration, executor, &stdout) + if err == nil || !strings.Contains(err.Error(), "2 of 2 runs produced an unreadable trace") { + t.Fatalf("a campaign whose runs left no readable trace must not report success: %v", err) + } + for _, record := range readRecords(t, campaignDirectory) { + if record.TraceError == "" { + t.Errorf("seed %d: expected a trace error, got none", record.Seed) + } + } +} diff --git a/cmd/internal-tools/campaign/end_to_end_test.go b/cmd/internal-tools/campaign/end_to_end_test.go new file mode 100644 index 0000000..61c8297 --- /dev/null +++ b/cmd/internal-tools/campaign/end_to_end_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// stubSanderling answers `version`, writes a run directory shaped like the one +// `sanderling test` produces, violates on seed 5 and fails on seed 7. +const stubSanderling = `#!/bin/sh +if [ "$1" = "version" ]; then + echo "stub 9.9.9" + exit 0 +fi +seed="" +output="" +while [ $# -gt 0 ]; do + case "$1" in + --seed) seed="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) shift ;; + esac +done +echo "stub run seed=$seed" +run="$output/20260101-000000" +mkdir -p "$run" +echo "{\"seed\":$seed,\"platform\":\"web\"}" > "$run/meta.json" +{ + echo '{"step":1,"hierarchy":{"elements":[]}}' + echo '{"step":2,"hierarchy":{"elements":[]}}' + if [ "$seed" = "5" ]; then + echo '{"step":3,"hierarchy":{"elements":[]},"violations":["cartTotalMatches"],"witnesses":{"cartTotalMatches":{"reason":"total drifted","step":2,"detected_step":3}}}' + else + echo '{"step":3,"hierarchy":{"elements":[]}}' + fi +} > "$run/trace.jsonl" +if [ "$seed" = "7" ]; then + echo "driver crashed" >&2 + exit 3 +fi +` + +func TestRun_EndToEndAgainstStubBinary(t *testing.T) { + directory := t.TempDir() + binaryPath := filepath.Join(directory, "stub-sanderling") + if err := os.WriteFile(binaryPath, []byte(stubSanderling), 0o755); err != nil { + t.Fatal(err) + } + campaignDirectory := filepath.Join(directory, "campaign") + var stdout bytes.Buffer + + err := run([]string{ + "--spec", "/specs/folio.ts", + "--bundle-id", "app.folio", + "--platform", "web", + "--arm", "seeded-web", + "--generator", "seeded", + "--max-steps", "50", + "--duration", "30s", + "--seeds", "4-5,7", + "--devices", "worker-a,worker-b", + "--sanderling", binaryPath, + "--output", campaignDirectory, + "--", "--clear-data=false", + }, &stdout, io.Discard) + if err == nil || !strings.Contains(err.Error(), "1 of 3 runs failed") { + t.Fatalf("expected the failing seed to be reported, got %v", err) + } + + body, err := os.ReadFile(filepath.Join(campaignDirectory, manifestFileName)) + if err != nil { + t.Fatal(err) + } + var recorded manifest + if err := json.Unmarshal(body, &recorded); err != nil { + t.Fatal(err) + } + if recorded.SanderlingVersion != "stub 9.9.9" { + t.Errorf("version: got %q", recorded.SanderlingVersion) + } + if !slices.Equal(recorded.Seeds, []int64{4, 5, 7}) { + t.Errorf("intended seeds: got %v", recorded.Seeds) + } + if slices.Contains(recorded.ArgumentTemplate, "--device") { + t.Errorf("web template should carry no device flag: %v", recorded.ArgumentTemplate) + } + if recorded.ArgumentTemplate[len(recorded.ArgumentTemplate)-1] != "--clear-data=false" { + t.Errorf("template lost the passthrough flag: %v", recorded.ArgumentTemplate) + } + + records := readRecords(t, campaignDirectory) + if len(records) != 3 { + t.Fatalf("records: got %d, want 3", len(records)) + } + bySeed := map[int64]runRecord{} + for _, record := range records { + bySeed[record.Seed] = record + } + for _, seed := range []int64{4, 5, 7} { + record, ok := bySeed[seed] + if !ok { + t.Fatalf("seed %d is missing from %s", seed, recordsFileName) + } + if record.Steps != 3 { + t.Errorf("seed %d steps: got %d, want 3", seed, record.Steps) + } + if record.RunDirectory == "" { + t.Errorf("seed %d has no run directory", seed) + } + if !slices.Contains([]string{"worker-a", "worker-b"}, record.Device) { + t.Errorf("seed %d device: got %q", seed, record.Device) + } + } + violating := bySeed[5] + if violating.FirstViolationOriginStep == nil || *violating.FirstViolationOriginStep != 2 { + t.Fatalf("seed 5 origin step: got %v, want 2", violating.FirstViolationOriginStep) + } + if *violating.FirstViolationDetectedStep != 3 || violating.FirstViolationReason != "total drifted" { + t.Errorf("seed 5 violation: %+v", violating) + } + if bySeed[4].FirstViolationOriginStep != nil { + t.Errorf("seed 4 should be censored at the budget: %+v", bySeed[4]) + } + if bySeed[7].ExitCode != 3 { + t.Errorf("seed 7 exit code: got %d, want 3", bySeed[7].ExitCode) + } + + log, err := os.ReadFile(filepath.Join(campaignDirectory, "seed-7", "sanderling.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(log), "driver crashed") { + t.Errorf("seed 7 log lost the stderr line: %q", log) + } + if !strings.Contains(stdout.String(), "outcome=violation@2") { + t.Errorf("progress output: %q", stdout.String()) + } +} diff --git a/cmd/internal-tools/campaign/main.go b/cmd/internal-tools/campaign/main.go new file mode 100644 index 0000000..8586bfd --- /dev/null +++ b/cmd/internal-tools/campaign/main.go @@ -0,0 +1,190 @@ +// Command campaign sweeps a list of seeds for one experiment cell, writing a +// directory an analysis pipeline can read without re-parsing raw traces. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +type config struct { + specPath string + bundleID string + platform string + arm string + generator string + maxSteps int + duration time.Duration + seeds []int64 + devices []string + sanderlingPath string + outputDirectory string + extraArguments []string +} + +const usage = `campaign sweeps seeds for one experiment cell of a sanderling evaluation. + +Usage: + campaign --spec --bundle-id --platform + --arm