You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Snakemake orchestration for the real-data pipeline
Summary
Replace the shell-script orchestration of real-data ShapePipe runs (curl_canfar_local.sh → run_job_sp_canfar_v2.0.bash → job_sp_canfar_v2.0.bash, plus per-site sbatch reimplementations like the nibi P3 scripts) with a Snakemake workflow living in this repo. First pass touches no module code: rules call shapepipe_run -c <config> on the existing config chains, at the same granularities the bash layer uses today. What changes is who tracks state: instead of hand-written file-count thresholds, done-log files, and flock-guarded dedup, Snakemake's file-based DAG does the bookkeeping — with science products as targets, a completeness count-floor as the failure policy, and one execution profile per machine.
Round history: round 1 posted as the original body of this issue (see edit history) → four-lens adversarial review (36 findings) → Cail's simplification feedback plus a Snakemake-v9 / nibi-networking research pass → a second four-lens adversarial review of the revised design (30 raw findings → 22 verified, 4 blockers) → this revision, which folds all 22. The through-line of Cail's feedback is simplify, and most of that simplification dissolves review findings rather than trading them away: dropping the failure-classifier removes the "misclassification swallows real bugs" hole; using the full default rerun-triggers removes the "code/config fixes don't propagate" contradiction; collapsing to one execution mode removes the multi-instance race. The second review then exposed a harder class of bug at the seam between Snakemake's state model and ShapePipe's — those fixes are folded below as design truth. This is a living PRD, kept updated as implementation proceeds; the eventual PR just says "implements #848." Please keep poking holes.
Motivation, concretely:
The v2.0 bash layer works but re-implements a build system: input discovery, completeness checks (ls | wc -l vs thresholds), done-markers (logs/log_job_<ID>_<bit>.txt), cross-tile dedup (flock on .sp_lock), retry/force semantics. Every one maps to a Snakemake primitive — and the bash versions live in three layers that each site then re-wraps (job_p3_batch1.sh on nibi is a fourth, parallel reimplementation).
The im_sims Snakefile (Image simulations workflow #766 lineage) proved the approach but kept the bash layer underneath and used log files as targets — completion ≠ product existence. This design inverts that.
Once the DAG is explicit, the questions we actually care about — how many jobs per node given memory, why did this tile fail, what is safe to rerun — become answerable instead of archaeological.
What the bash layer does today (what Snakemake must absorb)
Level 3 (curl_canfar_local.sh): fan out one job per tile, throttled batches, CANFAR/skaha REST.
Level 2 (run_job_sp_canfar_v2.0.bash): per-tile driver — work-dir forest (tiles/<IDra>/<ID>, exp/<2-digit-prefix>/<expbase>), loops exposures from exp_numbers-*.txt, per-exposure flock dedup, per-runner file-count completeness checks (with :warn/:subpath classes), done-logs, --force/--retry.
Level 1 (job_sp_canfar_v2.0.bash): job-bit → config → shapepipe_run, per-unit work dirs, init_exp_work_dir unit isolation. Unit isolation is per-unit work-dir content, not NUMBER_LIST — set_config_number_list exists (job_sp_canfar_v2.0.bash:255) but is never exercised in the forest flow: both call sites omit -e/--exclusive. A unit is isolated by its own work dir, its cfis symlink, and its own list files (a tile's tile_numbers.txt; an exposure's fabricated single-entry exp_numbers-000-000.txt). This is the mechanism the wrapper must reproduce (finding 9).
The pipeline being orchestrated:
flowchart TD
subgraph TP["tile-level, pre"]
Git["get_images (download)"] --> Uz[uncompress weight] --> Fe[find_exposures]
end
subgraph EX["exposure-level (modules parallelize over 40 CCDs internally)"]
Gie[get_images exp] --> Sp[split into CCDs] --> Ma[mask] --> Psf["SExtractor → setools → PSFEx → PSF interp"]
end
subgraph TQ["tile-level, post"]
Mh[merge_headers] --> Mat[mask tile] --> Sxt[SExtractor tile] --> Vi["PSF interp + vignets"] --> Ng["ngmix × N chunks"] --> Ms[merge_sep_cats] --> Mc["make_cat → final_cat"]
end
Fe -->|"exposure list (tile FITS HISTORY)"| Gie
Psf -->|"PSF models, stamps"| Vi
Sp -->|headers| Mh
Loading
Key structural facts:
The tile→exposure edge is data-derived: which exposures a tile needs is known only after the tile image is downloaded and its HISTORY header read (find_exposures.py reads hdu[0].header["HISTORY"], emits exp_numbers-<IDra>-<IDdec>.txt). Exposures overlap tiles (~7–10 per tile) and must be processed exactly once. In a contiguous survey the exposure-overlap graph is one giant connected component — exposure-disjoint tile batching is geometrically impossible (finding 6), which is why the shared-store and cleanup story below matters.
ShapePipe modules already parallelize within a shapepipe_run call (SMP over CCDs/objects), so orchestration jobs stay at tile/exposure granularity — never per-CCD.
Between modules, ShapePipe resolves inputs through its own run-log (log_run_sp.txt, the last:/run_sp_<prefix>: INPUT_DIR mechanism). This resolver wiring is load-bearing and stays. What we disable per-unit is the timestamp: the wrapper writes RUN_DATETIME=False into every per-unit config copy, so each stage lands at a deterministic output/run_sp_<prefix>/ and the resolver never sees ambiguity (finding 1/12/21).
Design
D1. Staged execution: prepare, then run
A run is declared by a tile list. Execution is honestly three static steps (findings 7/17: the exposure job set is data-derived from the tiles' find_exposures output, so it cannot be scheduled in the same static DAG that produces it), chained by the sp driver so the UX stays one command:
snakemake prepare_tiles # static per-tile DAG: Git → Uz → Fe, --keep-going
python workflow/scripts/build_index.py # PLAIN SCRIPT, not a DAG node
snakemake prepare_exposures # static per-exposure DAG: Gie + per-unit star cats
snakemake # phase B — the main compute DAG, parsed from the index
The index step exists for exactly one reason: run_index.sqlite must exist on disk before the later invocations parse it — the Snakefile loads the index once at parse time into plain dicts, and every input function is a dict lookup (no per-job DB access, no checkpoint, no directory globbing at scale). build_index.py iterates the declared tile list, checks each tile's Fe output at its deterministic path (O(tile-list) existence checks — no ls at scale), and writes tiles/exposures/tile_exposures plus index/missing.json.
The index build is threshold-gated, not all-or-nothing: a tile whose download persistently failed (retries + the FITS-integrity check below make that rare) simply lands in missing.json and its downstream is never scheduled; build_index.py exits nonzero only if the missing fraction exceeds a configurable floor. A bad tile costs that tile, not the run — one dead CADC file must never block a 7000-tile campaign. (There is no --allow-missing flag — that Snakemake option does not exist; tile list + --keep-going + the threshold + missing.json is the whole mechanism.)
This structure is implemented and green on nibi: a probe job ran shapepipe_run in-container through Snakemake and the prepare phase came up clean on a 4-tile subset — deterministic run_sp_tile_{Git,Uz,Fe} dirs on disk, run_index.sqlite built, 4 tiles / 30 exposures indexed.
The index is parse-time data, never a rule input: in phase B. Extending scope (append tiles, rebuild index) changes which jobs exist but does not touch the mtime chain of completed work — so 50 finished tiles stay finished when tile 51 arrives. This invariant is only true because the shared exposure store is persistent, not temp() (D2, finding 4): were the shared exposure intermediates temp(), tile 51 would force regeneration of border exposures, marking its finished spatial neighbours stale and wiping their RNG-bearing products. The invariant and temp()-on-shared-exposures are mutually exclusive; we keep the invariant.
Index schema:
tiles(tile_id, ra_dir, status)
exposures(exp_id, n_hdu) -- deduplicated; live-CCD count from the actual file
tile_exposures(tile_id, exp_id)
Exposure dedup is structural: exposure-level rules are keyed by exp_id alone, so every tile wanting exposure E requests the same output paths and Snakemake schedules E once within an invocation. Cross-invocation sharing over a common store is a contract, not a free ride (D6, finding 16).
Downloads run in-DAG on compute nodes. Round 1 assumed nibi compute nodes have no outbound internet; that was wrong — a compute node (c4.nibi.sharcnet) empirically reached Google (HTTP/2 200) and resolved CADC DNS, and Alliance docs confirm "all nodes on Nibi have internet access." So downloads sit inside the DAG: tile get_images in phase A (prepare, feeding find_exposures), exposure get_images and star-cat retrieval in phase B (the main run, keyed by the index). Each download rule carries retries: (endemic vcp flakiness) and a FITS-integrity verification step (open with astropy, check expected HDU count / NAXIS) so an exit-0-but-truncated transfer is a retryable failure, never a silent input — vcp's exit code alone is not trusted.
Rule granularity follows the existing job-bit boundaries — each rule wraps one shapepipe_run -c <config> on one unit (tile or exposure), in the same per-unit work-dir forest as v2.0. Because the wrapper writes RUN_DATETIME=False into each unit's config copy, every stage's output dir is a deterministic literaloutput/run_sp_<prefix>/ (prefixes distinct per stage, each stage runs once per unit tree → no collisions), declared as the rule's directory() output. Snakemake's delete-before-rerun then removes the real products, log entries stay stable, and last:/run_sp_<prefix>: resolution is unambiguous (get_run's >1-match RuntimeError can never fire). No rule declares a glob — there are no glob outputs anywhere in the DAG. The rows marked (phase A) build during snakemake prepare; everything else runs in phase B (the main invocation), exposure downloads and star cats included.
tile_merge_headers writes log_exp_headers-<IDra>-<IDdec>.sqlite — the tile suffix comes from the exp_numbers filename (merge_headers.py:47), and downstream Sx/PiViVi/Ng consume it via NUMBERING_SCHEME -000-000, so the declared output must carry the tile suffix (finding 12).
tile_detect (job bit 256) has three selectable variants, all representable: uc (external UNIONS catalogue → read_ext_sexcat_runner, config_tile_Git_cat_vos.ini+config_tile_Uc.ini — v2.0's data default), sx_nomask (config_tile_Sx_nomask.ini alone), and sx+onthefly-mask (config_tile_Ma_onthefly.ini+config_tile_Sx.ini). DR6 will standardise on one; the config selector carries the choice.
The data/image_sims axis (example/cfis vs example/cfis_image_sims, retrieve=vos vs symlink) is kept, but sims parity is a named follow-up, not a free ride — the sims chain diverges structurally (fake-Uz symlinking, fake_psf folded into vignets, different thresholds, forced tile_det=sx). First pass targets data.
Star cats are re-keyed per-unit.create_star_cat.py already emits star_cat-<exp>.fits (plus 40 per-CCD symlinks) and star_cat-<IDra>-<IDdec>.fits, so we declare per-exposure and per-tile star-cat outputs. Tile 51's cats become new DAG nodes rather than a mutation of an input to 50 finished units — preserving the growth invariant — and exp_mask/tile_detect declare their own unit's cat as a proper input: (finding 11a). A single run-level star_cats rule would dirty every finished mask job on append; that is out. First pass, the per-unit rules link cats from a pre-generated store (gen_star_cats.sh on a networked login node, the existing mechanism); folding create_star_cat.py itself into the DAG is a named follow-up.
Why the directory is the contract for per-CCD stages, and why that's the right primitive. Snakemake's unit of success is the job: one 40-CCD exp_psf invocation is a single exit code with all-or-nothing declared outputs, and Snakemake has no optional-output primitive (feature request #4016 open). Declaring per-CCD files is doubly impossible: at DR6 (~20k exposures) it is ~2.4M declared paths for split alone (~8M across mask+psf) — the DAG never builds against that many Lustre stats — and any missing CCD becomes a hard failure poisoning the whole downstream cone. Exploding to ~800k per-CCD jobs hits documented DAG-scale walls (#292/#3003). So the per-CCD stages (exp_mask, exp_psf, exp_split) declare their directory() output only; the count-floor (D3) counts the products in that directory at job time against the committed threshold table (workflow/scripts/completeness.py, stdlib-only so wrapper and report import the same table in-container) and sets the exit code — no per-unit manifest is written (delete-before-rerun on the declared directory is the whole state contract). It is exactly the primitive the flagship pipelines converged on — DES's per-CCD blacklist flagged on "too few stars" (our ~2% case), LSST/Rubin's quantum registry with pipetask report (which defaults to not fail-fast).
The wrapper (workflow/scripts/sp_rule.py). Each rule runs through a thin wrapper that:
materialises the unit (init_exp_work_dir parity, the real v2.0 isolation): the cfis config symlink; the per-unit list file (a tile's tile_numbers.txt in dot format; an exposure's fabricated single-entry exp_numbers-000-000.txt under output/run_sp_tile_Fe/find_exposures_runner/output/, so last:find_exposures_runner resolves to this exposure); and star_cat_exp/star_cat_tiles symlinks (dir-level links to the shared cats — the file handler intersects on NUMBERING_SCHEME, so extra files are harmless) in every unit work dir, because configs resolve $SP_RUN/star_cat_exp / $SP_RUN/star_cat_tiles inside each unit dir and mask stages fail with "No files found matching star_cat" without them (finding 11b). NUMBER_LIST is not the isolation path and is injected only for stages whose NUMBERING_SCHEME is the unit ID itself (tile-scheme stages Uz/Fe/Mh/Ma/Sx/PiViVi/Ng/merge/make_cat, exp_Sp); it is never applied to tile_get_images, exp_get_images, exp_mask, or exp_psf — for those, a -<ID> entry fails the [HELP] #746 startup validation against the per-CCD numbering scheme, and a 40-entry expansion would convert tolerated per-CCD attrition into whole-exposure hard failure (findings 9/10). The per-unit work dir already restricts scanning to this unit's files, reproducing the monolith's graceful per-CCD degradation exactly.
writes the per-unit config copy with RUN_DATETIME=False (deterministic output dir) and SMP_BATCH_SIZE={threads} (D4 fork-width invariant).
runs update_runs_log_file.py at job START — after Snakemake's pre-delete — so log_run_sp.txt (regenerated from the actual directory tree) never references a just-deleted run dir, then runs shapepipe_run. Concurrent tile_ngmix chunk jobs are exempt from this step (finding 18) — see D4.
does the completeness count-floor check (D3) → sets the exit code;
rewrites declared outputs only when content changed, so mtimes move only when reality moves (no per-unit manifest — the count-floor's verdict is the exit code), plus a cheap wrong-unit guard: output filenames must contain the unit's ID (finding 10);
tees module stdout/stderr to a per-unit log declared via log:.
State ownership (finding 21). Two state trackers meet here, and the design defines which wins: the directory tree is the source of truth; log_run_sp.txt is a derived cache; Snakemake owns existence. RUN_DATETIME=False makes each stage's dir a literal path Snakemake can delete-before-rerun; update_runs_log_file.py at job start rebuilds the log from disk so it can never point at a stale run. There is no pre-clean step and no rename-to-stable-name — both were failure modes (the rename left the timestamped dir behind for the resolver to resurrect). The wrapper is also the hand-run escape hatch (sp_rule.py tile_detect 123.456 reproduces the rule exactly); when hand-run it first deletes the target run dir if present (FileHandler.mkdir raises on an existing dir), reproducing Snakemake's delete-before-rerun so manual surgery stays visible to Snakemake afterwards.
tile_exp_forest is convenience, never the DAG edge (findings 5/19). Today the tile stages find exposure products by globbing $SP_EXP for the newest run dir (get_exp_output_files takes matches[-1]). Instead a per-tile rule builds a curated symlink view of exactly the index-listed exposures' verified products and points $SP_EXP at it — symlinking by exact run_sp_exp_<prefix> name (RUN_DATETIME=False guarantees exactly one). But the forest is not a sufficient input for any rule that reads through it: Snakemake has no notion of reachability through a symlink, so if the forest were the only declared consumer, temp() refcounting would delete the exposure dirs the instant the forest is built. Therefore tile_merge_headers, tile_vignets, and PSF-interp declare the realdirectory() outputs of their index-listed exposures (run_sp_exp_Sp, run_sp_exp_Ma, run_sp_exp_SxSePsfPi) as input: via an index-driven dict lookup. The last real reader — not tile_exp_forest — is the last DAG consumer.
Cleanup — persistent store, explicit verb (findings 4/5/6/19). Isn't there a temp() flag for exactly this? Yes — and temp()is the right tool for strictly-private intermediates. But it is per-invocation, and exposures are shared across tiles and batches: marking a shared exposure temp() combines with append-driven index growth, mtime propagation, and delete-before-rerun to destroy the "finished tiles stay finished" invariant and silently recompute RNG-bearing products under a later append or batch. So shared exposure intermediates are persistent, not temp(). Space is reclaimed by an explicit index-driven cleanup verb (sp clean-exposures) that deletes an exposure's intermediates only when run_index.sqlite's tile_exposures says every consuming tile has final_cat present across the whole output root — the index already carries exactly those edges, and cleanup can run periodically so peak storage stays bounded (accepting that later batches sharing a cleaned exposure re-trigger its regeneration). temp() may remain only on genuinely tile-private intermediates that cannot cross-couple units.
D3. Failure handling: the count-floor is the policy
Round 1 had a three-class taxonomy plus a failure_classes.yaml whitelist plus a verdict-classifier. That was over-engineering beyond what the bash layer does, and it manufactured exactly the opacity Martin fears (a misclassifier writing green over a real bug). All of it is dropped. The policy is the count table the bash complete_check already encodes: the wrapper does a completeness count-floor check — the ported per-runner thresholds, with :warn and :subpath semantics — and exits 0 above the floor, nonzero below (zero output is always fatal). The thresholds are committed as the table in workflow/scripts/completeness.py (stdlib-only, imported by both the wrapper and the report); the table below is its human rendering.
:warn means a short count does not fail the unit (expected sparse-CCD PSF loss — psfex_interp_runner:40::warn); :subpath (setools_runner:80:rand_split) counts a nested dir. So exp_psf tolerates <40 PSF-interp CCDs but still fails hard on 0-of-40, a short SExtractor/PSFEx count, or a nonzero shapepipe_run exit — the count table is the fail-closed contract, anything below the floor is a real failure with no reclassification. Tolerated-but-below-40 cases simply pass (exit 0); the standalone report (below) re-derives them by disk-scanning the run dirs against expected_counts.yaml.
Three mechanisms carry the three real situations, and only these:
Transient (vcp hiccups, node flakiness, OOM): per-rule retries: with attempt-scaled resources (mem_mb = lambda wc, attempt: base * attempt). Downloads additionally get the integrity check (D1). A deterministic failure exhausts retries and fails.
Hard (real bug, unrecoverable short count): the job exits nonzero; with keep-going: true everything independent continues and only the failed downstream cone waits.
Below-floor-but-expected (too few stars on some CCDs): absorbed by :warn, exit 0 — surfaced by the standalone report's disk-scan, no separate class, no whitelist.
Rerun triggers — the full default. Use the full default triggers{mtime, params, input, software-env, code} (the v9 default since 7.8) and remove any override from the profile. A params change should re-run — params make a big scientific difference — and a fixed module/config propagates natively. The one honest cost: the first run after adopting this may rerun more than mtime-only would; the mitigation is in the wrapper (declared outputs rewritten only when content changed), so timestamped-run-dir churn is gone anyway (RUN_DATETIME=False) and mtime cascades stay real, never cosmetic.
One deliberate scoping of the code trigger: rules invoke the wrapper via shell: (python sp_rule.py <stage> <unit>), not Snakemake's script: directive. So code tracks each rule's shell string (config path + params — exactly what should re-trigger) while an edit to the shared sp_rule.py does not invalidate the entire DAG (as script: would, every rule routing through the one wrapper). Wrapper-logic changes are an explicit --forcerun; science-affecting changes still propagate — configs/params via the shell string, module code via the software-env trigger on the container image.
Debugging path. Every rule declares its per-unit log via log:; the profile sets show-failed-logs: true, so on failure the head log prints rule, wildcards, unit log path, and its tail. In one-allocation mode (D4) the OOM/walltime trace also lands in the allocation's single stdout log; the standalone report (below) links every failed unit to its log path.
The report is out of the DAG (finding 8). A run_reportrule is a descendant of every tile, so under --keep-going any single hard failure (the very thing a report must enumerate) poisons its cone and the rule never runs — it would execute only on all-green runs, where it has nothing to say. So the report is a standalone verb (sp report): it reads run_index.sqlite and expected_counts.yaml, disk-scans the tiles/ and exp/ trees (the persistent exposure store makes re-counting cheap and always available), and enumerates failed cones — runnable at any time, including mid-run. onsuccess/onerror handlers in the Snakefile invoke the same script so a report lands automatically at the end of every invocation regardless of failures. The report distinguishes whole-exposure absence (all 40 CCDs missing) from tolerated per-CCD attrition, so no forest/deletion bug can be absorbed by the expected-by-data floor (finding 5).
D4. One execution mode: the one-big-allocation
Round 1 offered two modes. We keep only the one proven on nibi (job_p3_batch1.sh) and defer the slurm-executor:
# inside the sbatch allocation:
module load apptainer # via the committed launcher (D5), not shell state
snakemake --profile profiles/nibi <targets> # local scheduler bin-packs the whole allocation
# snakemake wraps each shapepipe_run in `apptainer exec project.sif` (--sdm apptainer)
sbatch an allocation; run snakemake with its local scheduler inside it. snakemake itself runs bare on the node — never inside the container. The orchestrator only needs snakemake + the stdlib (its parse-time job is loading run_index.sqlite; all heavy science lives in the container, reached per-job via --sdm apptainer, D5). snakemake's own environment lives on the shared FS (/project) because the local executor re-invokes its python inside jobs — a login-node-only venv would not be visible to the compute node. It is installed once as a small uv venv (or an Alliance module) and pinned in the workflow. The local executor bin-packs jobs against both declared cores/threadsand the mem_mb cap — that cap is required: in one-allocation mode SLURM runs --mem=0 --exclusive and enforces nothing per-job, so without a total mem_mb resource the scheduler packs by cores alone, attempt-scaled OOM retries don't thin concurrency, and the Linux OOM killer arbitrates. The cap makes higher mem_mb actually reduce concurrency on retry. --latency-wait 60 handles Lustre metadata propagation (outputs of a just-finished job appearing to the next).
threads == SMP fork-width invariant (finding 13). A rule's threads/cpus_per_task must equal ShapePipe's internal SMP fork width, or the scheduler's packing density decouples from the true per-job process count and the node-wide nproc budget blows (the im_sims crash beyond ~4 jobs/node at ulimit -u 1200). Enforced mechanically: the wrapper writes SMP_BATCH_SIZE={threads} into the per-unit config, so cpus_per_task and fork width are one number by construction (the exclusive-node-tuned 96/48/32 in the stock configs must never reach a per-unit job). mem_mb = threads × measured per-worker footprint (nibi P3: ngmix ≈ 3–4 GB/worker after #843, PSF light, SExtractor mid). P0 measures once: ulimit -u, pids.max and cpuset/memory cgroup confinement on a compute node, plus one deliberate max-packing test before P2 scale.
Orchestrator hosting (finding 14). The login-node user slice is measured at 6 cores / 16 GiB / 512 tasks, shared with editors and sessions — too small to host a long-lived orchestrator at batch scale, and an OOM-killed or rebooted login-node snakemake orphans in-flight jobs that then double-write directory() outputs on --rerun-incomplete. So: run snakemake inside a long-walltime sbatch job (compute nodes have internet and can sbatch) or, minimally, under tmux with the caveat that reboots still orphan jobs. Pin local-cores: 4 in the profile (it otherwise defaults to the node's ~192 visible CPUs; under the local executor it caps localrule/aggregation concurrency). Add an sp cancel verb: in one-allocation mode there are no per-job SLURM jobs, so it cancels the allocation itself; the deferred slurm-executor variant is a scancel-by-run-UUID sweep (squeue --me --format=%j | grep <uuid> | xargs scancel). Either way the rule is the same and mandatory: never --unlock/--rerun-incomplete while any process of the dead run may still be writing.
Stated plainly: this mode is single-node. Multi-node scaling = multiple tile-disjoint snakemake invocations, each its own allocation — explicitly deferred. First pass is single-node subsets and P0–P2 scale. (The slurm-executor path — login-node snakemake submitting one sbatch per rule via the apptainer SDM — is not hypothetical: a probe job ran it end-to-end on nibi and completed. It stays available from the same Snakefile as a later scaling knob; deferred, not designed out.)
ngmix straggler fix (the 13-hour chunk on the 50-tile run), retained and sharpened:
N chunks per tile is static (config), so the DAG shape is fixed and N_SPLIT_MAX == chunk count in tile_merge_cats (empty/short chunks tolerated by the merge).
The tile sexcat is a declared input: of each chunk job, and the wrapper computes that chunk's ID range at execution time from the tile's own object count — equal shares, closed ranges, no open-ended final chunk (the real root cause: ID_OBJ_MAX=-1 on the last chunk). The wrapper writes the computed range into the chunk's generated config copy (ID_OBJ_MIN/ID_OBJ_MAX). (A params: function can't do this — params evaluate at DAG-construction, before the sexcat exists.)
Chunk jobs are exempt from the run-log rewrite (finding 18): N chunks share one tiles/<ID>/output/ tree, and concurrent update_runs_log_file.py truncate-rewrites are a lost-update race that drops a sibling's just-appended entry. Concurrent appends (what shapepipe_run does natively) and reads are proven safe; only the rewrite races. Chunk configs use literal INPUT_DIRs (./output/run_sp_tile_Sx|PiViVi|Mh etc., deterministic under RUN_DATETIME=False), bypassing the log entirely. merge_sep_cats — DAG-serialized after all chunks — is the single per-tile log-sync point before make_cat reads the log. Design rule: log_run_sp.txt is shared mutable state within a unit tree; only DAG-serialized stages may rewrite it; concurrent stages may only append.
Thread/arena hygiene the p3 runs use: MALLOC_ARENA_MAX=2, MALLOC_TRIM_THRESHOLD_=0, OMP/OPENBLAS/MKL_NUM_THREADS=1.
# profiles/nibi/config.yamlcores: 192local-cores: 4resources: {mem_mb: 700000} # node-RAM cap for the local schedulerdefault-resources: {mem_mb: 2000, runtime: 2h}software-deployment-method: apptainerapptainer-args: "--cleanenv --env OMP_NUM_THREADS=1 --env MALLOC_ARENA_MAX=2 --env PYTHONPATH=<prod worktree>/src --home /home/cdaley --bind /project --bind /scratch"keep-going: trueshow-failed-logs: truererun-incomplete: truelatency-wait: 60# no rerun-triggers override — full default triggers (D3)
Containers: Snakemake wraps every job in apptainer exec automatically — the workflow never calls apptainer itself. Rules carry a container: directive pointing at the local /project.sif (/project/def-mjhudson/cdaley/containers/shapepipe-develop-runtime.sif), never docker:// — no apptainer-prefix cache, no registry pull mid-campaign (findings 15/2). The profile sets software-deployment-method: apptainer and the args above, and Snakemake wraps each command. This kills the hand-rolled in_container/apptainer_noslurm machinery. Per-rule containers are allowed → modularity (default to the project SIF; a different SIF per stage is permitted).
Container environment is explicit, because the SDM drops the proven recipe (findings 2/3). Snakemake's apptainer SDM generates apptainer exec with no --cleanenv, so only what the submitting shell exported would pass through, and it hardcodes --home <cwd> (singularity.py:143), which would set $HOME to the workflow workdir and hide ~/.ssl/cadcproxy.pem from vos/vcp — every get_images download would fail auth. The profile's args fix all three:
--home /home/cdaley is appended after snakemake's hardcoded --home <cwd> and wins (verified on apptainer 1.4.5), restoring $HOME so ~/.ssl/cadcproxy.pem is visible (and astropy/vos caches don't collide in the shared workdir).
--env OMP_NUM_THREADS=1 caps the sif's scipy-openblas pool (verified 32→1); without it 40 forked CCD workers each size a pool to the 192-core node.
--cleanenv is stricter than the proven recipe but safe: APPTAINERENV_* survive it (Snakemake's envvars: keeps working) and the wrapper's SP_RUN/SP_CONFIG/SP_EXP exports happen inside the container.
--env PYTHONPATH=<pinned checkout>/src is set for P0 (finding 2c): P0 validates science content against bash runs that use a worktree src override, so P0 must run known code. The pin must be a frozen checkout at a named commit, never a moving branch checkout — the live p3-batch1 run demonstrated the failure (its shapepipe-prod worktree switched branches mid-run, so late stages run different code than early ones). Production later rebuilds the sif at the exact validation commit and drops the override — which-code-runs is a decision, never a build-time accident.
A P0 smoke rule, run inside a submitted SLURM job, prints shapepipe.__file__, sys.path, and threadpoolctl.threadpool_info() to prove code precedence and thread caps before any science validation, and one containerized tile_get_images runs end-to-end before anything else in phase A (proves cert visibility).
The launcher owns module load apptainer (finding 20). apptainer is a cvmfs module, not on the default PATH; snakemake resolves the binary at job runtime via shutil.which, so a snakemake started from any shell without the module loaded fails every job after burning queue time. A committed launcher (workflow/bin/sp, or a Makefile target) does module load apptainer/1.4.5 before exec-ing snakemake --profile profiles/nibi — the documented entry point, so PATH never depends on shell state. It is also the path P0 exercises.
Group-compatibility is a design constraint (finding 22). Per-unit-per-stage granularity at DR6 is ~120–170k SLURM jobs/campaign — not yet priced against the proven single-node monolith (14 shapepipe_run calls for 50 tiles). Snakemake's designed answer is group: fusion, but only if rules stay fusable: no mid-chain localrules, no pipe/service outputs in the tile/exposure chains (currently true — state it so it stays true), making group: a pure profile-level knob. P0 measures the granularity cost: DAG build time/memory for a synthetic index at 2–4k-tile scale (snakemake -n on fake index rows), per-job overhead (apptainer cold-start + shapepipe_run startup vs stage runtime) on the shortest rules (exp_split, tile_merge_headers, tile_merge_cats), and a one-line check of nibi's MaxSubmitJobs/short-job policy. If short-rule overhead exceeds ~20–30% or scheduler load is unfriendly, set group: labels (fuse merge_headers→mask+detect→vignets per tile, or the exposure Sp→Ma→psf chain) in the profile — no rule or contract change, directory() outputs and failure classes unaffected.
CANFAR is a future profile — a one-line note, not implemented here. When it comes it is a second profiles/canfar/ (cluster-generic wrapping the skaha REST calls curl_canfar_local.sh already makes); the Snakefile is unchanged.
D6. Repo layout, storage, sharing, migration, and the complexity budget
Pin snakemake>=9,<10 (latest 9.23.1). v8→v9 breaking changes are minor for us: --use-singularity became --sdm, executors are now plugins (the local executor we use stays built-in), the full rerun-triggers set is the default.
Storage placement (finding 15)./scratch purges by age; a P3 campaign spans months. So durable, low-volume products — final_cat-<ID>.fits, index/run_index.sqlite, index/missing.json, reports — land on /project; bulk intermediates (per-CCD split/mask/PSF run dirs) live on /scratch, each batch sized to complete well inside the ~60-day purge. The Snakemake workdir (.snakemake/ — DAG metadata + incomplete-markers) is explicitly on /project so purge never breaks the mtime chain or resurrects finished-tile reruns.
Sharing model is a contract, not a convenience (finding 16). Dedup over a shared output root is per-invocation DAG semantics only. The contract: exactly one Snakemake workdir (one .snakemake) per output root, so all invocations share provenance metadata and the workdir lock serializes them; invocations are strictly sequential and the launcher refuses --nolock (two concurrent invocations would each delete-before-rerun a directory the other is reading/writing). Even sequentially, under full rerun-triggers, code/params drift between invocations would mark run A's finished exposure dirs stale and delete-before-rerun their RNG-bearing products. So final_cat and completed exposure-product directories are protected() — check_protected_output fires before deletion, converting a silent destructive recompute into a loud failure exactly when the full-rerun-trigger × delete-before-rerun combination would otherwise strike. Intentional recomputation routes only through the sp rerun (--forcerun) verb. (In Snakemake 9 a cross-workdir invocation is not itself a rerun hazard — missing metadata is informational, excluded from rerun reasons — but it silently drops provenance tracking, which is its own reason to forbid it.)
Is this a fourth orchestration generation? The honest accounting: sp_rule.py + build_index.py + the index + the report replace ~2000 lines across three bash levels plus each site's sbatch wrapper. The wrapper is smaller than round 1 — no classifier, no whitelist, just unit setup, config copy, run-log, count-floor, tee — and everything it owns the bash layer already does, scattered. The claim to hold us to: net less code, in one place, testable, with the DAG delegated to a tool whose whole job is DAGs. If an implementation PR doesn't deliver that, this design has failed and we should say so.
Phased migration, each phase independently useful, bash layers untouched until Martin retires them (P0/P1 run on a separate output tree — no shared exp/ with a bash-driven run during validation):
P0 — prepare + exposure stages on a 4-tile P3 subset. Gates: the container smoke rule (shapepipe.__file__, sys.path, threadpool_info), one end-to-end containerized tile_get_images (cert visibility), the nproc/cgroup + max-packing measurement, and the granularity measurement (DAG build cost, short-rule overhead, MaxSubmitJobs). Validate against a bash-driven run by scientific equivalence, not byte-identity (FITS DATE headers, setools rand_split RNG, and ngmix stochasticity all break bytes) — product counts per stage, catalogue row counts, shape/PSF parameters to numerical tolerance. Add the 3-rule synthetic append test (run 2 tiles, append 1, dry-run must schedule nothing for finished tiles) to CI as the growth-invariant regression — it runs in seconds.
P1 — full tile chain through final_cat; retire job_p3_batch1.sh for new runs.
P2 — scale via batched one-allocation invocations (50-tile → P3, ~7300 tiles, tile-disjoint sky partitions); exercise the count-floor, retries, rerun propagation, and sp clean-exposures for real.
P3 — CANFAR profile (cluster-generic wrapping skaha); decide whether the deferred slurm-executor mode is worth a custom plugin.1
Open questions
Batch sizing for multi-invocation scaling: how many tile-disjoint sky partitions, how large (bounded above by the 60-day purge, below by exposure-overlap waste at batch borders)? Named now, decided at P2.
make_cat over partial tiles: if k of 40 CCDs failed PSF on some exposure, the tile completes with fewer epochs. Keep that (report records the gaps), or add a quality gate (quarantine a tile above X% missing)?
Where do generated per-chunk ngmix configs live — wrapper writes them into the unit work dir (proposed), or keep the repo-level sed-template approach?
Repo layout: workflow/ at top level per Snakemake convention, or under scripts/ next to the bash it replaces?
Round-2 design (revised), grounded in run_job_sp_canfar_v2.0.bash / job_sp_canfar_v2.0.bash on develop, the im_sims Snakefile lineage (#766), and the nibi P3 runs (#808). Simplified after Cail's feedback on round 1 and a second research pass (Snakemake v9, nibi compute-node networking, the field's partial-failure idiom), then hardened against a second adversarial review. Living PRD — kept current as implementation proceeds.
— Claude (Fable) on behalf of Cail
Footnotes
The deferred slurm-executor mode (per-rule sbatch instead of one big allocation) is not hypothetical — a probe completed end-to-end on nibi (login-node snakemake, one sbatch per rule via the apptainer SDM; slurm_account=def-mjhudson_cpu, per-rule cpus_per_task/mem/runtime from the same measured footprints). It runs from the same Snakefile; deferred to a later scaling decision, not designed out. ↩
Snakemake orchestration for the real-data pipeline
Summary
Replace the shell-script orchestration of real-data ShapePipe runs (
curl_canfar_local.sh→run_job_sp_canfar_v2.0.bash→job_sp_canfar_v2.0.bash, plus per-site sbatch reimplementations like the nibi P3 scripts) with a Snakemake workflow living in this repo. First pass touches no module code: rules callshapepipe_run -c <config>on the existing config chains, at the same granularities the bash layer uses today. What changes is who tracks state: instead of hand-written file-count thresholds, done-log files, andflock-guarded dedup, Snakemake's file-based DAG does the bookkeeping — with science products as targets, a completeness count-floor as the failure policy, and one execution profile per machine.Round history: round 1 posted as the original body of this issue (see edit history) → four-lens adversarial review (36 findings) → Cail's simplification feedback plus a Snakemake-v9 / nibi-networking research pass → a second four-lens adversarial review of the revised design (30 raw findings → 22 verified, 4 blockers) → this revision, which folds all 22. The through-line of Cail's feedback is simplify, and most of that simplification dissolves review findings rather than trading them away: dropping the failure-classifier removes the "misclassification swallows real bugs" hole; using the full default rerun-triggers removes the "code/config fixes don't propagate" contradiction; collapsing to one execution mode removes the multi-instance race. The second review then exposed a harder class of bug at the seam between Snakemake's state model and ShapePipe's — those fixes are folded below as design truth. This is a living PRD, kept updated as implementation proceeds; the eventual PR just says "implements #848." Please keep poking holes.
Motivation, concretely:
ls | wc -lvs thresholds), done-markers (logs/log_job_<ID>_<bit>.txt), cross-tile dedup (flockon.sp_lock), retry/force semantics. Every one maps to a Snakemake primitive — and the bash versions live in three layers that each site then re-wraps (job_p3_batch1.shon nibi is a fourth, parallel reimplementation).What the bash layer does today (what Snakemake must absorb)
Level 3 (
curl_canfar_local.sh): fan out one job per tile, throttled batches, CANFAR/skaha REST.Level 2 (
run_job_sp_canfar_v2.0.bash): per-tile driver — work-dir forest (tiles/<IDra>/<ID>,exp/<2-digit-prefix>/<expbase>), loops exposures fromexp_numbers-*.txt, per-exposureflockdedup, per-runner file-count completeness checks (with:warn/:subpathclasses), done-logs,--force/--retry.Level 1 (
job_sp_canfar_v2.0.bash): job-bit → config →shapepipe_run, per-unit work dirs,init_exp_work_dirunit isolation. Unit isolation is per-unit work-dir content, notNUMBER_LIST—set_config_number_listexists (job_sp_canfar_v2.0.bash:255) but is never exercised in the forest flow: both call sites omit-e/--exclusive. A unit is isolated by its own work dir, itscfissymlink, and its own list files (a tile'stile_numbers.txt; an exposure's fabricated single-entryexp_numbers-000-000.txt). This is the mechanism the wrapper must reproduce (finding 9).The pipeline being orchestrated:
flowchart TD subgraph TP["tile-level, pre"] Git["get_images (download)"] --> Uz[uncompress weight] --> Fe[find_exposures] end subgraph EX["exposure-level (modules parallelize over 40 CCDs internally)"] Gie[get_images exp] --> Sp[split into CCDs] --> Ma[mask] --> Psf["SExtractor → setools → PSFEx → PSF interp"] end subgraph TQ["tile-level, post"] Mh[merge_headers] --> Mat[mask tile] --> Sxt[SExtractor tile] --> Vi["PSF interp + vignets"] --> Ng["ngmix × N chunks"] --> Ms[merge_sep_cats] --> Mc["make_cat → final_cat"] end Fe -->|"exposure list (tile FITS HISTORY)"| Gie Psf -->|"PSF models, stamps"| Vi Sp -->|headers| MhKey structural facts:
HISTORYheader read (find_exposures.pyreadshdu[0].header["HISTORY"], emitsexp_numbers-<IDra>-<IDdec>.txt). Exposures overlap tiles (~7–10 per tile) and must be processed exactly once. In a contiguous survey the exposure-overlap graph is one giant connected component — exposure-disjoint tile batching is geometrically impossible (finding 6), which is why the shared-store and cleanup story below matters.shapepipe_runcall (SMP over CCDs/objects), so orchestration jobs stay at tile/exposure granularity — never per-CCD.log_run_sp.txt, thelast:/run_sp_<prefix>:INPUT_DIR mechanism). This resolver wiring is load-bearing and stays. What we disable per-unit is the timestamp: the wrapper writesRUN_DATETIME=Falseinto every per-unit config copy, so each stage lands at a deterministicoutput/run_sp_<prefix>/and the resolver never sees ambiguity (finding 1/12/21).Design
D1. Staged execution: prepare, then run
A run is declared by a tile list. Execution is honestly three static steps (findings 7/17: the exposure job set is data-derived from the tiles'
find_exposuresoutput, so it cannot be scheduled in the same static DAG that produces it), chained by thespdriver so the UX stays one command:The index step exists for exactly one reason:
run_index.sqlitemust exist on disk before the later invocations parse it — the Snakefile loads the index once at parse time into plain dicts, and every input function is a dict lookup (no per-job DB access, no checkpoint, no directory globbing at scale).build_index.pyiterates the declared tile list, checks each tile's Fe output at its deterministic path (O(tile-list) existence checks — nolsat scale), and writestiles/exposures/tile_exposuresplusindex/missing.json.The index build is threshold-gated, not all-or-nothing: a tile whose download persistently failed (retries + the FITS-integrity check below make that rare) simply lands in
missing.jsonand its downstream is never scheduled;build_index.pyexits nonzero only if the missing fraction exceeds a configurable floor. A bad tile costs that tile, not the run — one dead CADC file must never block a 7000-tile campaign. (There is no--allow-missingflag — that Snakemake option does not exist; tile list +--keep-going+ the threshold +missing.jsonis the whole mechanism.)This structure is implemented and green on nibi: a probe job ran
shapepipe_runin-container through Snakemake and the prepare phase came up clean on a 4-tile subset — deterministicrun_sp_tile_{Git,Uz,Fe}dirs on disk,run_index.sqlitebuilt, 4 tiles / 30 exposures indexed.The index is parse-time data, never a rule
input:in phase B. Extending scope (append tiles, rebuild index) changes which jobs exist but does not touch the mtime chain of completed work — so 50 finished tiles stay finished when tile 51 arrives. This invariant is only true because the shared exposure store is persistent, nottemp()(D2, finding 4): were the shared exposure intermediatestemp(), tile 51 would force regeneration of border exposures, marking its finished spatial neighbours stale and wiping their RNG-bearing products. The invariant andtemp()-on-shared-exposures are mutually exclusive; we keep the invariant.Index schema:
Exposure dedup is structural: exposure-level rules are keyed by
exp_idalone, so every tile wanting exposure E requests the same output paths and Snakemake schedules E once within an invocation. Cross-invocation sharing over a common store is a contract, not a free ride (D6, finding 16).Downloads run in-DAG on compute nodes. Round 1 assumed nibi compute nodes have no outbound internet; that was wrong — a compute node (
c4.nibi.sharcnet) empirically reached Google (HTTP/2 200) and resolved CADC DNS, and Alliance docs confirm "all nodes on Nibi have internet access." So downloads sit inside the DAG: tileget_imagesin phase A (prepare, feeding find_exposures), exposureget_imagesand star-cat retrieval in phase B (the main run, keyed by the index). Each download rule carriesretries:(endemic vcp flakiness) and a FITS-integrity verification step (open with astropy, check expected HDU count / NAXIS) so an exit-0-but-truncated transfer is a retryable failure, never a silent input — vcp's exit code alone is not trusted.D2. Rules = existing config files; work-dir forest preserved
Rule granularity follows the existing job-bit boundaries — each rule wraps one
shapepipe_run -c <config>on one unit (tile or exposure), in the same per-unit work-dir forest as v2.0. Because the wrapper writesRUN_DATETIME=Falseinto each unit's config copy, every stage's output dir is a deterministic literaloutput/run_sp_<prefix>/(prefixes distinct per stage, each stage runs once per unit tree → no collisions), declared as the rule'sdirectory()output. Snakemake's delete-before-rerun then removes the real products, log entries stay stable, andlast:/run_sp_<prefix>:resolution is unambiguous (get_run's >1-match RuntimeError can never fire). No rule declares a glob — there are no glob outputs anywhere in the DAG. The rows marked (phase A) build duringsnakemake prepare; everything else runs in phase B (the main invocation), exposure downloads and star cats included.tile_get_images+tile_find_exposures(prepare_tiles)config_tile_Git_*,Uz,Fe.../run_sp_tile_Git/,run_sp_tile_Uz/,run_sp_tile_Fe/find_exposures_runner/output/exp_numbers-<IDra>-<IDdec>.txtbuild_index.py— plain script between the two prepare targets, threshold-gatedindex/run_index.sqlite,index/missing.jsonexp_get_images(prepare_exposures)config_exp_Gie_*.../run_sp_exp_Gie/star_cat_exp(prepare_exposures)star_cat-<exp>.fits(+ per-CCD symlinks)star_cat_tile(prepare_exposures)star_cat-<IDra>-<IDdec>.fitsexp_splitconfig_exp_Sp.ini.../run_sp_exp_Sp/(headers-<exp>.npy+ CCD files,ensure(non_empty=True))exp_maskconfig_exp_Ma_onthefly.ini.../run_sp_exp_Ma/(per-CCD masks)exp_psfconfig_exp_psfex.ini.../run_sp_exp_SxSePsfPi/(per-CCD PSF models)tile_exp_forest$SP_EXPview — runtime convenience, not a DAG edgetile_merge_headersconfig_tile_Mh_exp.ini.../run_sp_tile_Mh/merge_headers_runner/output/log_exp_headers-<IDra>-<IDdec>.sqlitetile_detect.../run_sp_tile_<Uc|Sx>/(tile sexcat)tile_vignetsconfig_tile_PiViVi_*.ini.../run_sp_tile_PiViVi/(vignet files)tile_ngmixconfig_tile_Ng<k>.ini.../run_sp_tile_ngmix_Ng<k>u/(chunk catalog)tile_merge_catsconfig_merge_sep_cats.ini.../run_sp_tile_msc/(merged ngmix cat)tile_make_catconfig_tile_Mc_*.inifinal_cat-<IDra>-<IDdec>.fitstile_merge_headerswriteslog_exp_headers-<IDra>-<IDdec>.sqlite— the tile suffix comes from theexp_numbersfilename (merge_headers.py:47), and downstream Sx/PiViVi/Ng consume it viaNUMBERING_SCHEME -000-000, so the declared output must carry the tile suffix (finding 12).tile_detect(job bit 256) has three selectable variants, all representable:uc(external UNIONS catalogue →read_ext_sexcat_runner,config_tile_Git_cat_vos.ini+config_tile_Uc.ini— v2.0's data default),sx_nomask(config_tile_Sx_nomask.inialone), andsx+onthefly-mask (config_tile_Ma_onthefly.ini+config_tile_Sx.ini). DR6 will standardise on one; the config selector carries the choice.The
data/image_simsaxis (example/cfisvsexample/cfis_image_sims,retrieve=vosvssymlink) is kept, but sims parity is a named follow-up, not a free ride — the sims chain diverges structurally (fake-Uz symlinking,fake_psffolded into vignets, different thresholds, forcedtile_det=sx). First pass targets data.Star cats are re-keyed per-unit.
create_star_cat.pyalready emitsstar_cat-<exp>.fits(plus 40 per-CCD symlinks) andstar_cat-<IDra>-<IDdec>.fits, so we declare per-exposure and per-tile star-cat outputs. Tile 51's cats become new DAG nodes rather than a mutation of an input to 50 finished units — preserving the growth invariant — andexp_mask/tile_detectdeclare their own unit's cat as a properinput:(finding 11a). A single run-level star_cats rule would dirty every finished mask job on append; that is out. First pass, the per-unit rules link cats from a pre-generated store (gen_star_cats.shon a networked login node, the existing mechanism); foldingcreate_star_cat.pyitself into the DAG is a named follow-up.Why the directory is the contract for per-CCD stages, and why that's the right primitive. Snakemake's unit of success is the job: one 40-CCD
exp_psfinvocation is a single exit code with all-or-nothing declared outputs, and Snakemake has no optional-output primitive (feature request #4016 open). Declaring per-CCD files is doubly impossible: at DR6 (~20k exposures) it is ~2.4M declared paths for split alone (~8M across mask+psf) — the DAG never builds against that many Lustre stats — and any missing CCD becomes a hard failure poisoning the whole downstream cone. Exploding to ~800k per-CCD jobs hits documented DAG-scale walls (#292/#3003). So the per-CCD stages (exp_mask,exp_psf,exp_split) declare theirdirectory()output only; the count-floor (D3) counts the products in that directory at job time against the committed threshold table (workflow/scripts/completeness.py, stdlib-only so wrapper and report import the same table in-container) and sets the exit code — no per-unit manifest is written (delete-before-rerun on the declared directory is the whole state contract). It is exactly the primitive the flagship pipelines converged on — DES's per-CCD blacklist flagged on "too few stars" (our ~2% case), LSST/Rubin's quantum registry withpipetask report(which defaults to not fail-fast).The wrapper (
workflow/scripts/sp_rule.py). Each rule runs through a thin wrapper that:init_exp_work_dirparity, the real v2.0 isolation): thecfisconfig symlink; the per-unit list file (a tile'stile_numbers.txtin dot format; an exposure's fabricated single-entryexp_numbers-000-000.txtunderoutput/run_sp_tile_Fe/find_exposures_runner/output/, solast:find_exposures_runnerresolves to this exposure); andstar_cat_exp/star_cat_tilessymlinks (dir-level links to the shared cats — the file handler intersects onNUMBERING_SCHEME, so extra files are harmless) in every unit work dir, because configs resolve$SP_RUN/star_cat_exp/$SP_RUN/star_cat_tilesinside each unit dir and mask stages fail with "No files found matching star_cat" without them (finding 11b).NUMBER_LISTis not the isolation path and is injected only for stages whoseNUMBERING_SCHEMEis the unit ID itself (tile-scheme stages Uz/Fe/Mh/Ma/Sx/PiViVi/Ng/merge/make_cat, exp_Sp); it is never applied totile_get_images,exp_get_images,exp_mask, orexp_psf— for those, a-<ID>entry fails the [HELP] #746 startup validation against the per-CCD numbering scheme, and a 40-entry expansion would convert tolerated per-CCD attrition into whole-exposure hard failure (findings 9/10). The per-unit work dir already restricts scanning to this unit's files, reproducing the monolith's graceful per-CCD degradation exactly.RUN_DATETIME=False(deterministic output dir) andSMP_BATCH_SIZE={threads}(D4 fork-width invariant).update_runs_log_file.pyat job START — after Snakemake's pre-delete — solog_run_sp.txt(regenerated from the actual directory tree) never references a just-deleted run dir, then runsshapepipe_run. Concurrenttile_ngmixchunk jobs are exempt from this step (finding 18) — see D4.log:.State ownership (finding 21). Two state trackers meet here, and the design defines which wins: the directory tree is the source of truth;
log_run_sp.txtis a derived cache; Snakemake owns existence. RUN_DATETIME=False makes each stage's dir a literal path Snakemake can delete-before-rerun;update_runs_log_file.pyat job start rebuilds the log from disk so it can never point at a stale run. There is no pre-clean step and no rename-to-stable-name — both were failure modes (the rename left the timestamped dir behind for the resolver to resurrect). The wrapper is also the hand-run escape hatch (sp_rule.py tile_detect 123.456reproduces the rule exactly); when hand-run it first deletes the target run dir if present (FileHandler.mkdir raises on an existing dir), reproducing Snakemake's delete-before-rerun so manual surgery stays visible to Snakemake afterwards.tile_exp_forestis convenience, never the DAG edge (findings 5/19). Today the tile stages find exposure products by globbing$SP_EXPfor the newest run dir (get_exp_output_filestakesmatches[-1]). Instead a per-tile rule builds a curated symlink view of exactly the index-listed exposures' verified products and points$SP_EXPat it — symlinking by exactrun_sp_exp_<prefix>name (RUN_DATETIME=False guarantees exactly one). But the forest is not a sufficient input for any rule that reads through it: Snakemake has no notion of reachability through a symlink, so if the forest were the only declared consumer,temp()refcounting would delete the exposure dirs the instant the forest is built. Thereforetile_merge_headers,tile_vignets, and PSF-interp declare the realdirectory()outputs of their index-listed exposures (run_sp_exp_Sp,run_sp_exp_Ma,run_sp_exp_SxSePsfPi) asinput:via an index-driven dict lookup. The last real reader — nottile_exp_forest— is the last DAG consumer.Cleanup — persistent store, explicit verb (findings 4/5/6/19). Isn't there a
temp()flag for exactly this? Yes — andtemp()is the right tool for strictly-private intermediates. But it is per-invocation, and exposures are shared across tiles and batches: marking a shared exposuretemp()combines with append-driven index growth, mtime propagation, and delete-before-rerun to destroy the "finished tiles stay finished" invariant and silently recompute RNG-bearing products under a later append or batch. So shared exposure intermediates are persistent, nottemp(). Space is reclaimed by an explicit index-driven cleanup verb (sp clean-exposures) that deletes an exposure's intermediates only whenrun_index.sqlite'stile_exposuressays every consuming tile hasfinal_catpresent across the whole output root — the index already carries exactly those edges, and cleanup can run periodically so peak storage stays bounded (accepting that later batches sharing a cleaned exposure re-trigger its regeneration).temp()may remain only on genuinely tile-private intermediates that cannot cross-couple units.D3. Failure handling: the count-floor is the policy
Round 1 had a three-class taxonomy plus a
failure_classes.yamlwhitelist plus a verdict-classifier. That was over-engineering beyond what the bash layer does, and it manufactured exactly the opacity Martin fears (a misclassifier writing green over a real bug). All of it is dropped. The policy is the count table the bashcomplete_checkalready encodes: the wrapper does a completeness count-floor check — the ported per-runner thresholds, with:warnand:subpathsemantics — and exits 0 above the floor, nonzero below (zero output is always fatal). The thresholds are committed as the table inworkflow/scripts/completeness.py(stdlib-only, imported by both the wrapper and the report); the table below is its human rendering.get_images_runner:4(2 for sims)uncompress_fits_runner:1find_exposures_runner:1get_images_runner:6(3 sims)split_exp_runner:121(40 CCD × 3 + 1 header)mask_runner:40sextractor_runner:80 psfex_runner:80 psfex_interp_runner:40::warn setools_runner:80:rand_splitmerge_headers_runner:1sextractor_runner:2get_images_runner:2 read_ext_sexcat_runner:1psfex_interp_runner:1 vignetmaker_runner_run_1:1 vignetmaker_runner_run_2:4(:3sims)ngmix_runner:1make_cat_runner:1:warnmeans a short count does not fail the unit (expected sparse-CCD PSF loss —psfex_interp_runner:40::warn);:subpath(setools_runner:80:rand_split) counts a nested dir. Soexp_psftolerates <40 PSF-interp CCDs but still fails hard on 0-of-40, a short SExtractor/PSFEx count, or a nonzeroshapepipe_runexit — the count table is the fail-closed contract, anything below the floor is a real failure with no reclassification. Tolerated-but-below-40 cases simply pass (exit 0); the standalone report (below) re-derives them by disk-scanning the run dirs againstexpected_counts.yaml.Three mechanisms carry the three real situations, and only these:
retries:with attempt-scaled resources (mem_mb = lambda wc, attempt: base * attempt). Downloads additionally get the integrity check (D1). A deterministic failure exhausts retries and fails.keep-going: trueeverything independent continues and only the failed downstream cone waits.:warn, exit 0 — surfaced by the standalone report's disk-scan, no separate class, no whitelist.Rerun triggers — the full default. Use the full default triggers
{mtime, params, input, software-env, code}(the v9 default since 7.8) and remove any override from the profile. A params change should re-run — params make a big scientific difference — and a fixed module/config propagates natively. The one honest cost: the first run after adopting this may rerun more than mtime-only would; the mitigation is in the wrapper (declared outputs rewritten only when content changed), so timestamped-run-dir churn is gone anyway (RUN_DATETIME=False) and mtime cascades stay real, never cosmetic.One deliberate scoping of the
codetrigger: rules invoke the wrapper viashell:(python sp_rule.py <stage> <unit>), not Snakemake'sscript:directive. Socodetracks each rule's shell string (config path + params — exactly what should re-trigger) while an edit to the sharedsp_rule.pydoes not invalidate the entire DAG (asscript:would, every rule routing through the one wrapper). Wrapper-logic changes are an explicit--forcerun; science-affecting changes still propagate — configs/params via the shell string, module code via thesoftware-envtrigger on the container image.Debugging path. Every rule declares its per-unit log via
log:; the profile setsshow-failed-logs: true, so on failure the head log prints rule, wildcards, unit log path, and its tail. In one-allocation mode (D4) the OOM/walltime trace also lands in the allocation's single stdout log; the standalone report (below) links every failed unit to its log path.The report is out of the DAG (finding 8). A
run_reportrule is a descendant of every tile, so under--keep-goingany single hard failure (the very thing a report must enumerate) poisons its cone and the rule never runs — it would execute only on all-green runs, where it has nothing to say. So the report is a standalone verb (sp report): it readsrun_index.sqliteandexpected_counts.yaml, disk-scans thetiles/andexp/trees (the persistent exposure store makes re-counting cheap and always available), and enumerates failed cones — runnable at any time, including mid-run.onsuccess/onerrorhandlers in the Snakefile invoke the same script so a report lands automatically at the end of every invocation regardless of failures. The report distinguishes whole-exposure absence (all 40 CCDs missing) from tolerated per-CCD attrition, so no forest/deletion bug can be absorbed by the expected-by-data floor (finding 5).D4. One execution mode: the one-big-allocation
Round 1 offered two modes. We keep only the one proven on nibi (
job_p3_batch1.sh) and defer the slurm-executor:sbatchan allocation; runsnakemakewith its local scheduler inside it. snakemake itself runs bare on the node — never inside the container. The orchestrator only needs snakemake + the stdlib (its parse-time job is loadingrun_index.sqlite; all heavy science lives in the container, reached per-job via--sdm apptainer, D5). snakemake's own environment lives on the shared FS (/project) because the local executor re-invokes its python inside jobs — a login-node-only venv would not be visible to the compute node. It is installed once as a smalluvvenv (or an Alliance module) and pinned in the workflow. The local executor bin-packs jobs against both declaredcores/threadsand themem_mbcap — that cap is required: in one-allocation mode SLURM runs--mem=0 --exclusiveand enforces nothing per-job, so without a totalmem_mbresource the scheduler packs by cores alone, attempt-scaled OOM retries don't thin concurrency, and the Linux OOM killer arbitrates. The cap makes highermem_mbactually reduce concurrency on retry.--latency-wait 60handles Lustre metadata propagation (outputs of a just-finished job appearing to the next).threads == SMP fork-width invariant (finding 13). A rule's
threads/cpus_per_taskmust equal ShapePipe's internal SMP fork width, or the scheduler's packing density decouples from the true per-job process count and the node-wide nproc budget blows (the im_sims crash beyond ~4 jobs/node atulimit -u 1200). Enforced mechanically: the wrapper writesSMP_BATCH_SIZE={threads}into the per-unit config, socpus_per_taskand fork width are one number by construction (the exclusive-node-tuned 96/48/32 in the stock configs must never reach a per-unit job).mem_mb = threads × measured per-worker footprint(nibi P3: ngmix ≈ 3–4 GB/worker after #843, PSF light, SExtractor mid). P0 measures once:ulimit -u,pids.maxand cpuset/memory cgroup confinement on a compute node, plus one deliberate max-packing test before P2 scale.Orchestrator hosting (finding 14). The login-node user slice is measured at 6 cores / 16 GiB / 512 tasks, shared with editors and sessions — too small to host a long-lived orchestrator at batch scale, and an OOM-killed or rebooted login-node snakemake orphans in-flight jobs that then double-write
directory()outputs on--rerun-incomplete. So: run snakemake inside a long-walltimesbatchjob (compute nodes have internet and cansbatch) or, minimally, undertmuxwith the caveat that reboots still orphan jobs. Pinlocal-cores: 4in the profile (it otherwise defaults to the node's ~192 visible CPUs; under the local executor it caps localrule/aggregation concurrency). Add ansp cancelverb: in one-allocation mode there are no per-job SLURM jobs, so it cancels the allocation itself; the deferred slurm-executor variant is ascancel-by-run-UUID sweep (squeue --me --format=%j | grep <uuid> | xargs scancel). Either way the rule is the same and mandatory: never--unlock/--rerun-incompletewhile any process of the dead run may still be writing.Stated plainly: this mode is single-node. Multi-node scaling = multiple tile-disjoint
snakemakeinvocations, each its own allocation — explicitly deferred. First pass is single-node subsets and P0–P2 scale. (The slurm-executor path — login-node snakemake submitting onesbatchper rule via the apptainer SDM — is not hypothetical: a probe job ran it end-to-end on nibi and completed. It stays available from the same Snakefile as a later scaling knob; deferred, not designed out.)ngmix straggler fix (the 13-hour chunk on the 50-tile run), retained and sharpened:
N_SPLIT_MAX == chunk countintile_merge_cats(empty/short chunks tolerated by the merge).input:of each chunk job, and the wrapper computes that chunk's ID range at execution time from the tile's own object count — equal shares, closed ranges, no open-ended final chunk (the real root cause:ID_OBJ_MAX=-1on the last chunk). The wrapper writes the computed range into the chunk's generated config copy (ID_OBJ_MIN/ID_OBJ_MAX). (Aparams:function can't do this — params evaluate at DAG-construction, before the sexcat exists.)tiles/<ID>/output/tree, and concurrentupdate_runs_log_file.pytruncate-rewrites are a lost-update race that drops a sibling's just-appended entry. Concurrent appends (whatshapepipe_rundoes natively) and reads are proven safe; only the rewrite races. Chunk configs use literalINPUT_DIRs (./output/run_sp_tile_Sx|PiViVi|Mhetc., deterministic under RUN_DATETIME=False), bypassing the log entirely.merge_sep_cats— DAG-serialized after all chunks — is the single per-tile log-sync point beforemake_catreads the log. Design rule:log_run_sp.txtis shared mutable state within a unit tree; only DAG-serialized stages may rewrite it; concurrent stages may only append.MALLOC_ARENA_MAX=2, MALLOC_TRIM_THRESHOLD_=0, OMP/OPENBLAS/MKL_NUM_THREADS=1.D5. One profile; Snakemake owns the containers
profiles/nibi/config.yamlcarries everything site-specific; rules carry only intrinsic needs (threads,mem_mb,runtime):Containers: Snakemake wraps every job in
apptainer execautomatically — the workflow never calls apptainer itself. Rules carry acontainer:directive pointing at the local/project.sif(/project/def-mjhudson/cdaley/containers/shapepipe-develop-runtime.sif), neverdocker://— no apptainer-prefix cache, no registry pull mid-campaign (findings 15/2). The profile setssoftware-deployment-method: apptainerand the args above, and Snakemake wraps each command. This kills the hand-rolledin_container/apptainer_noslurmmachinery. Per-rule containers are allowed → modularity (default to the project SIF; a different SIF per stage is permitted).Container environment is explicit, because the SDM drops the proven recipe (findings 2/3). Snakemake's apptainer SDM generates
apptainer execwith no--cleanenv, so only what the submitting shell exported would pass through, and it hardcodes--home <cwd>(singularity.py:143), which would set$HOMEto the workflow workdir and hide~/.ssl/cadcproxy.pemfrom vos/vcp — everyget_imagesdownload would fail auth. The profile's args fix all three:--home /home/cdaleyis appended after snakemake's hardcoded--home <cwd>and wins (verified on apptainer 1.4.5), restoring$HOMEso~/.ssl/cadcproxy.pemis visible (and astropy/vos caches don't collide in the shared workdir).--env OMP_NUM_THREADS=1caps the sif's scipy-openblas pool (verified 32→1); without it 40 forked CCD workers each size a pool to the 192-core node.--cleanenvis stricter than the proven recipe but safe:APPTAINERENV_*survive it (Snakemake'senvvars:keeps working) and the wrapper'sSP_RUN/SP_CONFIG/SP_EXPexports happen inside the container.--env PYTHONPATH=<pinned checkout>/srcis set for P0 (finding 2c): P0 validates science content against bash runs that use a worktree src override, so P0 must run known code. The pin must be a frozen checkout at a named commit, never a moving branch checkout — the live p3-batch1 run demonstrated the failure (itsshapepipe-prodworktree switched branches mid-run, so late stages run different code than early ones). Production later rebuilds the sif at the exact validation commit and drops the override — which-code-runs is a decision, never a build-time accident.A P0 smoke rule, run inside a submitted SLURM job, prints
shapepipe.__file__,sys.path, andthreadpoolctl.threadpool_info()to prove code precedence and thread caps before any science validation, and one containerizedtile_get_imagesruns end-to-end before anything else in phase A (proves cert visibility).The launcher owns
module load apptainer(finding 20). apptainer is a cvmfs module, not on the default PATH; snakemake resolves the binary at job runtime viashutil.which, so a snakemake started from any shell without the module loaded fails every job after burning queue time. A committed launcher (workflow/bin/sp, or a Makefile target) doesmodule load apptainer/1.4.5beforeexec-ingsnakemake --profile profiles/nibi— the documented entry point, so PATH never depends on shell state. It is also the path P0 exercises.Group-compatibility is a design constraint (finding 22). Per-unit-per-stage granularity at DR6 is ~120–170k SLURM jobs/campaign — not yet priced against the proven single-node monolith (14
shapepipe_runcalls for 50 tiles). Snakemake's designed answer isgroup:fusion, but only if rules stay fusable: no mid-chain localrules, no pipe/service outputs in the tile/exposure chains (currently true — state it so it stays true), makinggroup:a pure profile-level knob. P0 measures the granularity cost: DAG build time/memory for a synthetic index at 2–4k-tile scale (snakemake -non fake index rows), per-job overhead (apptainer cold-start +shapepipe_runstartup vs stage runtime) on the shortest rules (exp_split,tile_merge_headers,tile_merge_cats), and a one-line check of nibi'sMaxSubmitJobs/short-job policy. If short-rule overhead exceeds ~20–30% or scheduler load is unfriendly, setgroup:labels (fusemerge_headers→mask+detect→vignetsper tile, or the exposureSp→Ma→psfchain) in the profile — no rule or contract change,directory()outputs and failure classes unaffected.CANFAR is a future profile — a one-line note, not implemented here. When it comes it is a second
profiles/canfar/(cluster-generic wrapping the skaha REST callscurl_canfar_local.shalready makes); the Snakefile is unchanged.D6. Repo layout, storage, sharing, migration, and the complexity budget
Pin
snakemake>=9,<10(latest 9.23.1). v8→v9 breaking changes are minor for us:--use-singularitybecame--sdm, executors are now plugins (the local executor we use stays built-in), the full rerun-triggers set is the default.Storage placement (finding 15).
/scratchpurges by age; a P3 campaign spans months. So durable, low-volume products —final_cat-<ID>.fits,index/run_index.sqlite,index/missing.json, reports — land on/project; bulk intermediates (per-CCD split/mask/PSF run dirs) live on/scratch, each batch sized to complete well inside the ~60-day purge. The Snakemake workdir (.snakemake/— DAG metadata + incomplete-markers) is explicitly on/projectso purge never breaks the mtime chain or resurrects finished-tile reruns.Sharing model is a contract, not a convenience (finding 16). Dedup over a shared output root is per-invocation DAG semantics only. The contract: exactly one Snakemake workdir (one
.snakemake) per output root, so all invocations share provenance metadata and the workdir lock serializes them; invocations are strictly sequential and the launcher refuses--nolock(two concurrent invocations would each delete-before-rerun a directory the other is reading/writing). Even sequentially, under full rerun-triggers, code/params drift between invocations would mark run A's finished exposure dirs stale and delete-before-rerun their RNG-bearing products. Sofinal_catand completed exposure-product directories areprotected()—check_protected_outputfires before deletion, converting a silent destructive recompute into a loud failure exactly when the full-rerun-trigger × delete-before-rerun combination would otherwise strike. Intentional recomputation routes only through thesp rerun(--forcerun) verb. (In Snakemake 9 a cross-workdir invocation is not itself a rerun hazard — missing metadata is informational, excluded from rerun reasons — but it silently drops provenance tracking, which is its own reason to forbid it.)Is this a fourth orchestration generation? The honest accounting:
sp_rule.py+build_index.py+ the index + the report replace ~2000 lines across three bash levels plus each site's sbatch wrapper. The wrapper is smaller than round 1 — no classifier, no whitelist, just unit setup, config copy, run-log, count-floor, tee — and everything it owns the bash layer already does, scattered. The claim to hold us to: net less code, in one place, testable, with the DAG delegated to a tool whose whole job is DAGs. If an implementation PR doesn't deliver that, this design has failed and we should say so.Phased migration, each phase independently useful, bash layers untouched until Martin retires them (P0/P1 run on a separate output tree — no shared
exp/with a bash-driven run during validation):shapepipe.__file__,sys.path,threadpool_info), one end-to-end containerizedtile_get_images(cert visibility), the nproc/cgroup + max-packing measurement, and the granularity measurement (DAG build cost, short-rule overhead,MaxSubmitJobs). Validate against a bash-driven run by scientific equivalence, not byte-identity (FITS DATE headers, setoolsrand_splitRNG, and ngmix stochasticity all break bytes) — product counts per stage, catalogue row counts, shape/PSF parameters to numerical tolerance. Add the 3-rule synthetic append test (run 2 tiles, append 1, dry-run must schedule nothing for finished tiles) to CI as the growth-invariant regression — it runs in seconds.final_cat; retirejob_p3_batch1.shfor new runs.sp clean-exposuresfor real.Open questions
workflow/at top level per Snakemake convention, or underscripts/next to the bash it replaces?Round-2 design (revised), grounded in
run_job_sp_canfar_v2.0.bash/job_sp_canfar_v2.0.bashondevelop, the im_sims Snakefile lineage (#766), and the nibi P3 runs (#808). Simplified after Cail's feedback on round 1 and a second research pass (Snakemake v9, nibi compute-node networking, the field's partial-failure idiom), then hardened against a second adversarial review. Living PRD — kept current as implementation proceeds.— Claude (Fable) on behalf of Cail
Footnotes
The deferred slurm-executor mode (per-rule
sbatchinstead of one big allocation) is not hypothetical — a probe completed end-to-end on nibi (login-node snakemake, onesbatchper rule via the apptainer SDM;slurm_account=def-mjhudson_cpu, per-rulecpus_per_task/mem/runtimefrom the same measured footprints). It runs from the same Snakefile; deferred to a later scaling decision, not designed out. ↩