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