Skip to content

Configuring the Analysis Layer

Your two files

There are two, both optional, and the second wins over the first:

File Where Applies to
analysis.config your project, beside parameters.config every module in this project
<module>.config your project that one module — mds.config for mds

Copy analysis/analysis.config.template out of the installation to start one — until you do, the report at the top of every module prints the path. The default for every setting below is built into PoolSeqFlow, so a setting you do not write has exactly one source, and there is no file anywhere you could edit to change it for every project at once.

There is a third file, analysis/frame.config in the installation, and it is not a settings file: it holds none of the settings in this section and is installed read-only. It is how a module reaches conda, the helpers in bin/ and its resource ceiling — the things the pipeline gets from nextflow.config and a module, being its own entry script, does not get at all. It is listed here so that seeing it in a verification report is not a surprise; nothing in it is yours to change, and anything you set in analysis.config wins over it in any case.

parameters.config is read as well, and is where the pipeline's own settings stay. The analysis layer does not repeat them.

Everything the analysis layer takes:

Setting What it does Documented
analysis.runs Which of your runs this invocation covers below
analysis.folderName Which folder under Analysis/Results it writes to Output Layout
analysis.metadata.missingValueEncoding What a cell means when it is not a value — NA and its spellings below
analysis.metadata.timeVar.* How the time column is read and ordered below
analysis.design.* What the experiment was: which pools are independent of each other, which repeats are which, and which covariates a module may fit below
analysis.design.series.incomplete What a time axis makes of that, and what to do with a trajectory that skips a timepoint below
analysis.metadata.phenotypes.* What each pt_ column holds, so a module can test against it below
analysis.metadata.covariates.* What each cov_ column holds, so a module can compute with it below
analysis.modules.<module>.* One module's own settings below

The three inner scopes are namespaces, and the split between them is a real one. analysis.metadata says how metadata.csv is read — what a blank means, how a date parses, what scale a column carries. analysis.design says what the file describes — which pools are independent, which repeats are which. analysis.modules holds one scope per installed module. Keeping them apart means a module called design cannot read the frame's design settings, and it lets each level refuse a key it does not have — without which analysis { timevar { … } }, with a small v, would sit in your file unread while the project ran on defaults you never chose.

A scope you write only part of keeps the rest of its defaults — timeVar { kind = 'numerical' } still takes column from exp_time. A key a scope does not have is refused, naming the ones it does.

Missing values

A blank cell always means "no value", everywhere, with no setting required. analysis.metadata.missingValueEncoding is for the other spellings of it — the ones your export wrote.

params {
    analysis {
        metadata {
            missingValueEncoding = ['NA', 'N/A', '-']
        }
    }
}

Each entry is matched whole, so NA does not catch NAive. * stands for any run of characters and ? for exactly one, so na_* catches na_lost and na_dead.

Matching is case sensitive, and that is deliberate. NA does not catch na; list both if your file has both. Folding case would be convenient right up to the project whose population codes include na, where it would silently delete a real level and leave a smaller design with no error attached. No setting can tell those two situations apart, so the safe one is the default.

It applies to exp_ and pt_ columns only — the ones the analysis layer reads. Read-group tags and param_ overrides were consumed by the pipeline long before any of this, and changing what they mean now would say a completed run had been configured differently than it was.

An entry that would match everything is refused. '*' on its own would turn every experimental and phenotype cell in the project into a blank, leaving an analysis with no design and nothing to say so.

And every cell it blanks is named in the verification report. This is the setting most able to remove data quietly: a pattern wider than you meant produces fewer levels, shorter series and dropped pools, none of which is an error anywhere downstream. The report lists each cell, its column and its pool, so a wrong pattern shows up as a list you did not expect rather than as a result you cannot explain.

Two consequences worth knowing. A pool whose rows say NA on one and nothing at all on another agrees once NA is declared, where before the encoding it would have been refused as a contradiction. And because these columns are recorded and never acted on by the pipeline, adding or changing this setting never invalidates results you already have.

The time axis

Time is the one experimental variable whose order carries meaning, and every way of getting it wrong is silent: the plot renders, the slope has a sign, and nothing says the sequence was backwards. So it is declared rather than guessed.

If your metadata has an exp_time column, analysis.metadata.timeVar.kind is required and every analysis refuses until it is set. There is no auto-detection on purpose: 20240307 reads as a number as readily as a date, which keeps the order right and makes every interval nonsense.

params {
    analysis {
        metadata {
            timeVar {
                column = 'exp_time'      // must be an exp_ column
                kind   = 'numerical'     // numerical | datetime | categorical
                unit   = 'generation'
            }
        }
    }
}

column must name an exp_ column. Only those are checked for agreeing across the rows of one pool, and pointing time at an unprefixed column would let a single pool carry two timepoints with nothing to stop it.

kind is a measurement scale, not a sort order

kind ordered by spacing a module may
numerical the number real fit a rate — per generation, per day
datetime the parsed date real, in days fit a rate per day
categorical order, else alphabetically none rank and sequence only — not a rate

Each level gets a position: the number itself for numerical, days from the earliest for datetime, and nothing at all for categorical. That absence is what lets a module refuse to fit a slope against an axis that has no spacing, instead of doing it anyway.

unit, required for numerical

The numbers are distances, so a rate is meaningful and has to be labeled — 0.003 per unit is not an answer. One of:

exact durations millisecond second minute hour
calendar day week month year
counts generation passage cycle
unnamed step — evenly spaced in something you have not named

step is the escape: 1, 2, 5 under step still means the second gap is three times the first. If the numbers are only ranks with no spacing at all, that is categorical, not step.

A unit on categorical time is refused. Setting one asserts a spacing that categorical time does not have; if the spacing is real, the kind is numerical. month and year are calendar units and not fixed durations — 28 to 31 days, 365 or 366 — which costs nothing here because the position stays in your unit and is never converted.

order, for categorical

timeVar { kind = 'categorical'; order = ['pre', 'during', 'post'] }

Matching is exact: Pre and pre are different. A level in your metadata that order does not include is refused, naming it. An entry in order that no pool has is allowed and reported — a timepoint you have planned but not yet sequenced is an ordinary state.

Without order the levels are sorted alphabetically, and alphabetical is wrong more often than it is right:

values alphabetical gives you meant
T1 T2 T10 T1 T10 T2 T1 T2 T10
baseline week2 week12 baseline week12 week2 baseline week2 week12
pre post post pre pre post

When alphabetical is in use, PoolSeqFlow compares it against a sort that reads embedded digits as numbers, and warns when the two disagree — that catches the first two rows above. It cannot catch pre/post: there is nothing in those strings to disagree about, and no software can know which came first. That one is yours, and the defense is that the verification report prints the levels in the order it will use them, immediately above your results.

format and locale, for datetime

07/03/2024 is a valid date under dd/MM/yyyy and under MM/dd/yyyy, and the two are four months apart. The pattern is asked for because no software can tell which you meant.

Patterns are java.time's. The letters you will actually use:

letter means example
uuuu / yyyy four-digit year 2024
uu / yy two-digit year — always 2000–2099 26 → 2026
MM month as two digits 03
M month as one or two digits 3
MMM short month name Mar
MMMM full month name March
dd day as exactly two digits 07
d day as one or two digits 7 or 07
HH hour, 24-hour clock 14
hh hour, 12-hour clock — requires a 02
mm minute 30
ss second 00
a AM/PM marker PM, pm
'text' literal text, in single quotes d 'de' MMMM 'de' yyyy

The failures, explicitly. Each of these was measured, and each is a real way to lose an afternoon:

  • hh without a is refused. hh:mm on afternoon samples has no way to know they are afternoon. PoolSeqFlow stops rather than reading 02:30 PM as half past two in the morning — twelve hours early on every one of them. Write hh:mm a, or HH:mm for a 24-hour clock.
  • dd does not accept a one-digit day. 2024-03-7 fails under yyyy-MM-dd and works under yyyy-MM-d. d accepts both 7 and 07, so prefer it if your file is inconsistent.
  • An impossible date is refused, never corrected. 2024-02-31 stops the run naming the value. A lenient parser would quietly make it 2024-02-29, which then sorts perfectly and is not the date anybody wrote.
  • yyyy and uuuu both work. They differ only for dates before year 1, which yours are not.
  • Two-digit years are this century. yy reads 69 as 2069, not 1969. Write four digits.
  • Month names are case-insensitive. 12 JUN 26, 12 Jun 26 and 12 jun 26 all read under dd MMM yy.
  • Time zones are not supported. Dates are read as local, without an offset. If your values carry one, strip it.
  • A pattern with no date is refused. A time of day alone has no order across days.

locale is the language your month names are written in — it defaults to en and only matters for MMM, MMMM and a. A numeric pattern like yyyy-MM-dd never needs it.

timeVar { kind = 'datetime'; format = 'd MMMM yyyy'; locale = 'fr' }   // 5 décembre 2011

de reads 5. Dezember 2011, tr reads 5 Aralık 2011, ja reads 2011年12月5日. A tag this Java does not know is refused by name rather than silently falling back to English. The locale affects only reading: what gets published is the ISO date and a number, so a project written in French produces output identical to one written in English.

What the report tells you, and why to read it

Two mistakes are impossible to catch — an ambiguous date pattern, and a categorical order with no lexical clue. The defense is that every run prints what it did, in the order it will use, immediately above the results:

TIME VARIABLE:         exp_time, datetime, 'dd/MM/yyyy' (fr)
TIME VARIABLE:             2024-03-07T00:00  2024-04-11T00:00   (2 levels)

Someone who meant July 3rd sees 2024-03-07 and catches it in one glance. That line is doing more work than any check in this program.

The experimental design

Which pools are independent of each other is where every degree of freedom in every module comes from, and nothing about it is guessed.

params {
    analysis {
        design {
            by            = ['exp_treatment', 'exp_replicate', 'exp_lane']
            biologicalRep = ['exp_replicate']
            technicalRep  = ['exp_lane']
            covariates    = ['cov_temperature']
        }
    }
}

Its own scope beside analysis.metadata, not inside it. That one says how the file is read; this one says what the file describes. The two answer different questions and a project can get one right and the other wrong.

This applies with or without a time course. A one-off comparison of three treated pools against three controls has conditions and independent units exactly as a five-generation time series does, and reads them from the same three settings.

by defaults to every exp_ variable except time, and the report always prints the key it used. Set it explicitly when one of your exp_ columns is recorded on each pool rather than saying what the pool is — a cage temperature, a census count. Such a column differs from pool to pool, so leaving it in the key makes every pool its own unit and, in a time series, splits every series into singletons with no error at all.

Replicates: which repeats are independent

"Replicate" covers two things that are handled in two different places and have opposite statistical standing.

Technical replicates are one biological sample measured more than once — two lanes, two libraries, a second sequencing run for validation. They are not independent, and treating them as though they were is pseudo-replication. PoolSeqFlow supports both ways of handling them, and either is a valid choice:

  • Merge them. Give the rows the same RG_Sample. The pipeline pools their reads and adds their depths, and they become one column of every published table. Anything that differs between those rows — the lane, the run — then goes in an unprefixed column, because it is no longer an experimental variable for that pool.
  • Keep them apart. Give the rows different RG_Sample values, so they stay distinct pools, and an exp_lane column that tells them apart is then a perfectly good experimental variable. Name it in technicalRep.

Biological replicates are independent repeats of the same condition — two cages of flies under one selection regime. They carry drift, they are the strata a test conditions on, and they are where degrees of freedom come from. Give them an exp_ column and name it in biologicalRep.

Both settings take any number of columns. Two lanes crossed with three sequencing runs is technicalRep = ['exp_lane', 'exp_seqrun'] and six technical replicates per unit; nested and crossed designs both work, because forming the unit drops all of those columns either way.

That gives three levels, each derived once and available to every module:

what it is formed by
series one measurable trajectory, and only where there is a time axis the full key
unit the independent biological unit dropping the technicalRep columns
condition what is being compared dropping the biologicalRep columns too

A module that counts degrees of freedom or chooses strata reads units, never series. A key column named in neither list is a condition, which is the default and the common case.

What makes two pools one unit, and it is only ever your declaration

RG_Sample has already decided what was merged. Rows sharing one are one pool; rows that do not are separate pools. So by the time the analysis layer sees your project, two pools are two independent units, and the only thing that can make them one material again is a column you named in technicalRep.

That is the whole rule, and it cuts both ways:

  • Three control pools with nothing but exp_treatment in the file are three units, not one. They are three separate RG_Samples and you did not say otherwise.
  • Three cages sequenced on two lanes each, with technicalRep = ['exp_lane'], are three units from six pools. Leave the lane out of technicalRep and they are six, which roughly halves every standard error in the project.

biologicalRep and technicalRep both default to empty, so a project that declares nothing gets one unit per pool. That is not a claim that your pools are independent — it is the absence of a claim that they are not, and a module whose test needs declared biological replication refuses by name rather than inventing it.

Once technicalRep is declared it has to resolve the pools it applies to. A group where some pools are told apart by the technical column and others are not is neither one unit nor several, and that refuses, naming the pools and offering the same remedies as above.

Which covariates are part of the design

covariates names the cov_ columns a module may adjust for, as opposed to only report. Left empty it is every cov_ column that has a declared scale; naming any restricts it to those, and a column named here without a scale refuses, because a column with no scale has no value a model could take.

Set it when you record covariates you do not want in any model. A collection site kept for provenance is not a term anybody wants fitted, and each covariate that is in the design costs a degree of freedom — with six pools there are four to spend.

Every covariate is resolved, reported and published either way. The verification report marks each one, and a covariate you left out is named in the design notes, because leaving one out is as much a decision as putting one in and neither is visible from the values:

COVARIATES:            2 declared, 1 in the design
COVARIATES:                cov_temperature, quantitative  [in the design]
COVARIATES:                    4 pools, 18 to 23
COVARIATES:                cov_site, nominal: north, south  [on the record only]

The mistake to watch for

The report prints every key column under exactly one role:

REPLICATION:           conditions   exp_treatment
REPLICATION:           biological   exp_replicate
REPLICATION:           technical    exp_lane, exp_seqrun
REPLICATION:               2 conditions, 3 biological replicates each, 6 technical
REPLICATION:               6 independent units from 144 pools

Read those three lines. A technical column left out of technicalRep is read as a condition — one treatment silently becomes several, and a test is handed strata that are the same DNA. Nothing can detect that, for the same reason nothing can detect dd/MM against MM/dd: both readings are internally consistent. Printing the partition is the whole defense.

The counts are per unit and given as a range when they vary, because a design where one sample was sequenced twice for validation and the rest once is perfectly ordinary and a single number would be a plausible-looking lie.

And a design PoolSeqFlow cannot see through: three cages sequenced on two lanes each, with the lanes neither merged nor declared technical but labeled exp_replicate = 1..6, has three independent units and claims six. The frame reports what you declared. The line 6 pools from 6 libraries — where a merged design would say from 12 — is the number to check.

Time series

A trajectory needs more than an order: it needs to know which pools are one thing measured repeatedly. A series is the pools that share every identifying variable and differ only in time — the same key the experimental design is built from, followed through the time axis.

Where a project has no time axis there are no series, and the units and conditions above are unaffected. The only setting a series adds is what to do when one has a gap in it, and it lives inside the design scope, because a series is what a time axis makes of the design rather than a thing of its own.

params {
    analysis {
        design {
            series {
                incomplete = 'fail'
            }
        }
    }
}

When a series is missing a timepoint

analysis.design.series.incomplete decides, and it defaults to fail because a ragged panel analyzed as a complete one is a wrong answer that looks like a right one.

fail refuse, naming each series and the timepoints it lacks
drop leave the incomplete series out, named in the report
keepLeft cut the timeline back to the longest run of points every series covers, from the start
keepRight the same, from the end

keepLeft and keepRight shorten the timeline, not each series individually, so every series that survives covers the same points and they remain comparable. Worked through, with T = g0 g5 g10 g15 g20:

        g0  g5  g10 g15 g20
  A      ●   ●   ●   ●   ●     a cage crashes late
  B      ●   ●   ●   ●   ●
  C      ●   ●   ●   ·   ·
keepLeft  └───────────┘        -> g0 g5 g10, all three complete
keepRight                      -> nothing: no suffix C covers.  REFUSED
        g0  g5  g10 g15 g20
  A      ●   ●   ●   ●   ●     a replicate joins late
  C      ·   ·   ●   ●   ●
keepRight        └───────┘     -> g10 g15 g20
keepLeft                       -> nothing: C has no g0.  REFUSED
        g0  g5  g10 g15 g20
  A      ●   ●   ●   ●   ●     a hole in the middle
  B      ●   ●   ·   ●   ●
keepLeft  └───────┘            -> g0 g5     (discards g15 g20, which B has)
keepRight             └───┘    -> g15 g20   (discards g0 g5,   which B has)

That last one is the case worth thinking about: both work and they keep different data. The choice is early drift against late response — a scientific one, not a mechanical one, and picking the wrong one silently answers a different question.

If a truncation leaves a single timepoint, that is reported loudly: every series then has one measurement and there is no time axis at all. drop is usually what such a design wanted.

None of the four fills a gap in. No value is carried forward, backward or interpolated. Imputing an allele frequency fabricates a measurement that everything downstream then weights by a sequencing depth nobody observed.

What always refuses

Four things are structural rather than preferences, and no setting turns them off:

  • Two pools with the same key at the same timepoint. The series is then not a function of time. What to do depends on which kind they are: give them an exp_ column and declare it in biologicalRep or technicalRep if they are genuinely separate measurements, or give the rows the same RG_Sample if they are one pool sequenced twice that you meant to merge.
  • by naming the time column, a column that is not an exp_ column, or one your metadata does not have.
  • Two series that would carry the same label.
  • Two spellings of one point in time5 December 2011 and 05 December 2011, or 1 and 1.0. Which level a pool belongs to would have no answer.

A pool whose exp_time cell is blank joins no series. It is counted and named in the report, and whether that matters is the module's to say — a project can legitimately hold one pool that was sampled once.

A module's own settings

A module's settings go in a scope named after it, inside analysis:

params {
    analysis {
        modules {
            basicstats {
                minReads = 3
            }
        }
    }
}

What a module has is listed in its own section of this manual, and the defaults are the module's own. A key it does not have is refused, naming what it does have — so chromosome for chromosomes stops the run rather than leaving the module quietly running on a default you did not choose. The verification report echoes every setting you did write, as you wrote it.

Inside analysis, always. A scope written at the top level of a configuration file becomes part of the record this project is checked against, and every analysis would then refuse with a message about a changed project rather than about the file you just wrote — so that mistake is caught and named where it happens.

Phenotypes

A pt_ column records something measured on the pool — a trait value, a resistance score, a case/control status. No pipeline step reads one. Declare a scale for as many as you record, one block per column:

params {
    analysis {
        metadata {
            phenotypes {
                pt_wingspan   { kind = 'quantitative' }
                pt_resistance { kind = 'binary'; levels = ['unaffected', 'affected'] }
            }
        }
    }
}

Declaring a phenotype does not choose it. This block says what each column holds; which one an analysis tests against is the module's own setting, listed in that module's section. So a project with three phenotypes writes one declaration block and runs three analyses from it — set folderName so each lands somewhere of its own. It is the same split covariates use, and the same one that separates analysis.metadata from analysis.design: what a column holds is one question, what it is for is another.

A pt_ column you do not declare is not an error. It is recorded, checked and reported like any pool-level column; it simply carries no scale, so no module can test against it. The report names the columns in that state, because "kept for the record" and "forgot to declare it" look identical in the file.

It has to be a pt_ column, and that is a rule rather than a convention. pt_ columns are checked for agreeing across the rows of one pool, exactly as exp_ ones are — so declaring any other column would let one pool carry two phenotype values with nothing to stop it. Anything outside the prefix is refused by name.

kind is a measurement scale, declared and never detected

kind What the column holds levels What a module may fit
quantitative a measurement. Every value must parse as a number refused a slope, signed
binary presence and absence — affected or not. Exactly two levels required a signed effect, and case/control methods
ordinal groups whose order means something and whose spacing does not required a trend across the order
nominal groups with no order required a comparison between groups, and no trend

Nothing is inferred, for the same reason analysis.metadata.timeVar.kind is not: 0 and 1 read as numbers as readily as they encode two groups, and which of case and control you mean by affected is not in your file at all.

binary is not "the two-group case". It is a claim that one level is the absence of the other — affected or unaffected, resistant or susceptible — which is what makes a design a case/control design rather than a comparison of two arbitrary groups. Two groups that are neither, such as coastal and inland or two host plants, are nominal with two levels. The published result records which you declared.

ordinal and nominal are two kinds because they permit different things. Scoring low/medium/high as 0/½ and fitting a slope asserts that low→medium is the same distance as medium→high, which an ordinal scale does not claim. Treating it as unordered instead throws the ordering away and loses the power to see a trend at all. So you say which you have, and the module fits accordingly.

For a nominal phenotype the frame carries no number at all for each pool — only which group it is in. That is deliberate, and it is the same mechanism categorical time uses when it sets position to null: it makes "you may not fit a rate on this" something a module can check rather than something its author has to remember. Wing types spotted, striped and curly have an index each, and a slope fitted on that index would assert curly is twice as far from spotted as striped is.

Counts, percentages, and what is not a phenotype kind

Counts and percentages are quantitative. A phenotype is a predictor, and a predictor carries no distributional assumption — that a bristle count is really Poisson changes nothing here.

A trait that varies WITHIN a pool is not a nominal phenotype, it is a composition. If a pool is 30% spotted, 50% striped and 20% curly, its phenotype is not "striped" — labeling it by the majority morph discards the other 70%. Record it as one quantitative column per morph, pt_spotted = 0.3 and so on, and analyze them one at a time. nominal is correct only when each pool was built homogeneous for the trait, which is a real design but a different one.

Two kinds that are real and are not supported, named here so that nobody declares one by accident:

  • Circular measurements — time of day of eclosion, a direction. 359° and 1° are two degrees apart, and any linear fit on them is simply wrong. Nothing in the values reveals it, so this cannot be refused for you: do not declare a circular phenotype quantitative.
  • Censored measurements — time to death where some individuals survived. This needs survival analysis, which no module here does.

levels decides the sign of every result

For binary, levels names the two values in order, as [absent, present].

phenotype {
    column = 'pt_status'
    kind   = 'binary'
    levels = ['unaffected', 'affected']
}

Write those the wrong way round and every effect reported against the phenotype has the opposite sign. ['affected', 'unaffected'] is just as legal, produces no error, and inverts the entire result. Nothing in your data says which you meant.

For ordinal the order is the scale, so it carries the same weight. For nominal it only sets which group the others are reported against, and a comparison between groups does not depend on that choice — which is the one case here where getting the order wrong costs you nothing.

A level no pool has is kept, not refused. A group you have not sequenced yet is legitimate, so the run continues and the report names the unused level. A misspelling looks exactly the same, which is why that line is worth reading.

There is no check that can catch this, and pretending otherwise would be worse than saying so. What you get instead is the same defense the time levels get: the verification report prints every pool's value as you wrote it beside the number it became, and the published folder's README.md repeats it.

PHENOTYPE:             pt_status, binary, 'control' = 0 and 'case' = 1
PHENOTYPE:                 Pool1  case -> 1.0
PHENOTYPE:                 Pool2  control -> 0.0

Read those two lines before you read any result. They are where a reversed encoding becomes visible.

Blanks, and one phenotype at a time

A blank cell is no value, not a third group. The pool is named in the report and carried into the design, and a module that needs a phenotype drops that pool from its fit and says so. Recording what your project was is the frame's job; deciding what can be fitted is the module's.

If every pool has the same value there is nothing to associate anything with, and the report says so. A module that fits against it refuses rather than reporting an effect of zero as a finding.

A group holding one pool is reported too. It contributes no within-group variance, so any comparison against it rests on that single pool — legitimate, occasionally unavoidable, and not something a table of group means shows you.

Record and declare as many pt_ columns as you like. What a module does with them is its own setting, and a module that fits one phenotype at a time is run once per phenotype with analysis.folderName set, so each result lands in a folder of its own and says which phenotype produced it. A folder holding three answers under one name is a folder nobody can cite.

Covariates

A cov_ column records something measured on the pool that you neither set nor are testing against — a cage temperature, an altitude, a collection site, a technician when every library of a pool shares one. It is the third pool-level prefix and it exists for one mechanical reason: an exp_ column says what the experiment set up, so a temperature recorded on each pool would make every pool its own unit and leave every series a single point long. A cov_ column never identifies a unit or a series, so adding one cannot do that.

Declaring a covariate is optional. Undeclared, it is recorded, checked for agreeing across the rows of its pool, and printed in the verification report like any other pool-level column. Declaring it gives it a scale, so a module can compute with it:

params {
    analysis {
        metadata {
            covariates {
                cov_temperature {
                    kind = 'quantitative'
                }
                cov_site {
                    kind   = 'nominal'
                    levels = ['coastal', 'inland', 'montane']
                }
            }
        }
    }
}

The kinds are the phenotype's — quantitative, binary, ordinal, nominal — and they mean the same things, including that a nominal covariate carries no number a module could fit a slope on. The report names any cov_ column you recorded and did not declare, because "kept for the record" and "forgot to declare it" look identical in the file.

What adjusting for one costs, and why it is a decision

The frame never adjusts for anything. It resolves each covariate, says which are part of the design, and publishes both. Whether a module puts a covariate in its model is that module's business, declared in its own section — and no module is published without saying.

The arithmetic is why it has to be your decision. n is the number of pools, typically six to twenty, so every covariate a model fits is a degree of freedom the effect you came for does not get. At six pools a comparison starts with four; a single covariate makes it three; a repeated-measures design of three units has one left before any covariate at all.

Whatever a module does with it, declaring a covariate buys one thing unconditionally: the value travels with the result. The verification report and every published folder's README.md carry each pool's covariate value beside its phenotype, so a reader can see that the high-phenotype pools were also the warm ones. That is the confounding you would otherwise have no way to suspect, and it needs no degrees of freedom at all.

If a covariate turns out to explain your result, the answer is usually a better design rather than a bigger model.

Which runs to analyze

analysis.runs chooses which of your runs an invocation covers:

params {
    analysis {
        runs = 'all'                         // every run in the project — the default
        // runs = 'lenient'                  // one, by the RunID you gave it
        // runs = ['lenient', 'strict']      // several
    }
}

all is a keyword and a list is always run names, so a run you have actually named all is selected by writing ['all'].

A single-run project has no run names — there is no run table — so analysis.runs must be left at 'all'. Setting anything else is refused rather than ignored, because a name that selects nothing would otherwise look like it had worked.

A run selects a directory, not a run

This is the part worth reading twice. Runs that produced the same tables share one results directory (Multi-run), and the analysis is of the directory. So naming one run can reach results that belong to several:

RUN SELECTION:         analysis.runs = 'lenient_a'
RUN SELECTION:         1 of 3 runs, in 1 results directory
RUN SELECTION:             Shared_1
RUN SELECTION:                 selected: lenient_a
RUN SELECTION:                 also the results of lenient_b - those runs
RUN SELECTION:                 produced the same tables, so there is one directory
RUN SELECTION:                 here and one analysis of it

Nothing is wrong there: lenient_a and lenient_b differ only in something neither the tables nor the filters depend on, so the pipeline produced one set and both runs own it. Naming both would produce the same single analysis. The report says so every time rather than leaving you to infer it from the directory name.

Which directory a run's results are in is not guessed from directory names — the analysis layer rebuilds the pipeline's own division of the runs and reads the answer off it, so the two can never disagree.