Status: Authoritative current behavior. Verified through: v0.6.0. calque's goal is broad enough Modal-idiom mimicry that real Modal code ports to AWS unchanged. This document is the single most direct answer to "does calque support my script."
Provenance:
- Updated the §E
modal.NetworkFileSystemrow (2026-08-15, calque#91 Workstream B): moved from ⬜ (not modeled — leak only) to ✅ (a real, bring-your-own EFS mount over NFS). See the row itself for the file-by-file implementation summary. - Updated the §E
modal.CloudBucketMountrow (2026-08-14, calque#91 Workstream A): moved from ⬜ (not modeled — leak only) to ✅ (a real mountpoint-s3 mount against the script's own S3 bucket). See the row itself for the file-by-file implementation summary. - Verified against calque v0.5.0 (2026-08-14) — updated the App-level
defaults row for calque#174 (image= per-function resolution fix).
Also fixed the §H
modal.Cron/modal.Periodrows (calque#149 doc audit pass): they had claimed the object forms were entirely unrecognized (⬜), which was stale —tools/pyast/pyast.py's_schedule_markerandinternal/parse/parse.go'sdecodeScheduleMarkeralready recognize both structurally (calque#91), same "recorded but not honored" posture as the bare-string form. Content otherwise last verified at commit19b4a1a(2026-08-12); the tracking-column re-check for #97/#98/#168 happened at v0.4.0 (2026-08-13). - Modal API doc survey last refreshed: 2026-08-07.
- Real-world corpus survey last refreshed: 2026-08-13 (calque#150 pass —
see
testdata/real-world/README.md's "pass 2" section for the 5 scripts added and the 2 confirmed bugs — calque#151/#152 — that survey found).
This document merges research passes from three sources, most recently refreshed 2026-08-13:
- calque's current state — a full read of
tools/pyast/pyast.py,internal/parse/parse.go,internal/ir/ir.go,internal/gpu/gpu.go,internal/gate/*.go,internal/image/dockerfile.go,cmd/calque/run.go, and the existingbehind-the-seam-register.md. - Modal's documented API surface — from
modal.com/docs(guide + SDK reference). - Real-world frequency — from
modal-labs/modal-examples(212 files) plus ~20 independent production repos found via GitHub code search across two passes (calque#79, calque#150). - Modal's CLI surface — from
modal.com/docs/reference/cli/*, compared against calque's current CLI (analyze,run,smoke,real,ramp,pool,spawn-run,session) — seedocs/guide/cli-reference.mdfor the full flag-level detail, kept separately since it changes faster than this matrix's construct-level census does.
Update this table as gaps close or Modal's docs change — that's cheaper than re-deriving the survey every time a new adopter's script surfaces a "new" gap.
Frequency tiers (Pass 3, rough real-world prevalence): 🔥 very common (used in most apps or nearly universal) · 🟡 common (a first-class, recurring use case) · ⚪ minority (recurring but not dominant) · 🧊 rare (a handful of real examples).
calque status legend: ✅ fully supported (parsed, represented, and executed as Modal would) · 🛑 recognized, refuses loudly (correct behavior is to reject, not run) · 🟨 recognized-and-leaked (detected, deliberately not honored, but still runs) · ❌ silently dropped/buggy (a real gap — should not stay this way) · ⬜ not present at all.
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
modal.App(name, ...) |
Deployment/namespace unit; functions don't run just by being deployed. | 🔥 | ✅ pyast.py:238-240 → ir.App.Name |
None known. | — |
App(image=..., secrets=..., volumes=...) (app-level defaults) |
Inherited by every Function/Cls unless overridden. | ⚪ | ✅ (fixed calque#168 for volumes=/secrets=, calque#174 for image=) — every Function/Class resolves its OWN image=/secrets=/volumes= kwarg first; a callable declaring none inherits the App-level default via one App→class→method fallback chain, the same shape the pre-existing gpu=/volumes= class→method fallback already used one level down. image= additionally fixes a DEEPER bug than a missing fallback: pre-#174, resolveImage picked ONE image variable for the whole script regardless of who referenced it, so a function with its OWN explicit image= could silently get a DIFFERENT function's image. |
Before these fixes: volumes=/secrets= were silently dropped with zero leak at all (confirmed via live repro); image= was worse still — an explicit per-function choice could be silently overridden. |
#168 closed; #174 closed |
@app.function(...) |
Registers an independently-autoscaled serverless function pool. | 🔥 | ✅ (as config carrier) / see §F for execution-shape gap | — | — |
@app.cls(...) |
Same kwarg surface as .function, plus lifecycle hooks + method pooling. |
🔥 | ✅ | — | — |
@app.local_entrypoint(name=None) |
Runs locally, not in a container; kicks off .remote()/.map() calls. Multiple entrypoints need modal run file.py::fn. |
🔥 | ✅ (fixed 2026-08-07, calque#78 — multiple entrypoints in one script all preserved) | — | closed |
@app.server(...) |
Registers HTTP-only server classes; no .remote() support. |
🧊 | ⬜ | — | not yet filed (low priority) |
App.include(...) / .deploy() / .run() |
Multi-app composition, deploy strategies, ephemeral-vs-deployed lifecycle. | ⚪ | ⬜ | calque has no concept of "deployed" vs. "ephemeral" — its execution model is closer to always-ephemeral. Real scripts calling App.run(detach=True) won't be recognized. |
#91 |
modal.Stub (deprecated → hard error since Modal 1.0) |
Old name for App. |
🧊 (legacy only) | ⬜ | Any script still using this is already broken against current Modal — not calque's problem. | — |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
@app.function(...) on a plain function |
The base serverless unit. | 🔥 (2x as prevalent as @app.cls, 118 vs 57 files in modal-examples) |
✅ (fixed 2026-08-07, calque#80 — pickWarmUnit now selects a plain function, preferring a .map()'d one, else the first, when no @cls+@enter unit exists; leaks that no warm-reuse economics exist to amortize; verified end-to-end against all three real AI-Almanac scripts, which previously refused outright) |
Was blocking 100% of real scripts surveyed; now runs (leaks surface the OTHER already-tracked gaps instead — image resolution, cpu/memory sizing, secrets, etc.). | closed |
@app.cls(...) + @modal.enter() (no .map()) |
Holds loaded state (model, DB connection) for .remote()-called or web-endpoint-served inference. |
🟡 (very common in GPU-serving apps, frequently paired with @asgi_app rather than .map()) |
✅ recognized, but pickWarmUnit only selects it as the runnable shape when a .map()'d method exists or falls back to "first method" — a @cls+@enter used purely for .remote() calls (no .map() at all) is still selected via the fallback, so this mostly works today. |
— | — |
@app.cls + @modal.enter() + .map() (calque's original target shape — a plain @app.function is also runnable, see §A above) |
"Load model once, batch-score many." | ⚪ (~5-10% of files; plain-function .map() is at least as common even among .map() users — 16/26 vs 10/26 files) |
✅ | This is not the dominant shape it was built around — see §A row above. | — |
@modal.enter(snap=False) |
Runs once per container at startup, before any input. snap=True marks pre-snapshot code (see §I memory snapshots). |
🔥 (wherever @cls is used) |
✅ body carried as ir.Class.EnterBody, actually run once by warmd. snap= kwarg itself unrecognized (falls through to generic "unmodeled arg"). |
Memory-snapshot semantics (snap=True vs default) aren't distinguished — low-risk since calque doesn't do container snapshotting at all. |
— |
@modal.exit() |
Runs on container shutdown; gets a grace period on preemption specifically for cleanup. | 🟡 (paired with @enter wherever teardown matters) |
✅ (fixed 2026-08-07, calque#86) recognized in visit_ClassDef, excluded from cls.Methods (confirmed via a live repro: before the fix, an exit-only class was picked as the warm unit's sole method by pickWarmUnit's fallback; after the fix, run() correctly refuses with "no mapped @cls+@enter warm unit found" instead). Teardown itself is leaked as unreproduced (ir.Class.HasExit). |
The warm supervisor has no shutdown-hook concept — teardown logic itself still doesn't run, just no longer silently misclassified. | closed |
@modal.method(is_generator=None) |
Converts an instance method into an invokable Modal Function scoped to the class. | 🔥 (wherever @cls is used) |
✅ | is_generator= kwarg not specifically recognized. |
— |
@modal.batched(max_batch_size=, wait_ms=) |
Dynamic input batching; all inputs/outputs must be equal-length lists; at most one batched method per class. | 🧊 (2/212 files in modal-examples; not found in independent-repo sample) | 🟨 recognized, not modeled — detected via trailing decorator name (same pattern as _SERVE_DECOS), tagged with a distinct modal.batched leak in _describe_fn; the function/method still runs, just without Modal's request-coalescing behavior. |
Real batching execution (coalescing N concurrent calls into one list-valued call) is not reproduced — out of scope per this construct's rare (🧊) frequency. | #91 |
@modal.concurrent(max_inputs=, target_inputs=None) |
Replaces the deprecated allow_concurrent_inputs=N kwarg (v0.73.148) — now a separate decorator, not a function kwarg. Sync functions get separate OS threads (must be thread-safe); async get coroutines on one thread. |
🟡 (a common tuning pattern in production) | ✅ (fixed 2026-08-07, calque#82) _describe_fn already captured every decorator's kwargs on a plain function, so max_inputs/target_inputs just needed adding to autoscalingKwargs. The class-level case needed a real fix: visit_ClassDef only read @app.cls's OWN kwargs — a separate @modal.concurrent(...) stacked on the same class was invisible; now merged into cls_kwargs. |
— | closed |
@app.batched/max_batch_size= |
See @modal.batched above (same construct, different framing in the original audit). |
🧊 | 🟨 see @modal.batched above — same trailing-name match covers this spelling too. |
— | dup of above |
Web decorators: @modal.fastapi_endpoint (renamed from @modal.web_endpoint, v0.73.89), @modal.asgi_app(), @modal.wsgi_app(), @modal.web_server(port) |
Long-lived, request-driven, no fixed N, autoscaling-driven termination — fundamentally different execution model from batch .map(). |
🟡 (a first-class use case — own top-level directory in modal-examples, ~19% of files) | 🟨 detected via _SERVE_DECOS (matches both old and new decorator names by trailing attribute), sets entry_kind: "serve"; run.go refuses gracefully with a leak, the long-lived server is never built (by design — see docs/serve-architecture.md). |
Working as intended per the project's own documented scope decision. | — |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
gpu= (single string, e.g. "H100") |
Card selection. | 🔥 | ✅ drives the actual clean-swap/flag-multi/flag-couple decision. | — | — |
gpu= card substitution (calque real --allow-card-swap) |
— (calque-side capability, no Modal equivalent) | 🧊 (curated, one entry at a time) | ✅ (calque#178) an explicit opt-in flag substitutes a CleanSwap site's asked-for card for a VERIFIED cheaper alternative from internal/target.CardSwapFor's curated table — currently one entry, A100-80GB → RTX PRO 6000 (AWS has no single-GPU A100-80GB instance at all; p4de.24xlarge is an 8-GPU box). Verified on real g7e.2xlarge spot hardware running earth2studio's AIFS model end-to-end (real weight load, real live GFS data, real inference rollout — reported SUCCESS) for AI-Almanac's forecasts_app.py. Off by default; the asked-for card always passes through unchanged unless the flag is set AND a verified entry exists — no cost-search, no guessing. |
Each table entry is narrow and per-workload-class; absence means "not yet verified," never "assume it's fine." | closed |
gpu= newer type strings: L40S, H200, B200, B300 (space-separated forms) |
Card types added since calque's gpu.go was written. |
🟡 (growing as newer hardware ships) | ✅ verified 2026-08-07 (calque#85) — internal/gpu/gpu.go's card field is an opaque string by design (no local validation), and truffle's find.ResolveCard already resolves all four to correct instance families (H200→p5e, L40S→g6e, B200→p6-b200/p6e-gb200, B300→p6-b300) — confirmed live, no gap here. |
— | closed (no fix needed) |
gpu= hyphenated/suffixed spec strings: RTX-PRO-6000, RTX-PRO-4500, A100-80GB, H100!, B200+ |
Modal's own documented spec-string convention uses hyphens for multi-word names and !/+ suffixes for upgrade-pin/opt-in behavior. |
🟡 | ❌ upstream truffle bug, not calque's. Confirmed live: find.ResolveCard("RTX-PRO-6000") and ("A100-80GB") both fail to resolve (resolved to no GPU) even though the space-separated forms ("RTX PRO 6000", "A100 80GB") work — truffle's tokenizer splits on whitespace only, so a hyphenated multi-word card arrives as one unmatched token. Notably affects calque's OWN default target card: calque's StubRecommender defaults to RTX PRO 6000, but a real script spelling it Modal's actual way (gpu="RTX-PRO-6000") would fail to resolve. |
Filed upstream: truffle#130. | tracked upstream |
gpu="L40" (distinct from L40S) |
L40 (plain) and L40S are physically distinct NVIDIA chips — AWS's g6 (L40) vs g6e (L40S) instance families reflect the distinction directly, not a naming variant. |
⚪ | ❌ upstream truffle bug, not calque's. Confirmed live: find.ResolveCard("L40") incorrectly resolves to g6e.* (L40S's family) via an alias ("l40": "l40s") — a caller asking for the cheaper/different L40 chip silently gets routed to L40S instead, with no signal anything was substituted. |
Filed upstream: truffle#129. | tracked upstream |
gpu="H100:8" (multi-GPU) |
>1 card, same physical machine (NVLink-class). | ⚪ | 🛑 FlagMulti → refuses (by design, §7 guard). |
— | — |
gpu=["H100", "A100-40GB:2"] (fallback-list syntax) |
Modal tries types in list order. | 🧊 | ✅ (fixed 2026-08-07, calque#85) readConfigKwargs now decodes the list, takes the first (highest-preference) entry as gpu=, and leaks that the try-in-order-until-available semantic isn't reproduced (no live availability probe at parse time) — instead of the generic "not a plain string literal" message. |
calque picks statically; it doesn't probe live availability the way Modal's real fallback does. | closed |
cpu= (plain number or (request, limit) tuple) |
Physical cores; tuple limit is a throttle, not OOM-kill. | 🔥 | ✅ (fixed 2026-08-07, calque#77 — tuple form now leaks the dropped limit, mirroring memory=) |
Recorded but not used for instance sizing (deliberate, behind the seam). | closed |
memory= (plain int MB or tuple) |
MiB; tuple limit is a hard OOM-kill ceiling (different failure mode than CPU's throttle). | 🔥 | ✅ recorded+leaked correctly. | Same sizing-deferred caveat as cpu=. |
— |
retries= (plain int or modal.Retries(...)) |
Per-input retry cap; plain int = fixed delay, object = exponential backoff. | ⚪ | ✅ (plain int) wired into the warm supervisor's crash-restart cap — a genuine reliability knob that's honored. Retries(...) object form: recognized+leaked (falls back to default cap). |
Exponential-backoff semantics not reproduced even when leaked — acceptable per behind-the-seam scope. | — |
secrets= |
List of Secret objects, injected as env vars in list order (later overrides earlier on key clash). |
🔥 | 🟨 declared names recorded, NOT resolved from Modal's own secret store — see §K for the --secret NAME=VALUE escape hatch (calque#150) that lets a caller supply the same values directly. |
See §K. | — |
schedule= (bare cron string) |
— | ⚪ | 🟨 recorded, not honored, leaked. | See §H — the object forms (modal.Cron/modal.Period) are ALSO now recognized structurally (calque#91), not just the bare string. |
#91 |
region= / cloud= |
Placement hints. | ⚪ | 🟨 both recorded+leaked (cloud= fixed 2026-08-07, calque#91 — mirrors region='s pattern exactly: ir.Config.Cloud, dedicated "recorded but NOT honored" leak). |
calque always targets AWS regardless of cloud='s value — recorded for visibility, not acted on (a script requesting GCP/OCI isn't rejected, just silently run against AWS anyway, same posture as every other portable-but-unhonored kwarg). |
closed |
Autoscaling kwargs, old spellings: concurrency_limit, allow_concurrent_inputs, min_containers, max_containers, keep_warm, container_idle_timeout |
Warm-pool/scaling config. | 🟡 | 🟨 explicit named set (autoscalingKwargs in internal/parse/parse.go), each gets a dedicated "behind the seam" leak. |
— | — |
Autoscaling kwargs, current spellings: min_containers (was keep_warm), max_containers (was concurrency_limit), scaledown_window (was container_idle_timeout), buffer_containers |
Same knobs, renamed at Modal 1.0 (v0.73.76). | 🟡 (real scripts will use EITHER era depending on when they were written) | ✅ (fixed 2026-08-07, calque#82) scaledown_window/buffer_containers added to autoscalingKwargs; min_containers/max_containers were already present from an earlier pass. |
— | closed |
image=<var> |
Per-function image override. | 🔥 | ✅ resolved per-callable (calque#174), not app-wide. A direct x = modal.Image.... chain resolves as-is; a trivial zero-arg factory function (no branching, one unconditional return — e.g. AI-Almanac's blending_app.py's _image()) is inlined and resolved too (calque#175). A specific parameterized-factory-in-a-for-loop shape also resolves now (calque#179): IMAGES = {k: factory(v) for k, v in D.items()} followed by for k, v in IMAGES.items(): @app.function(name=f"fn_{k}", image=v) def fn(...): ... — the exact idiom AI-Almanac's forecasts_app.py uses for its per-environment earth2studio images — expands into one real ir.Function per (key, value) pair, with the factory's own local sep.join(list)-into-f-string substitution folded correctly per iteration. A factory that branches, or is called/parameterized in any OTHER shape (not this specific for-loop-over-dict-comprehension pattern), still can't be resolved and is loudly leaked instead of silently substituting the wrong image (calque#76/#179's own documented non-goals). |
Branching factories, and parameterized factories outside the specific for-loop/dict-comprehension shape calque#179 models, remain an honest gap, not a silent one. | closed |
Non-literal / **kwargs splat on any decorator |
— | 🧊 | 🟨 generic fallback leak — the safety net; nothing silently dropped at this layer. | — | — |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
Image.debian_slim() |
Default base. | 🔥 | ✅ | — | — |
Image.from_registry(...) |
Pull an existing image; linux/amd64 required. |
🟡 | ✅ resolves at parse time (dry-run Dockerfile FROM line); a --script real run against the picked unit actually docker pulls this exact ref when it's a bare reference with nothing layered on top (calque#176) — ECR hostnames authenticate via aws ecr get-login-password, other registries (e.g. AI-Almanac's app.py's real GCP Artifact Registry ROMP_IMAGE_URI default) pull anonymously. When the chain has steps LAYERED on top of this base (.pip_install(...)/.add_local_file(...) etc. — e.g. app.py's benchmark_image, or a from-scratch debian_slim()... chain with no pullable base at all, e.g. blending_app.py's _image()), calque instead renders the Dockerfile (internal/image.Render, the same renderer --dry-run already uses) and runs docker build ON THE ACQUIRED INSTANCE itself before docker run (calque#177) — no ECR round-trip, no ambient Docker requirement on the caller's machine, no second/throwaway instance. |
add_local_*/from_dockerfile steps within the built chain that calque can't stage into the build context are still leaked separately (unchanged, see internal/image's own per-step leaks). |
#176/#177 closed |
Image.from_dockerfile(...) |
Direct Dockerfile ingestion. | ⚪ | 🟨 (fixed 2026-08-07, calque#84) resolveBase now names the specific unstaged local path in the leak (e.g. from_dockerfile("./Dockerfile.custom"): calque does not read/stage this local Dockerfile) instead of the generic "unknown image base" message. Still defaults to CUDA — calque can't read/inline an arbitrary local Dockerfile's content, same limitation as add_local_*. |
Leak is now specific and actionable; the underlying default-base substitution is unavoidable without local-file staging (a separate, bigger capability). | closed |
Image.from_aws_ecr(...) |
Pull from ECR; secret= carries IAM/OIDC. |
⚪ | ✅ resolves at parse time + emits an integration_edge leak noting IAM pull-permission needs; a --script real run pulls this ref for real too, same authenticated pull-or-build path as from_registry above (calque#176/#177). |
Same as from_registry above. |
#176/#177 closed |
Image.from_gcp_artifact_registry(...) |
GCP equivalent. | 🧊 | ⬜ | — | not yet filed (low priority) |
Image.micromamba() |
Conda-alternative base. | 🧊 | ✅ (fixed 2026-08-07, calque#84) resolves to mambaorg/micromamba:latest (the closest stock equivalent) instead of silently defaulting to CUDA/debian, with a leak noting kwargs like python_version= aren't captured by _walk_image_chain's positional-only arg collection. |
No GPU/CUDA variant of this base exists — a GPU payload built on it needs its own CUDA install, same limitation Modal's own micromamba() base has. |
closed |
.pip_install(...) / .uv_pip_install(...) |
Package install layers. | 🔥 | ✅ | — | — |
.pip_install_from_requirements(...) / .poetry_install_from_file(...) |
File-based install. | ⚪ | ✅ (with a leaked caveat: calque doesn't stage the local file into the build context). | — | — |
.apt_install(...) / .run_commands(...) / .dockerfile_commands(...) / .env(...) / .workdir(...) / .entrypoint(...) |
Standard Dockerfile-equivalent verbs. | 🔥 | ✅ | — | — |
.add_local_dir/.add_local_file/.add_local_python_source(...) (renamed from .copy_local_dir/.copy_local_file/Mount.from_local_python_packages, v0.66.40-v0.67.28) |
Ship local source into the image. | 🔥 | 🟨 renders a COPY line but leaks that calque doesn't stage the local path — build fails unless the caller stages it themselves. |
Old names (copy_local_dir etc.) aren't in calque's _IMAGE_STEPS set at all — a pre-1.0 script using them silently fails the "is this an image chain" heuristic if that's the only step present. |
not yet filed (verify old-name coverage) |
modal.Mount (fully removed at Modal 1.0; mount=/context_mount=/copy_mount all gone) |
— | 🧊 (legacy only) | ⬜ | Scripts predating 1.0 may still use this — not worth building for, but worth a dedicated "this construct was removed upstream" leak if seen, rather than a generic one. | not yet filed (low priority) |
.run_function(fn) |
Runs arbitrary Python at build time on a full remote worker (GPU/volumes/secrets available). | 🧊 | 🟨 explicitly not reproduced — leaked, no Dockerfile line emitted. | Correct given no direct AWS-build-time equivalent. | — |
Unknown/other .method(...) step |
— | 🧊 | 🟨 catch-all: Dockerfile comment + leak. | — | — |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
modal.Volume.from_name(...) + volumes={mount: vol} |
Not a live shared filesystem — snapshot-at-container-start, explicit .commit()/.reload() for cross-container visibility, last-write-wins on concurrent same-file writes (documented, expected data loss). |
🔥 | ✅ maps to a deterministic S3 prefix, real delta-sync before @enter, real end-of-run commit write-back. |
calque's model (sync-before-run, commit-after-run) matches Modal's snapshot-at-start semantics reasonably well for the common case; mid-run .reload() (re-sync during execution) is correctly leaked as unreproduced. |
— |
.commit() / .reload() call sites |
End-of-run persistence / mid-run re-read. | 🔥 (wherever Volumes are used) | ✅ .commit() honored as real end-of-run write-back. 🟨 .reload() leaked as unreproduced. |
— | — |
modal.NetworkFileSystem (deprecated, being removed) |
Live-shared filesystem — no commit/reload cycle, closer to EFS/NFS than Volume's snapshot model. | 🧊 (deprecated, Modal steers users to Volume) | ✅ (calque#91 Workstream B) a real NetworkFileSystem.from_name(name) used as a network_file_systems={mount: nfs} value (a SEPARATE decorator kwarg from volumes=, never nested inside it) resolves to a real EFS-over-NFS mount, bring-your-own: tools/pyast/pyast.py's _network_file_systems_map/self.network_file_systems track the binding structurally (mirroring Volume.from_name's own zero-leak-on-binding posture), internal/parse/parse.go decodes them into ir.Function/ir.Class.NetworkFileSystems, and internal/plan/efs.go's DiscoverEFSFilesystem finds the pre-provisioned EFS filesystem by matching a calque:nfs-name=<name> tag (calque NEVER auto-creates an EFS filesystem — create_if_missing=True is a distinct leak, not a blocker), ResolveMountTargetsForAZs narrows the acquirer's AZ sweep to AZs with live mount-target coverage (a hard error, not a leak, if narrowing empties it), EnsureNFSSecurityGroup creates/finds a self-referential NFS/2049-ingress security group attached to the launched instance via spawnaws.LaunchConfig.SecurityGroupIDs, and NFSMountCommands renders the on-instance mount -t nfs4 invocation (spliced into the bootstrap script before @enter runs, via internal/exec.BootstrapConfig.NFSMountLines). IAM (elasticfilesystem:ClientMount/ClientWrite) is explicitly OUT of scope for this pass — the EFS filesystem's own access policy/security-group pairing is the operator's responsibility. |
No auto-create (bring-your-own only, matching Modal's own create_if_missing=False default). No IAM grant — a filesystem-level access policy that requires ClientMount/ClientWrite must already permit the instance's role/network path. |
#91 (Workstream B closed) |
modal.CloudBucketMount |
Direct S3/R2/GCS mount via mountpoint-s3 — no append writes, no seek+write, must open in truncate mode, no rename. |
🧊 | ✅ (calque#91 Workstream A) a real CloudBucketMount(bucket_name, key_prefix=, read_only=) used INLINE as a volumes= value (the real Modal idiom — constructed directly in the dict, not assigned to a variable first) resolves to a real mountpoint-s3 mount against the SCRIPT'S OWN S3 bucket: tools/pyast/pyast.py's _cloud_bucket_mount extracts bucket_name/key_prefix/read_only, internal/parse/parse.go decodes them into ir.Function/ir.Class.CloudBucketMounts, and internal/plan/cloudbucketmount.go's MountCommands renders the on-instance mount-s3 invocation (spliced into the bootstrap script before @enter runs, via internal/exec.BootstrapConfig.CloudBucketMountLines); internal/plan.RealRunPolicy's extraBuckets param grants the instance role read/write/list on that bucket, separate from calque's own --bucket staging area. secret= is recognized but NOT honored (the instance's own IAM role is used instead) — leaked distinctly. bucket_endpoint_url=/requester_pays=/force_path_style= are NOT supported — leaked distinctly; mounting is against AWS S3 with default settings only. A bucket_name that isn't a string literal still falls back to the pre-existing "recognized but not modeled" leak. |
R2/GCS-backed CloudBucketMounts (bucket_endpoint_url=) are NOT reproduced — AWS S3 only. No live-Modal-managed credential rotation via secret=; the instance's own IAM role is the only credential path. |
#91 (Workstream A closed; see the row below for Workstream B, modal.NetworkFileSystem) |
modal.Dict |
Distributed KV store, cloudpickle values, 7-day inactivity TTL, capped .len() at 100,000. |
🧊 | ⬜ not modeled, but #151 closed the failure mode: a bare reference to a module-level Dict.from_name(...) constant used to ship verbatim and crash at runtime with a confusing Modal SDK auth error — it's now refused with a clear leak naming the construct instead. |
— | #91 |
modal.Queue |
FIFO per-partition only, 24h partition auto-expiry. | 🧊 | ⬜ not modeled; same #151 honest-refusal fix applies to a bare reference to a Queue.from_name(...) constant. |
— | #91 |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
.map(iterable, order_outputs=True, return_exceptions=False) |
order_outputs=True by default — results returned in input order, not completion order (Modal buffers internally). Hard cap 1000 concurrent inputs/call. |
⚪ (real minority even among .map() users vs. plain-function .map(), but still calque's core supported shape) |
✅ highest-precedence idiom, drives pickWarmUnit selection and the actual warm-runner execution. |
calque's own spec (§10) already flags the ordering-at-scale question as a leak to watch — this confirms it's a real, documented Modal contract to replicate, not a hypothetical concern. | — |
.starmap(iterable_of_tuples) |
Same as .map but tuple-splat args. |
🧊 | ✅ (fixed 2026-08-09, calque#93) the warm runner now binds ALL of the callable's positional params and splats each item's tuple (fn(self.state, *payload)) whenever the real iterable was statically resolved at parse time (calque#136 — a literal list of tuples or a range()). checkInvokeSupport (cmd/calque/run.go) only refuses a .starmap'd unit when NO real tuple data exists at all (an unresolvable iterable — variable/comprehension/non-range call), since that's the one case with nothing safe to splat. |
A .starmap'd unit whose real iterable is unresolvable (or shorter than --n) still needs a splat-compatible fallback — realOrSyntheticItems' fallback synth closure is tuple-shaped for starmap units specifically (cmd/calque/run.go's starmapAwareSynth), not the single-string placeholder every other idiom uses. |
closed |
.for_each(iterable, ignore_exceptions=False) |
Side-effect-only, no result collection, but still blocks until all complete. | 🧊 | ✅ (fixed 2026-08-07, calque#83) shares .map's single-arg signature, so it runs correctly through the warm unit — the only mismatch (Modal discards the result, calque collects it) is now leaked explicitly rather than silently unhonored. |
Milder than .starmap: nothing crashes, so this is a leak, not a refusal. |
closed |
.remote(*args, **kwargs) |
Single blocking call. | 🔥 | ✅ shares .map's exact single-arg, collect-a-result signature — no actual execution mismatch exists (confirmed while fixing calque#83; the original framing of this as a gap was incorrect). calque already drives N synthetic items regardless of how many times the script itself calls .remote(). |
— | closed (no fix needed) |
.local(*args, **kwargs) |
Runs in the CALLER's own process/container — no new container, only locally-available resources apply. | 🧊 (rare — mostly same-class intra-container calls or entrypoint-local testing, NOT general pipeline chaining) | ✅ (fixed 2026-08-08, calque#92) a .local()-referenced plain @app.function sibling is now resolved transitively and SHIPPED alongside the picked warm unit's body (not just leaked) — both helper(x) and helper.local(x) call-site styles work unmodified. A .local() call resolving to a @cls method is deliberately left unsupported (would need its own warm @enter state) and still leaks honestly instead of NameError-ing silently. Verified against a fixture mirroring blending_app.py's real chaining shape. |
Real-AWS execution paths (real/session/fleetrun) still drive hardcoded reference bodies rather than an arbitrary parsed script — this fix applies to --dry-run only so far. |
#81 (recognition) + #92 (shipping, both closed) |
.spawn(*args, **kwargs) → FunctionCall handle |
Non-blocking; FunctionCall.object_id is a persistable string, reconstructable via .from_id() from a different process; results retrievable for 7 days post-completion. |
⚪ (~7% of files; common at web/bot/CLI boundaries — "trigger and poll") | 🟨 (fixed 2026-08-08, calque#97) the block-and-wait fan-out driver now exists (cmd/calque/spawnrun.go) and is live-verified on real AWS: every .spawn()'d callable found via ir.App.FindFunction/FindClass gets its own shard (own EnterBody/MethodBody), acquired and run in parallel, collected via a string-keyed collector, with one re-drive on failure. |
calque still doesn't reproduce Modal's real decoupled contract (persistable handle, 7-day retention, cross-process .get()) — confirmed as explicitly out of scope, matching §18. The driver is block-and-wait fan-out only, by design. |
#88 (classification) + #97 (driver, both closed) |
.spawn_map(*input_iterators) |
Fire-all without waiting; even Modal itself has no clean in-SDK result-collection API for this yet. | 🧊 | ⬜ | — | not yet filed (low priority — even upstream is unfinished here) |
Function.from_name(...) / Cls.from_name(...) (cross-app invocation) |
Look up an already-deployed Function/Cls by name from a separate app/process. | ⚪ in curated examples (~5%) but structurally essential in real external-consumer production code — anything outside the defining app must use this. | 🟨 (fixed 2026-08-07, calque#87) recognized and leaked distinctly, naming the looked-up app/object when the args are plain string literals (e.g. Function.from_name("almanac-blending", "score_live_forecast_bundle")) — verified against the real forecasts_app.py call site. Carefully guarded to Function/Cls specifically so Volume.from_name/Secret.from_name (unrelated constructs sharing the same method name) aren't misclassified as cross-app invocation — confirmed this guard was NEEDED via a live false-positive against the same real script before narrowing it. |
Recognition-only; not currently supported (calque#137): calque runs exactly ONE script's ir.App per invocation and has no path to locate/parse a separately-deployed app's source — unlike .spawn() (calque#97), whose targets live inside the already-parsed ir.App. Actually executing this would need a real design pass (a deployment-registry concept), not a quick fix. See docs/behind-the-seam-register.md. |
#87 closed (recognition); #137 closed (orchestration decided not currently supported) |
.map.aio(...) / other .aio async variants |
Coroutine variant of any blocking method. | ⚪ | 🟨 .map.aio/.starmap.aio detected, leaked as deferred (same bucket as .spawn). Other .aio variants (.remote.aio, .get.aio, etc.) — not specifically detected. |
— | not yet filed (low priority, narrow) |
| Construct | calque behavior today | Frequency of the underlying real shape | Risk / gap |
|---|---|---|---|
@cls+@enter+.map()'d method |
The shape pickWarmUnit's automatic scan (cmd/calque/run.go) prefers without a fallback heuristic. --function NAME (calque#150) bypasses this scan entirely to select ANY specific callable directly, when the automatic preference isn't what you want. |
⚪ minority (~5-10% of real scripts) | Working as designed, but the automatic-scan design targets a minority shape — --function exists precisely because real scripts routinely need a different one selected. |
@cls+@enter, no .map()'d method |
Falls back to "first method" — runnable, but an arbitrary pick if there's real ambiguity; no leak for this specific fallback. | 🟡 common | Acceptable for now; could use a leak noting the fallback was used. |
@cls, no @enter |
Skipped entirely as a warm-unit candidate; separately leaked ("@cls has no @enter"). | 🧊 | Correct — a class with no warm-load-once body genuinely doesn't fit the model. |
Plain @app.function, no @cls anywhere |
✅ (closed, calque#80) pickWarmUnit selects the .map()'d function if any, else the first, wrapping it in a synthesized zero-value ir.Class so dryRunWarm's existing unit.class.* reads need no changes. Also fixed swapLegal to accept gpu.NoGPU (a plain CPU function) — it previously treated "no gpu= declared" as an illegal swap, identical to a flagged multi-GPU/coupled one, invisible until a GPU-free plain function became reachable. |
🔥 the most common real shape, and the one that was blocking every AI-Almanac script | Verified against testdata/scripts/plain_function.py and a fresh clone of all three real AI-Almanac scripts — all now run past warm-unit selection. |
| Serve-shaped app, no batch warm unit | Detected, leaked as deferred, Bedrock route-away still runs, returns cleanly (no error). | 🟡 | Working as designed — documented non-goal (docs/serve-architecture.md). |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
schedule= bare string kwarg |
— | ⚪ | 🟨 recorded, leaked as unhonored (no scheduler in the spike). | — | — |
modal.Cron(cron_string, timezone=None) |
Standard 5-field cron; requires the app to be deployed to activate (no effect on ephemeral runs). | ⚪ (~5% of files; when present, OFTEN the entire app — e.g. earth-mover/forecast-datacube-demo uses only plain functions + Cron) | 🟨 (fixed calque#91) recognized structurally via pyast's _schedule_marker (the object-call form is matched by trailing attribute name, Cron/Period, rather than falling through to a generic literal-eval) and decoded by decodeScheduleMarker — the cron string (first positional arg) becomes ir.Config.Schedule verbatim, timezone= is discarded. Recorded, not honored (no scheduler in the spike) — same "recorded but NOT honored" leak as the bare-string schedule= case above, plus a suffix noting it was recognized from the object form. |
Same behavior gap as the bare-string case: no scheduler exists in the spike, so nothing actually fires on a cron cadence. | #91 (recognition landed; execution remains out of scope) |
modal.Period(days=, hours=, ...) |
Fixed-interval, deployment-anchored, not wall-clock-anchored — resets on every redeploy. | 🧊 | 🟨 (fixed calque#91) same recognition path as modal.Cron above — days=/hours=/minutes=/seconds= kwargs are summed (Modal itself combines any subset) into one normalized <n>d<n>h<n>m<n>s string, recorded but NOT honored. |
Same as modal.Cron above. |
#91 (recognition landed; execution remains out of scope) |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
@modal.fastapi_endpoint / @modal.asgi_app / @modal.wsgi_app / @modal.web_server |
Long-lived, request-driven, autoscaling-terminated — see §B. | 🟡 | 🟨 detected, deferred by design. | — | — |
modal.forward(port, unencrypted=False) (Tunnels) |
Exposes a live container TCP port publicly. | 🧊 | ⬜ | — | not yet filed (low priority) |
modal.Proxy |
Static outbound IP; must be provisioned via Dashboard first, referenced via .from_name. |
🧊 | ⬜ | — | not yet filed (low priority) |
cloud= / region= / routing_region= |
See §C. | ⚪ | 🟨/⬜ partial — see §C row. | — | see §C |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
modal.Sandbox.create(...) / .exec() / .terminate() |
Fundamentally different execution model: a long-lived, explicitly-managed container (create once, exec() repeatedly), no autoscaling/warm-pool abstraction at all — closer to asyncio.subprocess.Process than to a Function pool. |
🟡 and growing — concentrated in modal-examples but disproportionately common in independent agent/code-execution products (Anthropic, OpenAI, LangChain, PostHog, HF smolagents all use it) | ⬜ Not present at all. | Flagged as a real, growing gap — but deliberately NOT attempted in this pass. It's a different execution model from everything calque does today (batch-warm-unit or request-driven-serve); needs its own design pass, not a bolt-on. | #89 |
| Construct | Modal semantics | Frequency | calque status | Behavior difference / risk | Tracking |
|---|---|---|---|---|---|
secrets=[Secret.from_name(...), ...] |
List of Secret objects, injected as env vars, list-order precedence. | 🔥 | 🟨 declared names recorded (see §C), NOT resolved from Modal's own secret store (calque has no live Modal control-plane connection) — but calque real --secret NAME=VALUE (repeatable, calque#150) lets the caller supply the same env-var VALUES directly, so the payload's own os.environ["NAME"] reads stay unchanged. realrun.go leaks which declared names weren't covered by any --secret flag. |
A generic escape hatch, not automatic secret-store resolution — the caller must still know/provide the real values. See docs/porting-modal-to-aws.md §4. |
— |
Secret.from_dict(...) / Secret.from_dotenv(...) / Secret.from_local_environ(...) |
Alternate construction forms. | ⚪ | ⬜ (not distinguished from the generic secrets= case — all become {"__unparsed__": ...} markers at the AST layer) |
Low risk — --secret covers the resulting need (an env var the payload reads) identically regardless of construction form. |
— |
| Construct | Modal semantics | Frequency | calque status |
|---|---|---|---|
@modal.experimental.clustered(size=N, rdma=False) |
Gang-scheduled multi-node; whole-cluster restart on any single-node preemption. | 🧊 (Beta) | ✅ #152 closed: this decorator is now recognized directly (ir.Function.IsClustered) and unconditionally forces FlagCouple in the §7 guard, regardless of the per-node gpu= count. Previously a real, confirmed gap: since the decorator carries no gpu= string and no body-text coupling token itself, a real script with a LITERAL single-GPU gpu="A100" stacked under @modal.experimental.clustered(...) (the shape found in tokenbender/avataRL's modal_train.py) silently passed the guard as CleanSwap — exactly the silent-downgrade class §7 exists to prevent. Still explicitly out of scope to actually RUN multi-node (§1) — this closes the detection gap, not the execution gap. |
Modal's modal CLI has a core local-dev triad (run/deploy/serve) plus
shell for interactive debugging, and a long tail of remote resource
management (secret/volume/nfs/environment) and observability
(app logs/container exec) commands that are about administering Modal's
own control plane, not about running code — out of scope for a tool whose job
is porting code, not managing a Modal workspace.
| Modal command | Purpose | calque equivalent | Gap |
|---|---|---|---|
modal run <file>[::entrypoint] [args] |
Ephemeral run; ::entrypoint selects which @app.local_entrypoint() to invoke when a file has several; passes through arbitrary CLI args to it. |
calque run [--n N] [--region R] [--dry-run] [--entrypoint NAME] <script.py>; calque real/calque ramp add --function NAME (calque#150) |
✅ (calque#90) --entrypoint <name> validates against app.Entrypoints, auto-selects when there's exactly one, and requires explicit selection when 2+ exist (mirroring Modal's own "ambiguous, pick one" posture). ✅ (calque#98, closed) --entrypoint now steers which callable pickWarmUnit selects, via call-site-to-entrypoint attribution. ✅ (calque#150) --function NAME selects a specific @app.function/@cls method directly by name, for a target unreachable through ANY entrypoint (e.g. one entrypoint invokes a different sibling than the one you want) — wins over --entrypoint when both are given. Passing through arbitrary positional CLI args (Modal's [args]) is still not reproduced — a script's real args must go through --item-file/--arg-file/--arg-json instead, a different (bytes/JSON-typed) mechanism, not a generic argv passthrough. |
modal deploy <file> |
Publishes a persistent app (survives disconnect); --strategy rolling|recreate. |
none | Legitimate scope difference — AWS has no equivalent to Modal's redeploy-in-place model; calque's execution is closer to always-ephemeral. Not a gap to close, just a documented difference. |
modal serve <file> |
Hot-reload dev server for web endpoints. | none | Follows from calque not building the long-lived server at all (documented non-goal, docs/serve-architecture.md) — consistent, not a new gap. |
modal shell [ref] |
Interactive shell inside a container matching a function's image/mounts/volumes, or attaching to a live sandbox. | none | No calque equivalent for interactively debugging a port — worth considering once basic execution-shape gaps (backlog #1-#7) are closed, since debugging-the-port is exactly what an adopter mid-migration needs. |
modal secret/volume/nfs/environment/app/container/profile/config/token |
Remote resource management, observability, auth. | none | Out of scope by design — these manage Modal's own control plane; calque's job is running code, not administering a Modal workspace. |
Real scripts in the wild will contain either era of spelling depending on when they were written. calque should recognize both, routing both to the same dedicated leak/behavior rather than letting the newer spelling fall through to a generic "unmodeled arg" message.
| Old name | Current name | Introduced | calque recognizes old? | calque recognizes new? |
|---|---|---|---|---|
keep_warm |
min_containers |
v0.73.76 | ✅ | ❌ |
concurrency_limit |
max_containers |
v0.73.76 | ✅ | ❌ |
container_idle_timeout |
scaledown_window |
v0.73.76 | ✅ | ❌ |
_experimental_buffer_containers |
buffer_containers |
— | ❌ | ❌ |
allow_concurrent_inputs=N (kwarg) |
@modal.concurrent(max_inputs=N) (decorator) |
v0.73.148 | ❌ | ❌ |
max_inputs (old: cap before recycle) |
single_use_containers=True |
— | ❌ | ❌ |
modal.gpu.H100() (object API) |
gpu="H100" (string API) |
v0.73.31 | n/a — calque only ever supported the string form | — |
.lookup() |
.from_name() |
v0.72.56 | n/a — calque doesn't call this API itself | — |
.resolve() |
.hydrate() |
v0.72.39 | n/a | — |
modal.web_endpoint |
modal.fastapi_endpoint |
v0.73.89 | ✅ (matched by trailing decorator name, catches both) | ✅ |
Image.copy_local_dir/copy_local_file |
Image.add_local_dir/add_local_file |
v0.66.40 | ❌ (not in _IMAGE_STEPS) |
✅ |
Mount.from_local_python_packages |
Image.add_local_python_source |
v0.67.28 | n/a (Mount never supported) | ✅ |
modal.Mount / mount= / context_mount= / Image.copy_mount |
add_local_* + auto context inference |
removed at v1.0 | ⬜ never supported | — |
@modal.build |
modal.Volume or Image.run_function |
v0.72.17 | n/a | ✅ (run_function supported, leaked) |
Custom Cls.__init__ |
modal.parameter() + @modal.enter |
v0.74.0 | ⬜ modal.parameter() not recognized at all |
— |
modal.Stub |
modal.App |
hard error since v1.0 | n/a — scripts using this are already broken upstream | — |
Plain— closed the actual blocker in calque#79. #80 (closed 2026-08-07)@app.functionas a runnable warm unit— closed. #81 (closed 2026-08-07); full body-inlining tracked separately as #92..local()recognitionNewer autoscaling-kwarg spellings +— closed. #82 (closed 2026-08-07)@modal.concurrent— closed. #83 (closed 2026-08-07, safe refusal); full tuple-splat execution closed in #93 (closed 2026-08-09), built on calque#136's real.starmap/.for_each/.remoteexecution parity.map()/.starmap()iterable extraction.— closed. #84 (closed 2026-08-07)Image.micromamba()/from_dockerfile()base-resolution bugGPU spec string + fallback-list coverage— closed. #85 (closed 2026-08-07).gpu=[...]list syntax fixed in calque; space-separated newer type strings (L40S/H200/B200/B300) already worked via truffle. Found two real upstream truffle bugs along the way (hyphenated/suffixed spec strings failing entirely;L40/L40Sconflation) — filed as truffle#129 and truffle#130.— closed. #86 (closed 2026-08-07)@modal.exit()recognition— recognition closed. #87 (closed 2026-08-07). Actually orchestrating a call into a separately- deployed app is now a decided PERMANENT non-goal, not an open design gap — #137 (closed 2026-08-09); see docs/behind-the-seam-register.md.Function.from_name/Cls.from_namecross-app invocation— closed. #88 (closed 2026-08-07). The actual fan-out driver, keyed by callable identity rather than item index, shipped as.spawn()+.get()classificationcalque spawn-run(cmd/calque/spawnrun.go) and is closed and live-verified on real AWS — #97 (closed).modal.Sandbox— tracked, explicitly deferred; different execution model entirely. #89— closed (validate+inform scope). #90 (closed 2026-08-07).calque run --entrypoint <name>--entrypointnow DOES steer which callablepickWarmUnitselects — call-site-to-entrypoint attribution shipped and is live-verified end-to-end — #98 (closed).- Lower-priority/rare, still open:
modal.Dict/Queue,@modal.batched,App.include/.deploy/.runlifecycle nuances. (cloud=closed separately, calque#91's own §C fix;modal.Cron/Periodobject-form recognition also closed under calque#91 — see §H — though actually executing on a schedule remains out of scope.modal.CloudBucketMountis now REAL — see §E — closed as calque#91 Workstream A;modal.NetworkFileSystemis now ALSO REAL — see §E — closed as calque#91 Workstream B (bring-your-own EFS-over-NFS, no auto-create). Both concrete real-AWS-mapping workstreams calque#91 was tracking are now done;modal.Dict/Queue/App.includeremain deliberately leak-only/out of scope.) #91
Not individually filed (genuinely low-priority/narrow; revisit if real usage
surfaces): @app.server, App.include-equivalent lifecycle nuances beyond
what #91 covers, Image.from_gcp_artifact_registry, old-name coverage for
.copy_local_dir/.copy_local_file, modal.Mount (removed upstream too),
.spawn_map (unfinished even in Modal itself), other .aio variants beyond
.map.aio/.starmap.aio, modal.forward/modal.Proxy, modal shell-
equivalent interactive debugging.