From 755d9a4751f7af4f72106467e5c6ebe5c0e8e2d3 Mon Sep 17 00:00:00 2001 From: scttfrdmn <3011922+scttfrdmn@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:21:47 -0700 Subject: [PATCH] fix(run): refuse a multi-arg non-starmap warm unit instead of silently NameError'ing (calque#187) Found while re-verifying calque#79's closing claim that AI-Almanac's three real scripts run end-to-end on calque v0.6.0: a picked warm unit with 2+ non-self/cls positional args that isn't .starmap()'d (e.g. a .spawn()-invoked function like forecasts_app.py's run_forecast_inference(job_id, model_id, config)) silently NameError'd on every synthetic item, since the warm runner only ever binds the FIRST positional arg outside the .starmap() splat path. checkInvokeSupport now refuses loudly with an explanatory message instead. --arg-file/--arg-json (a real, caller-supplied positional tuple) already solves this for a real run when the caller supplies one -- e.g. app.py's run_benchmark_local, calque#178's real-hardware-verified path -- so that path explicitly bypasses the new arity guard via a hasRealArgTuple param, threaded only through realrun.go's own call site. --- CHANGELOG.md | 16 +++++++++ cmd/calque/fleetrun.go | 2 +- cmd/calque/gate_test.go | 60 +++++++++++++++++++++++++++++++--- cmd/calque/realrun.go | 2 +- cmd/calque/run.go | 34 +++++++++++++++++-- cmd/calque/starmap_e2e_test.go | 2 +- 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfbba8..ce193fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ per [semver.org](https://semver.org/#spec-item-4). ## [Unreleased] +### Fixed + +- A picked warm unit with 2+ non-self/cls positional args that ISN'T + `.starmap()`'d (e.g. a `.spawn()`-invoked function like AI-Almanac's + `forecasts_app.py`'s `run_forecast_inference(job_id, model_id, config)`) + now refuses loudly instead of silently `NameError`-ing on every synthetic + item (calque#187) — the warm runner only ever bound the FIRST positional + arg outside the `.starmap()` splat path, leaving the rest undefined. + Found while re-verifying calque#79's closing claim that AI-Almanac's + three real scripts run end-to-end: `forecasts_app.py`'s and + `blending_app.py`'s picked units both hit this unannounced. Does NOT + affect `calque real --arg-file`/`--arg-json` (a real, caller-supplied + positional tuple, e.g. `app.py`'s `run_benchmark_local` — calque#178's + real-hardware verification) — that path explicitly bypasses the new + arity guard, since it already supplies real per-position data. + ## [0.6.0] - 2026-08-15 22 commits since v0.5.0 (folding in the changes originally staged under an diff --git a/cmd/calque/fleetrun.go b/cmd/calque/fleetrun.go index 23da333..7d7f35e 100644 --- a/cmd/calque/fleetrun.go +++ b/cmd/calque/fleetrun.go @@ -99,7 +99,7 @@ func fleetRun(o realOpts, shards int) (err error) { shardBody := calexec.ManifestBody{EnterBody: realEnterBody, MethodBody: realMethodBody, MethodArg: "prompt"} shardHostMode := false if scriptBody, ok := manifestBodyForUnit(app, unit, rep); ok { - if err := checkInvokeSupport(app.Script, unit.method, rep); err != nil { + if err := checkInvokeSupport(app.Script, unit.method, rep, false); err != nil { return err } // GPU guard parity with dry-run (run.go's swapLegal check, §7): refuse diff --git a/cmd/calque/gate_test.go b/cmd/calque/gate_test.go index 277a557..eaf6cfd 100644 --- a/cmd/calque/gate_test.go +++ b/cmd/calque/gate_test.go @@ -178,7 +178,7 @@ func TestPickWarmUnitUnscopedWhenNotAmbiguous(t *testing.T) { func TestCheckInvokeSupportStarmapRefusesWithoutRealItems(t *testing.T) { rep := &leak.Report{} fn := ir.Function{Name: "combine", Invoke: ir.InvokeStarmap} - if err := checkInvokeSupport("script.py", fn, rep); err == nil { + if err := checkInvokeSupport("script.py", fn, rep, false); err == nil { t.Fatal("expected an error refusing a .starmap'd warm unit with no real Items, got nil") } } @@ -193,7 +193,7 @@ func TestCheckInvokeSupportStarmapRunsWithRealItems(t *testing.T) { Name: "combine", Invoke: ir.InvokeStarmap, Line: 12, Items: []any{[]any{float64(1), float64(2)}, []any{float64(3), float64(4)}}, } - if err := checkInvokeSupport("script.py", fn, rep); err != nil { + if err := checkInvokeSupport("script.py", fn, rep, false); err != nil { t.Fatalf("checkInvokeSupport(.starmap with real Items) must not error, got: %v", err) } found := false @@ -214,7 +214,7 @@ func TestCheckInvokeSupportStarmapRunsWithRealItems(t *testing.T) { func TestCheckInvokeSupportForEachLeaksNotRefuses(t *testing.T) { rep := &leak.Report{} fn := ir.Function{Name: "notify", Invoke: ir.InvokeForEach, Line: 7} - if err := checkInvokeSupport("script.py", fn, rep); err != nil { + if err := checkInvokeSupport("script.py", fn, rep, false); err != nil { t.Fatalf("checkInvokeSupport(.for_each) must not error, got: %v", err) } found := false @@ -234,7 +234,7 @@ func TestCheckInvokeSupportMapAndRemoteAreFine(t *testing.T) { rep := &leak.Report{} for _, kind := range []ir.InvokeKind{ir.InvokeMap, ir.InvokeRemote, ir.InvokeNone} { fn := ir.Function{Name: "f", Invoke: kind} - if err := checkInvokeSupport("script.py", fn, rep); err != nil { + if err := checkInvokeSupport("script.py", fn, rep, false); err != nil { t.Errorf("checkInvokeSupport(%q) must not error, got: %v", kind, err) } } @@ -243,6 +243,58 @@ func TestCheckInvokeSupportMapAndRemoteAreFine(t *testing.T) { } } +// TestCheckInvokeSupportMultiArgNonStarmapRefuses (calque#187): a picked +// unit with 2+ non-self/cls positional args that ISN'T .starmap()'d (e.g. a +// .spawn()-invoked function like AI-Almanac's forecasts_app.py's +// `run_forecast_inference(job_id, model_id, config)`) must refuse loudly +// instead of silently NameError'ing — the warm runner only ever binds the +// FIRST positional arg per item outside the .starmap() splat path (both +// dryRunWarm and manifestBodyForUnit), so the rest would be undefined. +// hasRealArgTuple=false: no --arg-file/--arg-json supplied a real tuple. +func TestCheckInvokeSupportMultiArgNonStarmapRefuses(t *testing.T) { + rep := &leak.Report{} + fn := ir.Function{Name: "run_forecast_inference", Invoke: ir.InvokeSpawn, Args: []string{"job_id", "model_id", "config"}} + err := checkInvokeSupport("script.py", fn, rep, false) + if err == nil { + t.Fatal("expected an error refusing a multi-arg non-starmap unit, got nil") + } + for _, want := range []string{"run_forecast_inference", "job_id", "model_id", "config", "3"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing expected substring %q", err.Error(), want) + } + } +} + +// TestCheckInvokeSupportMultiArgWithRealArgTupleFine (calque#187) is the +// regression guard for the exact case that made a blanket arity refusal +// wrong: AI-Almanac's app.py's run_benchmark_local(job_id, config, bundle, +// runtime_env) takes 4 positional args, is invoked via .remote() (not +// .starmap()), yet calque#178 verified it running for real on real +// hardware — because `calque real --arg-file`/`--arg-json` supplies a REAL +// per-position tuple, bypassing the single-item-arg synthetic path +// entirely. hasRealArgTuple=true must never refuse, regardless of arity. +func TestCheckInvokeSupportMultiArgWithRealArgTupleFine(t *testing.T) { + rep := &leak.Report{} + fn := ir.Function{Name: "run_benchmark_local", Invoke: ir.InvokeRemote, Args: []string{"job_id", "config", "bundle", "runtime_env"}} + if err := checkInvokeSupport("script.py", fn, rep, true); err != nil { + t.Fatalf("checkInvokeSupport(hasRealArgTuple=true) must not refuse a multi-arg unit, got: %v", err) + } +} + +// TestCheckInvokeSupportSingleArgNonStarmapFine proves the arity guard only +// fires on 2+ args — the overwhelmingly common single-arg case (every +// existing .map()/.remote()/plain-function corpus script) must stay +// unaffected, byte-for-byte unchanged from before calque#187. +func TestCheckInvokeSupportSingleArgNonStarmapFine(t *testing.T) { + rep := &leak.Report{} + for _, kind := range []ir.InvokeKind{ir.InvokeMap, ir.InvokeRemote, ir.InvokeNone, ir.InvokeSpawn} { + fn := ir.Function{Name: "f", Invoke: kind, Args: []string{"self", "item"}} + if err := checkInvokeSupport("script.py", fn, rep, false); err != nil { + t.Errorf("checkInvokeSupport(%q, 1 non-self arg) must not error, got: %v", kind, err) + } + } +} + // TestResolveEntrypointNoneDefined: a script with no @app.local_entrypoint() // has nothing to select — not an error, unless one was explicitly requested. func TestResolveEntrypointNoneDefined(t *testing.T) { diff --git a/cmd/calque/realrun.go b/cmd/calque/realrun.go index 65d43d3..869e0b9 100644 --- a/cmd/calque/realrun.go +++ b/cmd/calque/realrun.go @@ -242,7 +242,7 @@ func realRun(o realOpts) (err error) { var dockerfileText string body := calexec.ManifestBody{EnterBody: realEnterBody, MethodBody: realMethodBody, MethodArg: "prompt"} if scriptBody, ok := manifestBodyForUnit(app, unit, rep); ok { - if err := checkInvokeSupport(app.Script, unit.method, rep); err != nil { + if err := checkInvokeSupport(app.Script, unit.method, rep, forceStarmap); err != nil { return err } // GPU guard parity with dry-run (run.go's swapLegal check, calque#7/§7): diff --git a/cmd/calque/run.go b/cmd/calque/run.go index fcd39f2..97e1d85 100644 --- a/cmd/calque/run.go +++ b/cmd/calque/run.go @@ -108,7 +108,7 @@ func run(o runOpts) error { // protocol) and always collects+returns a result. .starmap/.for_each are // classified at the IR layer but were silently run as if .map'd — check // explicitly rather than let the mismatch surface as a mystery failure. - if err := checkInvokeSupport(app.Script, unit.method, rep); err != nil { + if err := checkInvokeSupport(app.Script, unit.method, rep, false); err != nil { return err } @@ -556,7 +556,18 @@ func swapLegal(glog *gpu.Log, owner string) bool { // .starmap'd unit with no statically-resolvable iterable" — the one case // realOrSyntheticItems (items.go) can't make splat-safe on its own, because // there is no real per-item shape to consult. -func checkInvokeSupport(script string, fn ir.Function, rep *leak.Report) error { +// hasRealArgTuple is true only at realrun.go's own call site, when the +// caller already resolved a REAL per-position argument tuple via +// --arg-file/--arg-json (forceStarmap) — that path splats every position +// from real, caller-supplied data regardless of what static parsing +// detected, so the generic arity guard below would wrongly refuse an +// already-solved case (e.g. AI-Almanac's app.py: run_benchmark_local takes +// 4 positional args, invoked via .remote() not .starmap(), but calque#178 +// verified it running for real on real hardware precisely BECAUSE +// --arg-file/--arg-json supplied the real tuple). Every other caller +// (dry-run, fleetrun, and realrun.go itself when --arg-file/--arg-json +// weren't given) passes false. +func checkInvokeSupport(script string, fn ir.Function, rep *leak.Report, hasRealArgTuple bool) error { switch fn.Invoke { case ir.InvokeStarmap: if len(fn.Items) == 0 { @@ -567,6 +578,25 @@ func checkInvokeSupport(script string, fn ir.Function, rep *leak.Report) error { case ir.InvokeForEach: rep.Addf(leak.PrimMap, leak.KindSemanticGap, script, fn.Line, "%s is .for_each()'d (side-effects only, no result collection in real Modal); calque collects+reports a result per item anyway — harmless but not a faithful .for_each", fn.Name) + default: + // calque#187: outside .starmap() (which already knows how to splat a + // real tuple across every positional arg) and outside + // --arg-file/--arg-json's own real-tuple path (hasRealArgTuple), the + // warm runner binds exactly ONE positional value per item + // (unit.method.ItemArg, the first non-self/cls param) — + // dryRunWarm/manifestBodyForUnit never populate the rest. A picked + // unit with 2+ non-self/cls positional args or (e.g. a + // .spawn()-invoked function like + // `run_forecast_inference(job_id, model_id, config)`, real-world + // case: AI-Almanac's forecasts_app.py) silently NameErrors on every + // item instead of refusing with an honest message — found live via + // calque#79's re-verification. + if hasRealArgTuple { + break + } + if args := nonSelfArgs(fn.Args); len(args) > 1 { + return fmt.Errorf("%s takes %d positional args (%s) but isn't .starmap()'d — the warm runner only binds the first (%q) per item; the rest would be undefined. This function's real invocation shape (multiple positional args, likely via .spawn() fan-out) isn't reproduced by calque's single-arg warm-unit model unless driven via --arg-file/--arg-json (a real, caller-supplied tuple) — see calque#187", fn.Name, len(args), strings.Join(args, ", "), args[0]) + } } return nil } diff --git a/cmd/calque/starmap_e2e_test.go b/cmd/calque/starmap_e2e_test.go index bd52f0b..0967782 100644 --- a/cmd/calque/starmap_e2e_test.go +++ b/cmd/calque/starmap_e2e_test.go @@ -55,7 +55,7 @@ func TestStarmapEndToEndDryRun(t *testing.T) { } // checkInvokeSupport must NOT refuse now that real tuple data exists. - if err := checkInvokeSupport(app.Script, unit.method, rep); err != nil { + if err := checkInvokeSupport(app.Script, unit.method, rep, false); err != nil { t.Fatalf("checkInvokeSupport must not refuse a .starmap unit with real Items, got: %v", err) }