File Naming and Output Layout¶
NeuroDAGs has no database: an output’s location and name are its identity, and a small
.done manifest next to the outputs records that a unit finished. This page lists every naming
rule, where outputs go, and when a unit counts as done. The rules are those of
naming_version: 1 (neurodags 0.4.0); neurodags validate reports problems before anything
runs.
NeuroDAGs is agnostic to your file-naming convention: it keeps your source file names (BIDS or
not) and only appends its own markers — @ (source/derivative separator), the derivative name
and the artifact suffix. Source file names are kept verbatim, including any @ or & they
contain.
Important
Changed in 0.4.0. Derivative names are used verbatim on disk (no CamelCase conversion),
output folders are computed from each dataset’s source_root (never from the set of discovered
files), and a unit is done only when its .done manifest is valid. Outputs written by 0.3.x have
no manifest: neurodags status reports them as incomplete and the next run recomputes them,
unless you convert the tree with neurodags migrate (see Migrating to 0.4).
Rules at a glance¶
Do |
Don’t |
|---|---|
Use names matching |
Use |
Quote YAML keys that YAML would read as something else: |
Write |
Use the YAML name everywhere: files, |
Define names that differ only in case ( |
Start custom node artifact keys with |
Use keys without a leading |
Make custom artifact writers return every file they write (see Writer contract). |
Write extra files from a writer without reporting them (they are discarded), or anywhere but next to (or below) the path the writer is given — the unit fails. |
Set |
Rely on the set of matched files to decide output folders — it never does since 0.4.0. |
Split dataframe column names with |
Split columns by hand on |
Treat |
Store your own files there — |
Output file names¶
Every saved artifact of a unit (one source file × one derivative) is written to
<reference_base>@<name><artifact suffix>
<reference_base>identifies the source file (see Output layout). It keeps the source’s own extension:sub-01_task-rest_eeg.vhdr@....@separates the source from the derivative: it is the last@of the file name. The source part is the source basename verbatim and may itself contain@(a chained output such assub-1.fif@CleanedEEG.fif@PowerSpectrum.nc), but names and suffixes never do, sobasename.rsplit("@", 1)gives(source part, name + suffix), andname, _, s = rest.partition(".")gives the name and suffix"." + s(neurodags.naming.parse_output_basename()). Do not split on the first@.<name>is the derivative name exactly as written inDerivativeDefinitions.<artifact suffix>is the key of each artifact the last node returns, appended verbatim:.fif,.nc,.json,.report.html,.EO_baseline.fif,.fif.gz. It must match^(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)+$, its last segment must not bedone,errororskip(any case), and two suffixes of one result must not differ only in case. Nor may one suffix be another with-<digits>inserted before the final extension (.EO.fifand.EO-1.fif, compared ignoring case): MNE writes the split parts of a large<prefix>.EO.fifas<prefix>.EO-1.fif,<prefix>.EO-2.fif, …, which the second artifact would overwrite (the artifact-level analogue of theClean/Clean-1name rule). Suffixes are checked before the first writer runs: an invalid key fails the unit with an.errormarker and writes nothing. As a second line of defence, a writer is not run when an earlier artifact of the unit already reported its target, and two artifacts reporting the same file fail the unit before anything reaches the final names (no.done).
Completion manifest (.done) and markers¶
Three marker files share the unit’s prefix; a unit has at most one of them:
File |
Written when |
Effect |
|---|---|---|
|
every artifact was written |
JSON manifest listing the unit’s files. The unit is done (cached) while it is valid. |
|
anything inside the unit failed: a node, a derivative it depends on, preparing a node’s arguments, selecting a referenced artifact, or writing an artifact |
Contains the error. The unit is retried on the next run unless |
|
a node raised |
The unit (and units depending on it) is skipped until the marker is deleted or |
The manifest:
{
"manifest_version": 1,
"naming_version": 1,
"derivative": "Fit_alpha-0p5_w-6",
"source": {"dataset": "study", "rel": "sub-01/eeg/sub-01_task-rest_eeg.vhdr"},
"artifacts": {".nc": ["sub-01_task-rest_eeg.vhdr@Fit_alpha-0p5_w-6.nc"]},
"companions": [],
"fingerprints": {"sub-01_task-rest_eeg.vhdr@Fit_alpha-0p5_w-6.nc": [52144, 1791799200123456789]},
"written": "2026-10-12T10:00:00Z",
"origin": "run"
}
artifactsmaps each suffix to the files it consists of: usually one, every part for an MNE split recording (X.fif,X-1.fif, …). File names are relative to the manifest’s folder, so a tree moved or copied to another mount (local ↔ HPC) still verifies.fingerprintsrecords[size, mtime_ns]of every listed file as this run wrote it (taken from the staged file before it was moved into place). Copy derivative trees with tools that keep modification times to the nanosecond (cp -aandrsync -aon Linux do): a plaincpgives the copies new mtimes, so their units no longer verify and are recomputed. A copy onto a filesystem that truncates timestamps (e.g. to whole seconds) also makes its units recompute; a changed timestamp never makes a unitdone.source.relis the source path relative tosource_rootwith its original spelling (including any@).
A unit is done (a cache hit) when its .done parses, its naming_version and derivative
match, every file it lists exists and, when the manifest has fingerprints, every file still
has exactly the recorded size and modification time (mtime_ns, compared to the
nanosecond). A file changed after publication (replaced by another process, edited, even with
the same size a moment later, or copied without its exact mtime) makes the unit incomplete
(“modified after publication”) and it is recomputed. Manifests without
fingerprints (written before 0.4.0 was released) are checked by existence only. Files that
match the prefix but are not listed are
ignored (and reported by neurodags status). There is no other cache check: a file named like
an output but without a valid manifest is not trusted.
Write order. On (re)compute NeuroDAGs deletes the old .done and any old .skip (before
computing anything, including the derivatives it depends on), validates the suffixes, runs
every writer inside a staging folder, moves the files into place, then publishes the new
.done atomically (a temporary file in the same folder renamed with os.replace) and removes
any .error. Writing an .error or .skip removes the other markers, including .done. A
crash, a failing writer or a failing dependency therefore never leaves a .done for a
half-written or failed recompute; the next run recomputes it.
save: false derivatives write neither outputs nor markers and are never a cache hit, also
when another derivative references them: an old manifest left from when the derivative was
saved is not read.
Atomic publication¶
Writers never write at the final file names. For each (re)computation of a unit, NeuroDAGs
creates a hidden staging folder next to the outputs,
.<reference_base basename>@<name>.tmp~<token>/, and calls every artifact writer with the
final basename inside it (.../.sub-01.vhdr@Clean.tmp~3f9c.../sub-01.vhdr@Clean.fif). When all
writers have finished, every reported file is moved to the same relative path in the output
folder with os.replace (atomic within one filesystem, including NFS): continuation files
(MNE split parts X-1.fif, …) first, each main file last. MNE links split parts by file
name, so moved parts stay linked. Only then is .done written. The staging folder is removed
afterwards, and also on any failure before a file was moved. Once a file has been moved,
anything that stops the unit before its .done exists (an exception, Ctrl-C, a kill) leaves
the staging folder in place: some files may be new and some old, and the folder is the
durable trace of that half-published unit.
Consequences:
No file under an output name is ever partially written: a process killed at any point (an exception, Ctrl-C,
SIGTERM,kill -9, a scheduler timeout) leaves no.doneover half-written or mixed files and nothing that blocks the next run.Each file of a unit and its fingerprint are checked against the
.done, so a unit whose files were replaced or mixed after publication is notdone(see above).Writers must write inside the folder of the path they are given, under names that start with the unit’s prefix (see Writer contract). A writer that reports a file elsewhere fails the unit, and files a writer writes without reporting them are discarded with the staging folder.
Not supported: two jobs on one unit at the same time. Publication is per file, not per unit, and readers do not hold a snapshot. Therefore:
Do not run two jobs that compute the same unit at once (a requeued task while the original still runs, overlapping arrays, two derivatives computing the same uncached intermediate in parallel). Interleaved publications can leave a unit’s files from two runs, even the parts of one split recording; the fingerprints then make the unit
incompleteand it is recomputed, but which run’s results you got is undefined until then.Do not overwrite a unit while another job reads it. A reader holds file names, not a snapshot: a lazily loaded MNE recording (
preload=False) can read new samples with old metadata, and a dataframe collection can mix columns from the old and the new results. Nothing detects that in the reader’s output.
Run dependencies before their consumers (the per-file pattern or chained arrays in HPC / SLURM Array Jobs) and finish or cancel jobs before overwriting units they use.
A process stopped before it could clean up leaves its staging folder behind. Staging folders
are ignored by discovery, status states, dataframe collection, migrate scans and the 0.3.x
output check; their names never parse as outputs (~ is not allowed in a suffix). They are
never deleted automatically, because a live one may belong to a job still running.
neurodags status reports how many exist and how many belong to units without a .done
(JSON: staging_dirs, per file with --include-files). Once no job is writing to the output
tree:
a staging folder of a unit without a valid
.donemay mark a half-published unit: recompute the unit (neurodags run) first.migrate --legacy-treetreats any staging folder as proof that 0.4.0 wrote the tree, so deleting it first could let a mixed unit be certified;a staging folder of a done (or skipped) unit is left over from an earlier, stopped run and can be deleted.
Unit states¶
neurodags status, the dry-run plan (state field of the derivative_output step) and the TUI
report each unit (one source file × one derivative) as:
State |
Meaning |
Next |
|---|---|---|
|
valid |
skipped (cached) |
|
|
skipped (cached) |
|
|
skipped |
|
|
retried, unless |
|
output files (or an invalid |
recomputed |
|
nothing on disk for the unit |
computed |
With a frozen input list (--inputs), a listed source file that no longer exists is reported
as missing-source at its index; it is a state of the input, not of a unit, and is never
recomputed. See Inspection and Visualization for the status output and exit codes.
Writer contract¶
Artifact.writer(path) receives the artifact path and returns the list of files it wrote, or
None meaning exactly path. Every reported file must exist afterwards (otherwise the unit
fails with .error), and must be in the same folder as path or below it. The manifest is
built from these reports, never from a directory listing.
path has the final basename (<reference_base basename>@<name><suffix>) but lies in the
unit’s staging folder, not in the output folder (see
Atomic publication). Derive every other file name from path
by appending to it (path + ".json", os.path.join(path + ".figs", "plot.png")), never from
the output folder or reference_base. Reported files are moved to the same relative place in
the output folder, and each must belong to the unit: the first component of its path
(relative to the output folder) must be the unit’s prefix <reference_base basename>@<name>
followed by . (or an MNE split tail -1, -2, …), or be exactly the prefix used as a
folder. So x.vhdr@Clean.fif.figs/plot.png is accepted, while figs/plot.png, which every
unit of the folder would share, fails the unit before anything is replaced. A reported file
outside the staging folder (also through a symbolic link) fails the unit, and so does a
reported file that is itself a symbolic link (report the file it points to); files reached
through a linked folder are published where they really are. A writer may read files
another artifact of the unit reported, but must not change them or open them for writing
(h5py or netCDF4 mode "a"/"r+", np.memmap mode "r+": even an open that changes
nothing updates the modification time). A reported file that is gone, now a link, or has
another size or modification time than when its own writer returned fails the unit; the
check compares this metadata, not the contents.
Unreported files are discarded. The
exception is MNE split recordings: when the staging folder holds a recording of which only
some files were reported (X.fif with X-1.fif, …, X.fif.gz with X.fif-1.gz, …, or
BIDS names that differ only in _split-NN_), the unit fails instead of certifying a
truncated recording. That happens with a writer that saves an MNE object larger than its
split size and returns None, or returns only part of the list MNE’s save() returns. Use
neurodags.writers.save_mne, which returns every part. A writer’s own scratch files must
not be named like split parts of a FIF it reports (X-1.fif next to X.fif, X-2.fif
next to X-1.fif even without X.fif, or a name that differs from it only in
_split-NN_): they are indistinguishable from a lost part. Writers
that ignore path and write somewhere fixed cannot be published atomically and are not
supported.
from pathlib import Path
def writer(path):
figs = Path(path + ".figs") # x.vhdr@Clean.fif.figs/: owned by this unit
figs.mkdir()
report.save(path)
plot.savefig(figs / "psd.png")
return [path, str(figs / "psd.png")]
MNE splits recordings larger than split_size (2 GB by default) into X.fif, X-1.fif,
X-2.fif, …; the first part names the next, so the parts belong together. The built-in
preprocessing nodes save MNE objects with neurodags.writers.save_mne(), which returns the
list of parts MNE reports having written. Use it in your own nodes:
from neurodags.definitions import Artifact
from neurodags.writers import save_mne
Artifact(item=epochs, writer=lambda path: save_mne(epochs, path))
Derivative names¶
Grammar (naming_version: 1):
name = LETTER ( [A-Za-z0-9_-]* [A-Za-z0-9] )? ^[A-Za-z](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$
Names are opaque identifiers used verbatim everywhere: file names, references, DerivativeList,
--derivative, dataframe columns, status and logs. No case style is required.
Every entry point (neurodags validate, run, dry-run, status, count-inputs,
dataframe, slurm-script, the TUI and the Python API) checks, when the pipeline is loaded and
before anything is written, every DerivativeDefinitions key (not only those selected to run),
every DerivativeList entry, every reference (derivative: value) and every --derivative
value. All problems are reported together (exit code 2 on the command line):
Problem |
Why |
|---|---|
Not a YAML string ( |
YAML loads them as booleans, numbers or null; quote the key. |
Does not match the grammar |
|
|
|
Two names differ only in case ( |
Same files on case-insensitive filesystems (macOS, Windows). |
|
MNE writes split recordings as |
validate also prints warnings (not errors) for names longer than 64 characters and for names
that follow the key-value convention below but repeat a key (Fit_w-6_w-7).
Important
These checks are per pipeline configuration. Two different pipeline files writing to the
same derivatives_path are not checked against each other; give them distinct names or
distinct derivatives_path/derivatives_subdir values.
Optional key-value convention¶
A name may encode parameters as _-separated key-value pairs plus at most one bare word:
Fit_alpha-0p5_w-6, alpha-0p5_w-6_Fit. Numbers use p for the decimal point and m for
minus (0p5, m1). Any valid name is accepted whether or not it follows the convention, and
NeuroDAGs never parses names itself; neurodags.naming.parse_entities() and
neurodags.naming.decode_number() split them for your own analysis.
Derivative references (derivative: Name.suffix)¶
A reuse step value is SourceFile, a derivative name, or a name followed by an artifact suffix,
split at the first .. The suffix (everything from that . on) must equal one of the
derivative’s artifact suffixes exactly (string equality, case included):
Reference |
Derivative |
Artifact selected |
|---|---|---|
|
the raw input file |
— |
|
|
the |
|
|
the |
|
|
the |
|
|
error when |
|
|
its only artifact; error when |
The same rule applies on every path: when the referenced unit is done (read from its .done
manifest), when it is computed in the same run, and when it is kept in memory (save: false).
There is no fallback to another artifact or to the whole result. A suffix the derivative did not
produce, or a bare name for a derivative without exactly one artifact, stops the consuming unit
with neurodags.dag.ArtifactSelectionError (its .error marker records the message). The
message is the same on every path and lists the available suffixes:
Derivative 'Features': reference 'Splitter.fif' selects artifact '.fif', which derivative
'Splitter' did not produce (available suffixes: '.EC_baseline.fif', '.EO_baseline.fif').
References select the artifact whose suffix is exactly the text after the name. Write the
full suffix, e.g. 'Splitter.EC_baseline.fif'.
What the node receives:
from a done unit, the path of the selected artifact’s main file (the first file its manifest lists, e.g. the first part of a split recording);
from a unit computed in the same run (saved or
save: false), aNodeResultholding only the selected artifact, which theid.Nheuristic unwraps to the artifact’s object.
A bare name Fit for a single-artifact derivative behaves exactly like Fit followed by its
suffix.
The reference grammar (a valid name; a suffix matching ^(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)+$ whose
last segment is not done, error or skip; SourceFile with no suffix) is checked when the
configuration loads, before anything runs. Whether the suffix exists is known only once the
referenced derivative has produced its artifacts, so that check happens per file at run time.
Other reserved strings in node args¶
Form |
Meaning |
|---|---|
|
output of step |
|
one field of a mapping returned by step |
|
dataset variable from the active dataset’s |
|
Python expression (with |
Any other string is passed to the node unchanged. Strings starting with id.<digits>. that are not
a valid id.N.field raise an error instead of being passed through.
Output layout¶
For a source file f discovered in dataset D on the active mount:
rel = f relative to D's source_root (lexical, POSIX separators, no "..")
base = basename(rel) (verbatim, including any "@" or "&")
outdir = derivatives_path / [derivatives_subdir] / dirname(rel) with derivatives_path
outdir = dirname(f) without (next to the source)
prefix = outdir / (base + "@" + name)
The location depends only on the configuration and the file’s own path: adding or removing files, narrowing
file_pattern,exclude_pattern,max_files_per_dataset,--only-index, a frozen--inputslist or a previous run never moves outputs. A dataset with a single file keeps its folders belowsource_root.Mount points: the paths below
source_rootmust be the same on every mount; outputs computed on one mount are then found (and verified by their manifests) on another after copying the tree.derivatives_subdir: datasets sharing aderivatives_pathmust use differentderivatives_subdirvalues (see datasets.yml), so adding a dataset never moves another one’s outputs.Source basenames are verbatim: a source whose name already contains
@(typically a NeuroDAGs output used as the input of another pipeline) keeps it, so its outputs have several@(x.vhdr@clean_prep.fif@Entropy.json); the last one is the separator.a@b.vhdranda&b.vhdrin one folder are two distinct units. Before 0.4.0 every@of the source basename was written as&;run,dry-run,statusanddataframestop with an error pointing toneurodags migratewhen the output tree holds a file named like an output of one of the pipeline’s derivatives whose source part contains&and that no.doneclaims (0.3.x never wrote.done) and no real source of that spelling owns.Collisions are refused before running: two sources mapping to the same reference base (datasets sharing an output tree, or names differing only in case on a case-insensitive filesystem) stop
run,dry-run,statusanddataframewith an error naming both.Chained pipelines: a dataset whose
source_rootis an upstreamderivatives_pathmirrors the upstream tree. Discovery skips files incode/or.neurodags/under anyderivatives_pathof the configuration, and the dataset’s own output tree (derivatives_path[/derivatives_subdir]) when it lies strictly below itssource_root. It does not skip outputs written next to the sources, an output tree equal to or abovesource_root, or other pipelines’ trees.validatewarns when a dataset’ssource_rootcontains its own outputs without anexclude_patterncovering them. Anchor chained patterns on the full artifact suffix (*@clean_prep.fif): a broad*@clean_prep*or*@clean_prep.*also matches downstream outputs such asx.vhdr@clean_prep.fif@Entropy.jsonin the same tree.code/:neurodags runcopies the pipeline YAML,new_definitionsfiles and datasets YAML, plusneurodags_env.json, into<derivatives_path>/code/(not underderivatives_subdir; see Usage). Withoutderivatives_paththere is no snapshot.
The layout is computed by one function, neurodags.outputs.output_reference_base(), used by
every reader and writer (run, dry-run, status, dataframe, TUI).
Dataframe column names¶
build_derivative_dataframe / neurodags dataframe name columns after the derivative name, with
neurodags.naming.format_column():
<name><artifact suffix> # one column per artifact
<name><artifact suffix>@<dim>-<value>_<dim>-<value>... # one column per DataArray element
<name>.error # the derivative failed for this file
A .nc artifact holding an N-dimensional xarray.DataArray is flattened into one column per
element: one dim-value pair per dimension, joined by _, sorted by encoded dimension name
(0-d arrays get no @ part). Dimension names keep only A-Z, a-z, 0-9 literal; values also
keep ., +, ~, -. Every other character is percent-encoded as UTF-8 with uppercase hex:
Coordinate |
Column |
|---|---|
|
|
|
|
|
|
|
|
|
|
dimension |
|
The encoding is reversible, so any dimension name and coordinate value is safe. Metadata columns
(index, dataset, file_path) never contain ., while generated columns always do. Two
coordinates, artifacts or derivatives that would produce the same column (a non-unique
coordinate index, or mixed types such as 1 and "1") stop the build with
neurodags.naming.ColumnCollisionError, whatever raise_on_error is. Derivatives with
for_dataframe: true are read only from done units (valid .done).
Reading a column back. neurodags.naming.parse_column() returns
(name, suffix, coords, is_error), or None for a metadata column. By hand, for
band_power.nc@freq%5Fband-alpha%5Flow_spaces-Fp1-F3:
Split at the first
@:band_power.ncandfreq%5Fband-alpha%5Flow_spaces-Fp1-F3.Split the head at its first
.: nameband_power, suffix.nc(suffix.errormarks the error column).Split the coordinates on
_(never literal inside a dimension or value):freq%5Fband-alpha%5Flow,spaces-Fp1-F3.Split each pair at its first
-(never literal inside a dimension) and percent-decode:freq_band = alpha_low,spaces = Fp1-F3.
In long format (output_format="long"), the same string is in the derivative column. The
dataframe is easy to split, not pre-split: see
Splitting column names for a pandas recipe that expands
coordinates and name parameters into columns.
Worked example¶
pipeline.yml defines clean_prep (a .fif plus a .report.html) and band_power (a .nc
with dimensions spaces × freqbands); the dataset sets source_root: rawdata,
file_pattern: rawdata/**/*.vhdr and derivatives_path: derivatives:
rawdata/ # source_root
├── sub-01/eeg/sub-01_task-rest_eeg.vhdr
└── sub-02/eeg/sub-02_task-rest_eeg.vhdr
derivatives/
├── code/ # config snapshot (neurodags run)
│ ├── pipeline.yml
│ ├── datasets.yml
│ └── neurodags_env.json
├── sub-01/eeg/
│ ├── sub-01_task-rest_eeg.vhdr@clean_prep.fif
│ ├── sub-01_task-rest_eeg.vhdr@clean_prep.report.html
│ ├── sub-01_task-rest_eeg.vhdr@clean_prep.done
│ ├── sub-01_task-rest_eeg.vhdr@band_power.nc
│ └── sub-01_task-rest_eeg.vhdr@band_power.done
└── sub-02/eeg/
└── sub-02_task-rest_eeg.vhdr@clean_prep.error # clean_prep failed for this file
A second pipeline uses the cleaned files as its sources (source_root: derivatives,
file_pattern: derivatives/**/*@clean_prep.fif, derivatives_path: derivatives2) with a
derivative Entropy. The tree below source_root is mirrored (code/ is never discovered)
and the source name is kept verbatim, so the last @ separates it from Entropy:
derivatives2/
└── sub-01/eeg/
├── sub-01_task-rest_eeg.vhdr@clean_prep.fif@Entropy.json
└── sub-01_task-rest_eeg.vhdr@clean_prep.fif@Entropy.done
Dataframe columns from the first pipeline (wide format, band_power with for_dataframe: true;
file_path is the absolute path of the source, here with the project in /study):
index dataset file_path band_power.nc@freqbands-alpha_spaces-Fp1-F3 band_power.nc@freqbands-alpha_spaces-O1 ...
0 demo /study/rawdata/sub-01/eeg/sub-01_task-rest_eeg.vhdr 0.32 0.29 ...
To find all band_power outputs on disk: find derivatives -name '*@band_power.*'.