Several shocks in one scenario

A macro model rarely projects one thing. It gives a wage path and a labour-market path, and both belong in one scenario: the household-level answer to “what does this projection mean” is the answer to both together, not the sum of two separate answers.

So a shock table may carry several channels, and nothing about the scenario document changes when it does:

scenario = {
    "country_code": "BE",
    "system_name": "BE_2025",
    "shocks": {"inline": [
        {"channel": "scale", "metric": "employment income", "group": "deh=0-3",
         "period": "1", "op": "grow", "value": 0.03},
        {"channel": "align", "metric": "inactivity_rate", "group": "deh=0-3",
         "period": "1", "op": "add", "value": -0.035},
    ]},
    "params": {"period": "1"},
}

plan = apply_scenario(system, data, scenario)
plan["methodology"]   # 'scale_variables+lma_labour_alignment'
plan["methods"]       # ['scale_variables', 'lma_labour_alignment']

One channel, one method

Dispatch is per channel. Each channel in the table resolves to the method that consumes it, independently of every other channel, and the scenario runs those methods one after another. There is no method for a combination of channels — the combination is a property of the scenario, and the engine composes the methods.

That is a deliberate shape. A method written for a pair of channels would need to be written again for every other pair, and once more for each triple; and it would be a candidate for any table carrying only one of its channels, so dispatch would have to explain why the dedicated method still wins. Per-channel dispatch has neither problem. It also keeps a mistake local: a mistyped align metric is refused by name at dispatch, whatever else the table carries, rather than surfacing later as a complaint about some other channel.

The order is the methods’, not yours

Each method declares a stage, and methods run in stage order — a property of what the method does to the input, so it holds for every combination without being stated for each:

STAGE_VALUESscale_variables

Arithmetic on what the input records. Rows and people are unchanged.

STAGE_PEOPLElma_labour_alignment

Who is in which state: transitions, household weight splits.

Values first, then people. The values a method changes are the environment the later transitions happen in. lma_labour_alignment pays a new worker their own predicted hourly wage yivwg — an input variable — so scaling first is what makes an entrant enter at counterfactual wages. Moving people first and scaling after would leave entrants on baseline wages, because their earnings land in yem_a, which a yem or ils_udb_yem shock does not reach: the scaling would silently miss precisely the people the alignment created.

The order you write the records in never matters. A shock table is sorted by (channel, metric, group, period) and carries no order of its own — which is what keeps the content id a property of the scenario rather than of how it was typed.

Methods sharing a stage are assumed to commute; within a stage they run in name order, and that order must not matter.

What each method sees

Each method is handed only the records of the channels it consumes, and runs on the frame the previous method produced. check_dataset, income-list expansion, cell collapsing, the op-commutativity rules, targets, scoring, weight splits and entrant earnings all behave exactly as they do when the method runs alone — composition adds nothing to a method and takes nothing from it.

Params and run arguments

One scenario has one set of params, so every method reads the same period. The document is validated against the union of the methods’ params_schema, still with additionalProperties: false: tolerance_pct is accepted when lma_labour_alignment runs and refused when it does not, and a param no method declares is an error.

The add-ons and extension switches of a run are likewise the union of what the methods need.

What the baseline is

The untouched input: the scenario sets up “all shocks against none”.

plan["baseline"] — the paired baseline — is built by the last method that restructured rows, on the frame it received. With scaling before alignment it therefore carries the scaling: its job is to keep the two frames row-aligned so a fixed poverty line or baseline-defined decile groups mean something, not to undo the earlier stages. Run it with paired_baseline=True when a baseline-vs-reform statistics template will pair observations.

If you want a shock treated as context rather than as part of the experiment, the scenario’s constants are applied to both runs; for anything else, run two scenarios.

Reading the diagnostics

One method: its diagnostics as they are, flat, exactly as documented for that method.

Several: each method’s diagnostics unflattened under its name, with order naming the sequence and stages saying which channels each one took:

plan["diagnostics"]
# {'order': ['scale_variables', 'lma_labour_alignment'],
#  'stages': [{'method': 'scale_variables', 'stage': 10, 'channels': ['scale']},
#             {'method': 'lma_labour_alignment', 'stage': 20, 'channels': ['align']}],
#  'scale_variables': {'applied': [...], 'income_list_expansions': {...}, ...},
#  'lma_labour_alignment': {'targets': [...], 'grades': {...}, 'transitions': {...}, ...},
#  'warnings': [...],
#  'cell_population': {...}}

Anything you already read from a method on its own is in the same place one level down. The top-level warnings is the concatenation of every method’s.

Previewing

apply_scenario(..., validate_only=True) previews each method in turn. A later stage is previewed against what the earlier ones would hand it, where an earlier method declares that its apply() is cheap enough to run on the validation path (preview_by_applying, which scale_variables does): a preview that sized an alignment against the unscaled frame could disagree with the run it previews. Nothing is fitted or aligned in a preview.

Reproducing a run

The result’s methodology is the methods in run order, joined with +, and it is accepted back as the scenario’s methodology pin:

scenario["methodology"] = "scale_variables+lma_labour_alignment"

A pin must cover every channel in the table. The scenario fingerprint folds in every method’s code_fingerprint and their order, so editing either method — or a change of stage — invalidates a cached result rather than serving one computed by the earlier code.

When not to combine

If you want the shocks in a different order, or one of them as context, chain two scenarios and feed the first counterfactual to the second:

plan_a = apply_scenario(system, data, wage_scenario)
plan_b = apply_scenario(system, plan_a["counterfactual"], alignment_scenario)

which makes the order and the baseline yours to state.