Migrating from CARACal / Stimela 2¶
If you have a CARACal pipeline, the thing to understand first is what this project asks of you and what it doesn’t.
Your cab definitions carry over. cult-cargo YAML is loaded as-is,
including _include and _use (Loaders). Two
exceptions are documented below.
Your worker configuration does not port automatically. A CARACal worker
is a YAML schema plus a Python module that reads it; shinobi has no worker
concept and no enable: machinery. What replaces both is a function that
builds a Recipe. There is no converter, and this page is
not pretending otherwise – it is a mapping guide, and the port is manual.
The honest summary: you are trading a declarative worker config for Python you write yourself. That is a real cost, paid once per worker, and it buys you ordinary control flow instead of a config language.
Note
caracal2 is the reference port and the most useful thing to read alongside this page. It keeps the CARACal worker schemas verbatim and rebuilds only the worker bodies as shinobi recipes – which is the migration path this page describes, done for real across a dozen workers.
First: package-scoped _include needs package_roots¶
This is a breaking change and it will be the first thing you hit.
A schema that pulls in a shared base by package name –
libs:
_include: (caracal.schemas)caracal_base.yaml
– used to be resolved by importing caracal.schemas to find its
directory. shinobi no longer does that: importing a package named by a
config file executes its __init__.py, which is arbitrary code execution
from data (see SECURITY.md). Both loaders now resolve the dotted name
against an explicit mapping the caller supplies:
from pathlib import Path
from shinobi.loaders.worker_schema import load_worker_schema
import caracal.schemas
SCHEMA_DIR = Path(caracal.schemas.__file__).parent
ROOTS = {"caracal": SCHEMA_DIR.parent}
schema = load_worker_schema(SCHEMA_DIR / "transform_schema.yaml", package_roots=ROOTS)
Without it you get a ConfigLoadError naming the package and telling you
what to pass.
This is not a weakening of the rule. The point is that shinobi must not
import a name it read out of a YAML file; your own code importing your own
package, deliberately, is fine. One entry covers a whole tree –
resolve_package_root matches the longest registered prefix and descends
the remainder as subdirectories, so {"caracal": ...} resolves
(caracal.schemas) and (caracal.anything.else) alike.
The same applies to shinobi.loaders.yaml_cab.load_file/loads, which
have always worked this way.
Two cab-level limitations to check first¶
Before porting anything, check your cab library against these – both are deliberate, and both are better discovered now than halfway through.
dynamic_schemais not resolvedA cab whose real schema comes from a Python function (
dynamic_schema: some.module.make_schema) loads with a warning and whatever staticinputs:/outputs:it happens to have, which may be incomplete. Real cult-cargowsclean.yml,cubical.ymlandquartical.ymlall use it. Hand-authored full ports of those three live in dosho; prefer them over the cult-cargo originals.- Only
binarycabs execute Code-carrying flavours –
flavour: python, inline source – are refused withUnsupportedFlavourErrorrather than run.msutils.copycolandbdsf.catalogare the ones CARACal users hit. dosho has native equivalents for the common cases.
Worker to Recipe, side by side¶
Take CARACal’s transform worker: split and average an MS, optionally
re-centre it, producing a new MS for downstream workers.
The configuration you write is unchanged – shinobi loads that schema and validates against it exactly as before:
transform:
enable: true
field: target
tag: cal
split_field:
enable: true
col: corrected
time_avg: '8s'
chan_avg: 4
changecentre:
enable: false
What changes is the worker body. In CARACal, enable: flags are consumed
by a worker module that appends stimela recipe steps as it walks the config.
In shinobi, they are just if statements in a function that returns a
Recipe:
import shinobi
from shinobi import Recipe
from dosho.cabs.casatasks import fixvis, mstransform
def build_recipe(config) -> Recipe:
"""`config` is an instance of the schema's generated inputs model."""
recipe = Recipe(
name=schema.name,
info=schema.info,
inputs_model=schema.inputs_model,
outputs_model=schema.outputs_model,
)
# The worker-level `enable:` flag is an early return, not a framework
# concept. A disabled worker is an empty recipe.
if not config.enable:
return recipe
current_ms = recipe.inputs.ms
# Each segment's own `enable:` is an ordinary branch. The step is
# only added to the graph when the branch is taken -- there is no
# "declared but skipped" state to reason about.
if config.split_field.enable:
seg = config.split_field
recipe.add_step(
"split_field",
mstransform,
vis=current_ms,
outputvis=produced_ms_name,
field=recipe.inputs.field,
datacolumn=seg.col,
timeaverage=seg.time_avg not in ("", "0s", None),
timebin=seg.time_avg or "0s",
chanaverage=(seg.chan_avg or 1) > 1,
chanbin=seg.chan_avg or 1,
)
current_ms = recipe.outputs.split_field.outputvis
if config.changecentre.enable:
recipe.add_step("changecentre", fixvis, vis=current_ms, phasecenter=config.changecentre.ra_dec)
current_ms = recipe.outputs.changecentre.vis
recipe.set_output("ms", current_ms)
return recipe
The current_ms variable is doing the work that CARACal’s
label/alias-propagation machinery does: each enabled segment rebinds it, so
the next step wires from whatever actually ran. That is the whole
substitution mechanism, and it is a local variable.
A second worker: flag¶
transform shows the shape. flag shows what it looks like at length,
and it is the more typical case: a dozen independent flagging segments, each
with its own enable:, all operating on the same MS.
The config is again unchanged:
flag:
enable: true
field: calibrators
flag_autocorr:
enable: true
flag_quack:
enable: true
interval: 8.0
mode: beg
flag_shadow:
enable: true
tol: 0.0
flag_spw:
enable: true
chans: '*:850~900MHz'
and every segment collapses to the same four lines:
if config.flag_autocorr.enable:
recipe.add_step("flag_autocorr", flagdata, vis=current_ms, mode="manual", autocorr=True, field=selected_field, flagbackup=False)
current_ms = recipe.outputs.flag_autocorr.vis
if config.flag_quack.enable:
recipe.add_step(
"flag_quack",
flagdata,
vis=current_ms,
mode="quack",
quackinterval=config.flag_quack.interval,
quackmode=config.flag_quack.mode,
field=selected_field,
flagbackup=False,
)
current_ms = recipe.outputs.flag_quack.vis
if config.flag_shadow.enable:
recipe.add_step("flag_shadow", flagdata, vis=current_ms, mode="shadow", tolerance=config.flag_shadow.tol, field=selected_field, flagbackup=False)
current_ms = recipe.outputs.flag_shadow.vis
There is no cleverness to find here, and that is the point – porting a worker is mechanical once the pattern is in hand. Three things are worth noticing:
“Executed in the same order in which they are given below.” CARACal’s
flag schema has to promise that in prose, because the order lives in the
framework. Here the order is the order you wrote the if statements in,
and current_ms threading through them is what makes it real rather than
conventional.
A segment can depend on more than its own flag. flag_time only runs
when it was enabled and actually given a range:
if config.flag_time.enable and config.flag_time.timerange:
...
In a config language that is a second schema rule someone has to express and
enforce. In Python it is and.
Pass the mutated MS through as an output. flagdata flags the MS in
place – but its step declares vis as an output as well as an input,
so each segment wires from the previous one’s vis rather than all of them
naming the same file independently.
Do this. It is the single most valuable habit to carry into a port, because it turns an in-place mutation into a real edge in the graph:
the scheduler orders the segments because they are genuinely dependent, not because they happen to be declared in that sequence;
ninja run --dryrunshows the chain;the recipe stays offloadable to a cluster, where declaration order means nothing and only edges do (Offloading to a cluster).
The alternative – every segment taking vis=recipe.inputs.ms and relying
on in-place mutation – also works locally at the default
max_workers: 1, and shinobi will derive the ordering for you when
compiling to Slurm. But you get a graph that says the steps are
independent when they are not, and you are relying on inference where you
could simply have stated the dependency.
The mapping, item by item¶
CARACal / Stimela 2 |
shinobi |
|---|---|
A worker (schema + module) |
A function returning a |
|
|
|
An |
Alias / label propagation between steps |
A Python variable rebound as steps are added |
|
Same syntax, but see package_roots |
|
Unchanged |
|
Not implemented; it is ordinary Python here |
|
Not resolved – use a dosho port |
|
Refused – use a dosho port |
Worker ordering in the config file |
Declaration order, plus real wiring edges (Recipes) |
Running the pipeline |
|
Keeping your schemas¶
You do not have to give up the schema files. load_worker_schema turns a
scabha-dialect worker schema into pydantic models
(Loaders), so the config surface, its defaults, its
choices and its info strings stay exactly where they are – and you
get CLI options generated from them for free.
That is the incremental path, and the one caracal2 took: keep every schema, port one worker body at a time, and leave the rest of the pipeline alone while you do it.
What has no equivalent yet¶
Stated plainly, so you can judge before committing:
No resume-from-step. The opt-in cache (Provenance) approximates it by skipping steps whose inputs are unchanged, but there is no
--start-fromflag.No per-step retry/backoff for flaky cluster jobs.
No aggregated run report artifact beyond the run manifest.
Offload assumes a shared filesystem. There is no data-staging story for clusters without one (Offloading to a cluster).
Scatter is not offloadable – scattered recipes run locally.