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 (run_job_sp_canfar_v2.0.bash → job_sp_canfar_v2.0.bash, plus per-site sbatch reimplementations) with a Snakemake workflow in this repo. Rules call shapepipe_run -c <config> on the existing config chains, at the granularities the bash layer already uses; Snakemake's file-based DAG takes over the state the bash layer hand-rolls — completeness counts, done-logs, flock dedup, retry/force. Module code stays largely untouched. Once the DAG is explicit, "why did this tile fail, what is safe to rerun" become answerable instead of archaeological. Living PRD; the eventual PR just says "implements #848." Please keep poking holes.
Pipeline shape
🟦 tile-level 🟧 exposure-level (shared across tiles) 🟪 index 🟩 cleanup
flowchart TD
subgraph S1["invocation 1 · PREPARE — one chain per tile (×~7300 at DR6)"]
Git["tile_get_images<br/>(download)"] --> Uz[uncompress weight] --> Fe["find_exposures<br/>(read header)"]
end
subgraph S3["invocation 2 · COMPUTE"]
IX[("parse time: dedup union of<br/>exposure lists → index.sqlite")]
subgraph EX["one chain per DEDUPED exposure (×~20k) — each built once, shared by ~4 tiles"]
Gie[exp_get_images] --> Sp[split into 40 CCDs] --> Ma[exp_mask] --> Psf["exp_psf: SExtractor → setools → PSFEx → PSF interp"]
end
subgraph TQ["one chain per tile (×~7300)"]
Mh[tile_merge_headers] --> Sxt["tile_detect<br/>(SExtractor)"] --> Vi["tile_vignets<br/>(PSF interp + stamps)<br/>🗑 vignettes after ngmix"]
Vi --> Ng1["ngmix chunk 1"]
Vi --> Ngd["⋯"]
Vi --> NgN["ngmix chunk N"]
Ng1 --> Ms["merge_sep_cats — gather<br/>🗑 chunks after merge"]
Ngd --> Ms
NgN --> Ms
Ms --> Mc["make_cat → final_cat ×1 per tile"]
end
Vi --> Cl["🗑 clean_exposure ×1 per exposure:<br/>fires when ALL its tiles' vignettes exist"]
end
Fe --> IX
IX -.->|defines the job set| Gie
Psf -->|"PSF models, stamps"| Vi
Sp -->|headers| Mh
Psf -.-> Cl
classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c
classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a
classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c
classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a
class Git,Uz,Fe,Mh,Sxt,Vi,Ng1,Ngd,NgN,Ms,Mc tile
class Gie,Sp,Ma,Psf exp
class IX idx
class Cl clean
Loading
Each box is one Snakemake job: one shapepipe_run on one unit (tile or exposure); modules parallelize over CCDs/objects inside the job. Arrows are declared input: edges; the scheduler orders everything from them, including the tile↔exposure fan and the rolling cleanup. Deletion (🗑) rolls forward with the run — each product is removed when its last reader finishes: the exposure store once its tiles' vignettes exist, the vignettes once ngmix has read them, the chunks at the merge (D5). Only clean_exposure is a rule; the rest is temp() bookkeeping by the scheduler, not jobs. The prepare products and final_cat persist.
Design
D1. Execution model: sp run is the entry point
A run is declared by a tile list. sp run brings the products on disk up to date with it. It is two snakemake invocations, because the exposure job set is determined from tile headers and a Snakemake DAG is fixed at parse time:
sp run [--tiles tiles.txt] [--profile nibi] [-n] [snakemake passthrough…]
│
├─ 1. PREPARE snakemake prepare_all_tiles
│ Static per-tile DAG, known from the tile list alone:
│ download image, unzip weight, find_exposures.
│ Cheap, wide, idempotent — already-prepared tiles are no-ops.
│
└─ 2. COMPUTE snakemake all
At parse time: read each tile's exposure list (paths determined by
the tile list), dedup the union into index.sqlite; tiles whose list
is missing land in missing.json and are skipped. Then the DAG:
exposure chains, tile chains, ngmix scatter/gather, final_cat per tile.
Snakemake flags (--jobs, -n, --forcerun, …) pass through to both invocations.
The two invocations are joined by a tile ↔ exposure index, built at parse time and persisted as index.sqlite. It accumulates across invocations, so the clean_exposure consumer sets span the whole campaign, not just the current tile list.
Each exposure is processed exactly once, and cleaned automatically once its tiles have extracted what they need. In miniature — a run requesting tiles A and B, where A's header lists exposures 101, 102 and B's lists 102, 103:
flowchart LR
subgraph P["PREPARE"]
TA["tile A prepared"]
TB["tile B prepared"]
end
TA -->|"101, 102"| IX
TB -->|"102, 103"| IX
IX[("index:<br/>101, 102, 103")]
subgraph C["COMPUTE"]
E1[exp 101 chain] --> A[tile A chain] --> FA[final_cat A]
E2["exp 102 chain (shared)"] --> A
E2 --> B[tile B chain] --> FB[final_cat B]
E3[exp 103 chain] --> B
A --> C1[clean exp 101]
A --> C2[clean exp 102]
B --> C2
B --> C3[clean exp 103]
end
IX -.-> E1
IX -.-> E2
IX -.-> E3
classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c
classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a
classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c
classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a
class TA,TB,A,B,FA,FB tile
class E1,E2,E3 exp
class IX idx
class C1,C2,C3 clean
Loading
Two tiles in, three exposure chains out (not four — 102 is deduplicated), built once each, read back by their tiles, cleaned once every consuming tile has extracted its stamps (D5).
D2. Rules call shapepipe_run directly — no wrapper
A rule is one shell line against a committed config, on one unit (tile or exposure), at the existing job-bit boundaries:
Everything the bash layer's per-unit machinery did dissolves into three mechanisms ShapePipe already has (verified against the code):
Committed configs. Each stage's deterministic output/run_sp_<prefix>/ is the rule's directory() output — Snakemake creates it and deletes it before reruns. Every INPUT_DIR is the literal upstream path (the ngmix configs already work this way), which retires the last: resolver and log_run_sp.txt machinery entirely. NUMBER_LIST = $SP_UNIT_NUM inline.
Env interpolation. Configs expand $VARS in every path field and fail loudly on unset ones. SP_CONFIG points at the committed config dir (no symlink), SP_RUN at the unit's work dir, SP_UNIT_NUM carries the unit ID.
After shapepipe_run exits, completeness.py check counts outputs against the committed floor table, writes the manifest, and exits by the floor (D3). Hand-running a unit is the same shell line. The two per-unit list files (tile_numbers.txt; the per-exposure list, a find_exposures product) are one-line upstream rule outputs. The on-the-fly mask chain runs with USE_EXT_STAR=False; an external-star variant is one committed INPUT_DIR plus a star-cat download rule.
The one worthwhile module addition, optional: a generic --set SECTION.KEY=VALUE on shapepipe_run (~15 lines at the single config chokepoint) as a uniform override surface. The design does not depend on it. The real migration work is the config sweep: ~13 committed configs rewritten with literal INPUT_DIRs, committed RUN_DATETIME, env'd NUMBER_LIST.
No rule declares a glob. Rows marked (phase A) build during PREPARE; everything else runs in COMPUTE.
Both stores are sharded — tiles/<2-digit-prefix>/<ID>/, exp/<prefix>/<expbase>/ — so no directory exceeds ~1k entries at full-UNIONS scale. Sharding serves humans and the filesystem's metadata server; every path the DAG uses comes from the index. Prefix digits are chosen for uniformity against the real exposure list.
per-tile $SP_EXP view — convenience, not a DAG edge
tile_merge_headers
tile
config_tile_Mh_exp.ini
.../log_exp_headers-<IDra>-<IDdec>.sqlite
tile_detect
tile
uc / sx_nomask / sx+onthefly-mask
run_sp_tile_<Uc|Sx>/
tile_vignets
tile
config_tile_PiViVi_*.ini
run_sp_tile_PiViVi/
tile_ngmix
tile × chunk
config_tile_Ng template, env ID range
run_sp_tile_ngmix_Ng<k>u/
tile_merge_cats
tile
config_merge_sep_cats.ini
run_sp_tile_msc/
tile_make_cat
tile
config_tile_Mc_*.ini
final_cat-<IDra>-<IDdec>.fits
tile_merge_headers output carries the tile suffix because downstream stages consume it via NUMBERING_SCHEME -000-000. tile_detect has three variants (uc, sx_nomask, sx+onthefly-mask); the config selector carries the choice, and DR6 will standardise on one. The data/image_sims axis is kept, but sims parity is a named follow-up; the first pass targets data.
Per-CCD stages declare one manifest file plus the product directory(). Each job writes a small manifest listing sub-units present/missing with failure reasons parsed from the module logs, checked against the committed threshold table (completeness.py). This keeps legitimate CCD attrition (masked, too few stars) below the declared-output layer, where Snakemake would hard-fail it, and keeps declared paths at DR6 in the thousands rather than millions. DES and Rubin landed on the same shape.
NUMBER_LIST is set only for stages whose NUMBERING_SCHEME is the unit ID; the committed configs for *_get_images/exp_mask/exp_psf omit it (a -<ID> entry fails #746 startup validation there, and a 40-entry expansion would turn tolerated per-CCD attrition into a whole-exposure failure).
DAG edges point at real exposure outputs. Rules that read exposure products (tile_merge_headers, tile_vignets, PSF-interp) declare the exposure directory() outputs as input:; tile_exp_forest builds a per-tile symlink view and points $SP_EXP at it purely so ShapePipe configs use one path — a convenience, never an edge.
D3. Failure handling: count floors
completeness.py check — the second half of every rule's shell line — counts outputs against per-runner floors and exits 0 above the floor, nonzero below. Zero output is always fatal. The thresholds are the ported bash complete_check table, committed in completeness.py (stdlib-only, imported by the check and the report); below is its human rendering.
:warn means a short count does not fail the unit (expected sparse-CCD PSF loss); :subpath counts a nested dir. So exp_psf tolerates <40 PSF-interp CCDs but fails hard on 0-of-40, a short SExtractor/PSFEx count, or a nonzero exit. Tolerated shortfalls pass, and the manifest records them.
Three mechanisms cover the three real situations:
Transient (vcp hiccups, node flakiness, OOM): per-rule retries: with attempt-scaled resources (mem_mb = base * attempt). A deterministic failure exhausts retries and fails.
Hard (real bug): the job exits nonzero. With keep-going: true, everything independent continues; only the failed downstream cone waits.
Below-floor-but-expected (too few stars on some CCDs): absorbed by :warn, recorded in the manifest. No separate class, no whitelist.
Rerun triggers stay at the v9 default {mtime, params, input, software-env, code}, so a changed config, param, or rule shell line reruns its cone natively.
sp report reads the index and the manifests and emits the success/failure tables — per tile, per exposure, per CCD, with a failure-reason column — the successor to summary_tiles.py. It is a standalone verb, runnable at any time including mid-run, so it can describe failures rather than be blocked by them; onsuccess/onerror handlers call it so every invocation ends with one. Job-level statistics (runtimes, provenance) come from Snakemake's own --report.
D4. Execution: the SLURM executor, one profile
Snakemake submits one SLURM job per rule (--executor slurm), each with its rule's exact resources — cpus_per_task = threads, mem_mb, runtime — and each wrapped in apptainer exec by the SDM. SLURM enforces limits per job, so attempt-scaled retries are real requests, and the cluster backfills around our jobs instead of us renting idle cores. The profile's jobs: cap sits at the cluster's per-user submission limit (queried at P1, not invented); snakemake feeds the queue as jobs finish, so DR6's ~170k total jobs never need to queue at once. Multi-node scaling is inherent. This mode ran end-to-end in a probe on nibi; P0's compute ran in a single-allocation variant, and P1 adopts the executor as the standard mode.
Two consequences to manage:
Short rules pay queue latency per job. Chunky rules (exp_psf, ngmix) amortize it; the minutes-short ones (uncompress, merges) fuse into their neighbours via group: labels in the profile, tuned at P1/P2. The chains are kept group:-fusable (no mid-chain localrules, no pipe outputs).
The orchestrator is a light, long-lived process. The full DR6 DAG parses in ~11 min / ~4 GB, so snakemake runs under tmux on the login node or in a 1-core long-walltime shepherd job. Ctrl-C scancels outstanding jobs (executor behaviour); an orphaned run's jobs are one manual scancel.
threads == SMP fork width. A rule's threads must equal ShapePipe's internal fork width, or the SLURM request decouples from the true process count. Enforced mechanically: every rule passes -b {threads}, so the two are one number by construction. mem_mb = threads × measured per-worker footprint (ngmix ≈ 3–4 GB/worker after #843).
ngmix scatter/gather (the fix for the 13-hour straggler chunk):
Chunking uses Snakemake's native scattergather directive. Chunk count is a parse-time constant, overridable with --set-scatter; N=1 recovers one ngmix job per tile.
Each chunk's rule computes its ID range at execution time from the tile's own object count: equal shares, closed ranges, no open-ended final chunk (the old root cause: ID_OBJ_MAX=-1 on the last chunk). A params: function cannot do this — params evaluate before the sexcat exists.
Chunks write nothing shared; each writes its own run_sp_tile_ngmix_Ng<k>u/, and merge_sep_cats — DAG-serialized after all chunks — is the gather.
RNG. Chunking is only safe with SEED_FROM_POSITION=True (#796): each object's RNG derives from its own (ra, dec, ccd), so results are bit-identical under any chunking — N is a pure throughput knob and every rerun reproduces. It becomes the production mode; the tile-seed mode (one ordered stream across the tile, results dependent on chunk boundaries) is retired. Chunking is on by default: smaller per-job memory and disk high-water mark, better packing, N tuned at P1.
benchmark: on the heavy rules (ngmix, exp_psf) gives per-unit wall/RSS/CPU TSVs — the feed for the measured mem_mb values above.
# profiles/nibi/config.yamlexecutor: slurmjobs: <per-user MaxSubmitJobs> # query the cluster at P1; do not inventdefault-resources: {mem_mb: 2000, runtime: 2h, slurm_account: def-mjhudson}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)
Snakemake wraps every job in apptainer exec itself; the workflow never calls apptainer. Rules point container: at the local /project.sif, never docker:// — no registry pull mid-campaign. This retires the hand-rolled in_container machinery.
The apptainer-args line carries the proven container recipe: $HOME for the CADC cert, thread/arena caps (one openblas pool per worker), and the P0 PYTHONPATH pin at a frozen commit; production rebuilds the sif at the validated commit and drops the pin. The committed launcher (workflow/bin/sp) runs module load apptainer before exec-ing snakemake.
CANFAR is a future profiles/canfar/ wrapping the skaha REST API. The Snakefile is unchanged.
D5. Storage and reclamation
Storage placement. Durable, low-volume products (final_cat, the index, reports, .snakemake/) belong on /project, so scratch purge never resurrects finished-tile reruns. Bulk intermediates live on /scratch, each batch sized to finish inside the ~60-day purge. (/project placement is currently blocked by group quota.)
Disk economy. Un-cleaned intermediates measure ~34 GB/tile — at DR6 (~7300 tiles), beyond any scratch allocation — so intermediates are reclaimed during the campaign. Two tiers, split by who sees each product's full consumer set:
Intra-tile intermediates → temp(). Their consumer set closes inside one invocation, so native temp() reclaims them when the last reader finishes. --notemp keeps them for debugging.
Cross-tile exposure store → in-DAG clean_exposure rules. The index lists every consuming tile in the campaign, so one generated clean_exposure rule per exposure takes every consumer's vignette output — the last stage that reads exposure products; everything after it reads tile-level files — as input:, deletes the exposure's intermediates, and leaves a tombstone. Writer, then readers, then cleaner — DAG-ordered, race-free, rolling mid-run while ngmix still grinds; only exposures at the processing frontier stay pinned. It is temp() semantics applied to the campaign's consumer set instead of one invocation's.
A clean: config flag gates the clean rules; flipping it later reclaims retroactively, since the missing tombstones schedule exactly the clean jobs. A tile appended after its exposures were cleaned reschedules those chains — the accepted cost of a late append.
One Snakemake workdir per output root; invocations strictly sequential. All invocations share provenance metadata, and the workdir lock serializes them; the launcher refuses --nolock. Position-seeded RNG (D4) makes every product bit-reproducible, so an unintended recompute costs compute time, not correctness — -n before a real run shows exactly what a code/params drift would rerun.
Pin snakemake>=9,<10 plus snakemake-executor-plugin-slurm. The v8→v9 breaks are minor here: --use-singularity → --sdm, executors became plugins, full rerun-triggers is the default.
The line-count check: four small scripts (index, forest, report, completeness table) plus committed configs replace ~2000 lines across three bash levels, each site's sbatch wrapper, and the run-log machinery. Less code, in one place, testable, with the DAG delegated to a tool whose whole job is DAGs. If an implementation PR does not deliver that, this design has failed.
Phased migration, each phase independently useful, bash layers untouched until Martin retires them (P0/P1 use a separate output tree — no shared exp/ with a bash-driven run during validation):
P0 — prepare + exposure stages on a 4-tile P3 subset. Delivered — see the results comment below.
P1 — full tile chain through final_cat; retire job_p3_batch1.sh for new runs. Validation is statistical parity against the bash baseline: join on object ID, science columns within tolerances — not bit parity (the RNG mode deliberately changes, D4).
P2 — scale via batched invocations sized to scratch purge windows (50-tile → P3 → ~7300 tiles); exercise the count-floor, retries, rerun propagation, and rolling clean_exposure reclamation for real; tune jobs: and group: fusion against measured queue behaviour.
P3 — CANFAR profile.
Out of scope for the first pass (each a named follow-up): image_sims parity (the sims im_pipeline step will call these same per-unit rules — the two pipelines converge on one workflow); the CANFAR profile.
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)? Decided at P2.
make_cat over partial tiles: if k of 40 CCDs failed PSF, the tile completes with fewer epochs. Keep that (report records the gaps), or add a quality gate?
ngmix chunk configs: one committed template with env-interpolated ID range ($NGMIX_ID_MIN/$NGMIX_ID_MAX, exported by the chunk rule) should replace generated per-chunk configs entirely — confirm the range fields interpolate.
Repo layout: workflow/ at top level per Snakemake convention, or under scripts/ next to the bash it replaces?
Snakemake orchestration for the real-data pipeline
Summary
Replace the shell-script orchestration of real-data ShapePipe runs (
run_job_sp_canfar_v2.0.bash→job_sp_canfar_v2.0.bash, plus per-site sbatch reimplementations) with a Snakemake workflow in this repo. Rules callshapepipe_run -c <config>on the existing config chains, at the granularities the bash layer already uses; Snakemake's file-based DAG takes over the state the bash layer hand-rolls — completeness counts, done-logs,flockdedup, retry/force. Module code stays largely untouched. Once the DAG is explicit, "why did this tile fail, what is safe to rerun" become answerable instead of archaeological. Living PRD; the eventual PR just says "implements #848." Please keep poking holes.Pipeline shape
🟦 tile-level 🟧 exposure-level (shared across tiles) 🟪 index 🟩 cleanup
flowchart TD subgraph S1["invocation 1 · PREPARE — one chain per tile (×~7300 at DR6)"] Git["tile_get_images<br/>(download)"] --> Uz[uncompress weight] --> Fe["find_exposures<br/>(read header)"] end subgraph S3["invocation 2 · COMPUTE"] IX[("parse time: dedup union of<br/>exposure lists → index.sqlite")] subgraph EX["one chain per DEDUPED exposure (×~20k) — each built once, shared by ~4 tiles"] Gie[exp_get_images] --> Sp[split into 40 CCDs] --> Ma[exp_mask] --> Psf["exp_psf: SExtractor → setools → PSFEx → PSF interp"] end subgraph TQ["one chain per tile (×~7300)"] Mh[tile_merge_headers] --> Sxt["tile_detect<br/>(SExtractor)"] --> Vi["tile_vignets<br/>(PSF interp + stamps)<br/>🗑 vignettes after ngmix"] Vi --> Ng1["ngmix chunk 1"] Vi --> Ngd["⋯"] Vi --> NgN["ngmix chunk N"] Ng1 --> Ms["merge_sep_cats — gather<br/>🗑 chunks after merge"] Ngd --> Ms NgN --> Ms Ms --> Mc["make_cat → final_cat ×1 per tile"] end Vi --> Cl["🗑 clean_exposure ×1 per exposure:<br/>fires when ALL its tiles' vignettes exist"] end Fe --> IX IX -.->|defines the job set| Gie Psf -->|"PSF models, stamps"| Vi Sp -->|headers| Mh Psf -.-> Cl classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a class Git,Uz,Fe,Mh,Sxt,Vi,Ng1,Ngd,NgN,Ms,Mc tile class Gie,Sp,Ma,Psf exp class IX idx class Cl cleanEach box is one Snakemake job: one
shapepipe_runon one unit (tile or exposure); modules parallelize over CCDs/objects inside the job. Arrows are declaredinput:edges; the scheduler orders everything from them, including the tile↔exposure fan and the rolling cleanup. Deletion (🗑) rolls forward with the run — each product is removed when its last reader finishes: the exposure store once its tiles' vignettes exist, the vignettes once ngmix has read them, the chunks at the merge (D5). Onlyclean_exposureis a rule; the rest istemp()bookkeeping by the scheduler, not jobs. The prepare products andfinal_catpersist.Design
D1. Execution model:
sp runis the entry pointA run is declared by a tile list.
sp runbrings the products on disk up to date with it. It is two snakemake invocations, because the exposure job set is determined from tile headers and a Snakemake DAG is fixed at parse time:Snakemake flags (
--jobs,-n,--forcerun, …) pass through to both invocations.The two invocations are joined by a tile ↔ exposure index, built at parse time and persisted as
index.sqlite. It accumulates across invocations, so theclean_exposureconsumer sets span the whole campaign, not just the current tile list.Each exposure is processed exactly once, and cleaned automatically once its tiles have extracted what they need. In miniature — a run requesting tiles A and B, where A's header lists exposures 101, 102 and B's lists 102, 103:
flowchart LR subgraph P["PREPARE"] TA["tile A prepared"] TB["tile B prepared"] end TA -->|"101, 102"| IX TB -->|"102, 103"| IX IX[("index:<br/>101, 102, 103")] subgraph C["COMPUTE"] E1[exp 101 chain] --> A[tile A chain] --> FA[final_cat A] E2["exp 102 chain (shared)"] --> A E2 --> B[tile B chain] --> FB[final_cat B] E3[exp 103 chain] --> B A --> C1[clean exp 101] A --> C2[clean exp 102] B --> C2 B --> C3[clean exp 103] end IX -.-> E1 IX -.-> E2 IX -.-> E3 classDef tile fill:#cfe2f3,stroke:#4a86c8,color:#1a3a5c classDef exp fill:#fce5cd,stroke:#e69138,color:#5c3a1a classDef idx fill:#d9d2e9,stroke:#8e7cc3,color:#3a2a5c classDef clean fill:#d9ead3,stroke:#6aa84f,color:#2a4a1a class TA,TB,A,B,FA,FB tile class E1,E2,E3 exp class IX idx class C1,C2,C3 cleanTwo tiles in, three exposure chains out (not four — 102 is deduplicated), built once each, read back by their tiles, cleaned once every consuming tile has extracted its stamps (D5).
D2. Rules call
shapepipe_rundirectly — no wrapperA rule is one shell line against a committed config, on one unit (tile or exposure), at the existing job-bit boundaries:
Everything the bash layer's per-unit machinery did dissolves into three mechanisms ShapePipe already has (verified against the code):
output/run_sp_<prefix>/is the rule'sdirectory()output — Snakemake creates it and deletes it before reruns. EveryINPUT_DIRis the literal upstream path (the ngmix configs already work this way), which retires thelast:resolver andlog_run_sp.txtmachinery entirely.NUMBER_LIST = $SP_UNIT_NUMinline.$VARSin every path field and fail loudly on unset ones.SP_CONFIGpoints at the committed config dir (no symlink),SP_RUNat the unit's work dir,SP_UNIT_NUMcarries the unit ID.-b {threads}already overridesSMP_BATCH_SIZE.After
shapepipe_runexits,completeness.py checkcounts outputs against the committed floor table, writes the manifest, and exits by the floor (D3). Hand-running a unit is the same shell line. The two per-unit list files (tile_numbers.txt; the per-exposure list, afind_exposuresproduct) are one-line upstream rule outputs. The on-the-fly mask chain runs withUSE_EXT_STAR=False; an external-star variant is one committedINPUT_DIRplus a star-cat download rule.The one worthwhile module addition, optional: a generic
--set SECTION.KEY=VALUEonshapepipe_run(~15 lines at the single config chokepoint) as a uniform override surface. The design does not depend on it. The real migration work is the config sweep: ~13 committed configs rewritten with literal INPUT_DIRs, committedRUN_DATETIME, env'dNUMBER_LIST.No rule declares a glob. Rows marked (phase A) build during PREPARE; everything else runs in COMPUTE.
Both stores are sharded —
tiles/<2-digit-prefix>/<ID>/,exp/<prefix>/<expbase>/— so no directory exceeds ~1k entries at full-UNIONS scale. Sharding serves humans and the filesystem's metadata server; every path the DAG uses comes from the index. Prefix digits are chosen for uniformity against the real exposure list.tile_get_images+tile_find_exposures(phase A)config_tile_Git_*,Uz,Ferun_sp_tile_Git/,run_sp_tile_Uz/,run_sp_tile_Fe/.../exp_numbers-<IDra>-<IDdec>.txtbuild_index.py, threshold-gatedindex/run_index.sqlite,index/missing.jsonexp_get_imagesconfig_exp_Gie_*run_sp_exp_Gie/exp_splitconfig_exp_Sp.inirun_sp_exp_Sp/exp_maskconfig_exp_Ma_onthefly.inirun_sp_exp_Ma/exp_psfconfig_exp_psfex.inirun_sp_exp_SxSePsfPi/tile_exp_forest$SP_EXPview — convenience, not a DAG edgetile_merge_headersconfig_tile_Mh_exp.ini.../log_exp_headers-<IDra>-<IDdec>.sqlitetile_detectrun_sp_tile_<Uc|Sx>/tile_vignetsconfig_tile_PiViVi_*.inirun_sp_tile_PiViVi/tile_ngmixconfig_tile_Ngtemplate, env ID rangerun_sp_tile_ngmix_Ng<k>u/tile_merge_catsconfig_merge_sep_cats.inirun_sp_tile_msc/tile_make_catconfig_tile_Mc_*.inifinal_cat-<IDra>-<IDdec>.fitstile_merge_headersoutput carries the tile suffix because downstream stages consume it viaNUMBERING_SCHEME -000-000.tile_detecthas three variants (uc,sx_nomask,sx+onthefly-mask); the config selector carries the choice, and DR6 will standardise on one. Thedata/image_simsaxis is kept, but sims parity is a named follow-up; the first pass targets data.Per-CCD stages declare one manifest file plus the product
directory(). Each job writes a small manifest listing sub-units present/missing with failure reasons parsed from the module logs, checked against the committed threshold table (completeness.py). This keeps legitimate CCD attrition (masked, too few stars) below the declared-output layer, where Snakemake would hard-fail it, and keeps declared paths at DR6 in the thousands rather than millions. DES and Rubin landed on the same shape.NUMBER_LISTis set only for stages whoseNUMBERING_SCHEMEis the unit ID; the committed configs for*_get_images/exp_mask/exp_psfomit it (a-<ID>entry fails #746 startup validation there, and a 40-entry expansion would turn tolerated per-CCD attrition into a whole-exposure failure).DAG edges point at real exposure outputs. Rules that read exposure products (
tile_merge_headers,tile_vignets, PSF-interp) declare the exposuredirectory()outputs asinput:;tile_exp_forestbuilds a per-tile symlink view and points$SP_EXPat it purely so ShapePipe configs use one path — a convenience, never an edge.D3. Failure handling: count floors
completeness.py check— the second half of every rule's shell line — counts outputs against per-runner floors and exits 0 above the floor, nonzero below. Zero output is always fatal. The thresholds are the ported bashcomplete_checktable, committed incompleteness.py(stdlib-only, imported by the check and the report); 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);:subpathcounts a nested dir. Soexp_psftolerates <40 PSF-interp CCDs but fails hard on 0-of-40, a short SExtractor/PSFEx count, or a nonzero exit. Tolerated shortfalls pass, and the manifest records them.Three mechanisms cover the three real situations:
retries:with attempt-scaled resources (mem_mb = base * attempt). A deterministic failure exhausts retries and fails.keep-going: true, everything independent continues; only the failed downstream cone waits.:warn, recorded in the manifest. No separate class, no whitelist.Rerun triggers stay at the v9 default
{mtime, params, input, software-env, code}, so a changed config, param, or rule shell line reruns its cone natively.sp reportreads the index and the manifests and emits the success/failure tables — per tile, per exposure, per CCD, with a failure-reason column — the successor tosummary_tiles.py. It is a standalone verb, runnable at any time including mid-run, so it can describe failures rather than be blocked by them;onsuccess/onerrorhandlers call it so every invocation ends with one. Job-level statistics (runtimes, provenance) come from Snakemake's own--report.D4. Execution: the SLURM executor, one profile
Snakemake submits one SLURM job per rule (
--executor slurm), each with its rule's exact resources —cpus_per_task = threads,mem_mb,runtime— and each wrapped inapptainer execby the SDM. SLURM enforces limits per job, so attempt-scaled retries are real requests, and the cluster backfills around our jobs instead of us renting idle cores. The profile'sjobs:cap sits at the cluster's per-user submission limit (queried at P1, not invented); snakemake feeds the queue as jobs finish, so DR6's ~170k total jobs never need to queue at once. Multi-node scaling is inherent. This mode ran end-to-end in a probe on nibi; P0's compute ran in a single-allocation variant, and P1 adopts the executor as the standard mode.Two consequences to manage:
group:labels in the profile, tuned at P1/P2. The chains are keptgroup:-fusable (no mid-chain localrules, no pipe outputs).scancel.threads == SMP fork width. A rule's
threadsmust equal ShapePipe's internal fork width, or the SLURM request decouples from the true process count. Enforced mechanically: every rule passes-b {threads}, so the two are one number by construction.mem_mb = threads × measured per-worker footprint(ngmix ≈ 3–4 GB/worker after #843).ngmix scatter/gather (the fix for the 13-hour straggler chunk):
scattergatherdirective. Chunk count is a parse-time constant, overridable with--set-scatter; N=1 recovers one ngmix job per tile.ID_OBJ_MAX=-1on the last chunk). Aparams:function cannot do this — params evaluate before the sexcat exists.run_sp_tile_ngmix_Ng<k>u/, andmerge_sep_cats— DAG-serialized after all chunks — is the gather.MALLOC_ARENA_MAX=2, MALLOC_TRIM_THRESHOLD_=0, OMP/OPENBLAS/MKL_NUM_THREADS=1.RNG. Chunking is only safe with
SEED_FROM_POSITION=True(#796): each object's RNG derives from its own(ra, dec, ccd), so results are bit-identical under any chunking — N is a pure throughput knob and every rerun reproduces. It becomes the production mode; the tile-seed mode (one ordered stream across the tile, results dependent on chunk boundaries) is retired. Chunking is on by default: smaller per-job memory and disk high-water mark, better packing, N tuned at P1.benchmark:on the heavy rules (ngmix,exp_psf) gives per-unit wall/RSS/CPU TSVs — the feed for the measuredmem_mbvalues above.Snakemake wraps every job in
apptainer execitself; the workflow never calls apptainer. Rules pointcontainer:at the local/project.sif, neverdocker://— no registry pull mid-campaign. This retires the hand-rolledin_containermachinery.The
apptainer-argsline carries the proven container recipe:$HOMEfor the CADC cert, thread/arena caps (one openblas pool per worker), and the P0PYTHONPATHpin at a frozen commit; production rebuilds the sif at the validated commit and drops the pin. The committed launcher (workflow/bin/sp) runsmodule load apptainerbefore exec-ing snakemake.CANFAR is a future
profiles/canfar/wrapping the skaha REST API. The Snakefile is unchanged.D5. Storage and reclamation
Storage placement. Durable, low-volume products (
final_cat, the index, reports,.snakemake/) belong on/project, so scratch purge never resurrects finished-tile reruns. Bulk intermediates live on/scratch, each batch sized to finish inside the ~60-day purge. (/projectplacement is currently blocked by group quota.)Disk economy. Un-cleaned intermediates measure ~34 GB/tile — at DR6 (~7300 tiles), beyond any scratch allocation — so intermediates are reclaimed during the campaign. Two tiers, split by who sees each product's full consumer set:
temp(). Their consumer set closes inside one invocation, so nativetemp()reclaims them when the last reader finishes.--notempkeeps them for debugging.clean_exposurerules. The index lists every consuming tile in the campaign, so one generatedclean_exposurerule per exposure takes every consumer's vignette output — the last stage that reads exposure products; everything after it reads tile-level files — asinput:, deletes the exposure's intermediates, and leaves a tombstone. Writer, then readers, then cleaner — DAG-ordered, race-free, rolling mid-run while ngmix still grinds; only exposures at the processing frontier stay pinned. It istemp()semantics applied to the campaign's consumer set instead of one invocation's.A
clean:config flag gates the clean rules; flipping it later reclaims retroactively, since the missing tombstones schedule exactly the clean jobs. A tile appended after its exposures were cleaned reschedules those chains — the accepted cost of a late append.One Snakemake workdir per output root; invocations strictly sequential. All invocations share provenance metadata, and the workdir lock serializes them; the launcher refuses
--nolock. Position-seeded RNG (D4) makes every product bit-reproducible, so an unintended recompute costs compute time, not correctness —-nbefore a real run shows exactly what a code/params drift would rerun.D6. Repo layout and migration
Pin
snakemake>=9,<10plussnakemake-executor-plugin-slurm. The v8→v9 breaks are minor here:--use-singularity→--sdm, executors became plugins, full rerun-triggers is the default.The line-count check: four small scripts (index, forest, report, completeness table) plus committed configs replace ~2000 lines across three bash levels, each site's sbatch wrapper, and the run-log machinery. Less code, in one place, testable, with the DAG delegated to a tool whose whole job is DAGs. If an implementation PR does not deliver that, this design has failed.
Phased migration, each phase independently useful, bash layers untouched until Martin retires them (P0/P1 use a separate output tree — no shared
exp/with a bash-driven run during validation):final_cat; retirejob_p3_batch1.shfor new runs. Validation is statistical parity against the bash baseline: join on object ID, science columns within tolerances — not bit parity (the RNG mode deliberately changes, D4).clean_exposurereclamation for real; tunejobs:andgroup:fusion against measured queue behaviour.Out of scope for the first pass (each a named follow-up): image_sims parity (the sims
im_pipelinestep will call these same per-unit rules — the two pipelines converge on one workflow); the CANFAR profile.Open questions
$NGMIX_ID_MIN/$NGMIX_ID_MAX, exported by the chunk rule) should replace generated per-chunk configs entirely — confirm the range fields interpolate.workflow/at top level per Snakemake convention, or underscripts/next to the bash it replaces?