The Mathematics of Extension Models
The base MMM of notebook 04 is a single linear map: every channel feeds one outcome through its adstock·saturation·\(\beta\) response. Real marketing systems break that twice — channels build intermediate metrics like awareness (mediation), and products interact with each other (cannibalization, shared demand). This page derives the three extension models that handle those structures, fits two of them on the Aurora world whose generative truth is known, and ends with the warning the whole exercise earns: extension structure adds parameters faster than data adds moments.
The three models are a separate class hierarchy (BaseExtendedMMM), not
BayesianMMM subclasses. They fit the raw outcome, a single national time
series, and they all share one media transform. Writing
\(s_c = \max_t x_{c,t}\) for the per-channel spend scale and using the normalized geometric
adstock from notebook 01:
Same form as the core model (mmm_extensions/components/transforms.py:
logistic_saturation_pt returns \(1-e^{-\lambda x}\)): exponential
saturation with \(f_{\mathrm{sat}}(0)=0\), so a zero-spend week contributes
exactly nothing — consistent with the
notebook 05 estimand identity that powers
the calibration math. (An earlier version of this page wrote the logistic sigmoid
\(1/(1+e^{-\lambda z})\) here, which would have implied \(f(0)=\tfrac12\) and an
offset in every contribution; the shipped code has no such offset.)
with \(\alpha_c \sim \mathrm{Beta}(2,2)\) and \(\lambda_c \sim \mathrm{Gamma}(3,1)\) learned per channel. The output \(u_{c,t}\) is the channel's saturated effective exposure — the thing every downstream coefficient (\(\beta\), \(\delta\), \(\gamma\), \(\psi\)) multiplies. Holding this transform fixed across the three models is what makes them comparable: they differ only in how \(u_{c,t}\) is wired to the outcome(s).
🧭 Before you start
Prerequisites: comfort with calculus & probability; the Workshop series recommended first. · Time: ~30–45 min.
You will learn:
- How
NestedMMM,MultivariateMMM, andCombinedMMMshare one media transform and differ only in how the saturated effective exposure \(u_{c,t}\) is wired to the outcome(s): mediator paths, cross-effects, or both with routing. - How mediation recovers direct vs indirect effects the base model is blind to, and why a directed cross-effect (\(\psi\) in the mean) and shared demand (\(\rho\) in the noise) are different links.
- Why extension structure adds parameters faster than data adds moments — and how to count parameters against moments before trusting an extension.
Run it:
nbs/math/math_06_extensions.ipynb on GitHub.
Three causal structures, side by side
Before the equations, the shapes. The base model wires every channel straight to the
outcome. NestedMMM inserts a mediator \(m\) — some channels
reach the outcome through it, optionally plus a direct shortcut.
MultivariateMMM keeps direct wiring but models two outcomes jointly with a
cross-effect \(\psi\) between them. CombinedMMM (§3) is the middle and right
panels fused.
The mediator carries the product-of-coefficients path \(\beta_{c\to m}\gamma_m\); the cross-effect \(\psi\) is a directed arrow between observed outcomes. They are different objects, and each comes with its own identification trap (§4).
1 · NestedMMM — mediation: direct vs indirect effects
In Aurora, TV and Display barely sell anything directly — they build brand awareness, and awareness drives sales. A base model sees only the tiny direct slice and calls them weak (the stress series measures exactly this: TV ROAS 0.22 vs true 2.14). Mediation analysis recovers their real, mostly indirect value.
The two structural equations
A nested model is two stacked regressions. With \(u_{c,t}\) the shared saturated exposure, a mediator \(m\) (awareness), the set \(C(m)\) of channels routed to it, and a mediator intercept \(a_m\):
\[ \textbf{(mediator)}\qquad m_t \;=\; a_m \;+\; \sum_{c \in C(m)} \beta_{c\to m}\, u_{c,t}, \] \[ \textbf{(outcome)}\qquad y_t \;=\; a_y \;+\; \underbrace{\sum_{m} \gamma_m\, m_t}_{\text{mediated paths}} \;+\; \underbrace{\sum_{c} \delta_c\, u_{c,t}}_{\text{direct paths}} \;+\; \varepsilon_t, \qquad \varepsilon_t \sim \mathrm{Normal}(0,\sigma_y). \]\(\beta_{c\to m}\) is the channel → mediator effect (positive-constrained: media builds awareness), \(\gamma_m\) the mediator → outcome effect, \(\delta_c\) the direct shortcut. Aurora's awareness is only observed in a sparse monthly survey: the model treats \(m_t\) as a latent series and anchors it with a partial-observation likelihood \(\text{survey}_t \sim \mathrm{Normal}(m_t, \sigma_{\text{obs}})\) on the observed weeks only.
The effect decomposition
Substitute the mediator equation into the outcome equation. The coefficient on channel \(c\)'s exposure becomes a sum over every path from \(c\) to \(y\):
\[ \text{coef}_c \;=\; \underbrace{\sum_{m} \beta_{c\to m}\,\gamma_m}_{\text{indirect (mediated)}} \;+\; \underbrace{\delta_c}_{\text{direct}} , \]
each mediated path contributing the product of its two edges — the classic
product-of-coefficients mediation effect (stored as the Deterministic
indirect_<channel>_via_<mediator>). Defining
\(\text{indirect}_c = \sum_m \beta_{c\to m}\gamma_m\) and
\(\text{total}_c = \text{indirect}_c + \delta_c\):
A proportion mediated near 1 means the channel works almost entirely through the
mediator. The decomposition only applies to channels routed to a mediator — Aurora maps
TV and Display to awareness and leaves Search and Social as pure
direct-response (their proportion_mediated is NaN by design).
Measured on PyMC 5 — the baked notebook's get_mediation_effects()
table (500 draws, seed 0). On that run the direct slice was nearly invisible at this scale:
TV's effect is 0.053 direct vs 151.08 indirect; Display's is 0.117 vs
185.99. Under PyMC 6 the split is far less lopsided — proportion
mediated drops to ~0.69 (TV) and ~0.40 (Display), so the direct slice is a substantial share,
not invisible. Mediation still beats the base model's bad-control trap, but read it as
partial recovery, not "brand engines, not closers." Hover for exact values.
Measured — proportion mediated, model vs Aurora's generative truth. The chart shows the PyMC 5 baked run: TV 1.000 vs true 0.988; Display 0.999 vs true 0.967. Under PyMC 6 this is a genuine posterior shift for these weakly-identified models (clean convergence — R-hat ~1.0, ESS 2000+, 0 divergences — not a bug): proportion mediated falls to ~0.69 (TV) and ~0.40 (Display), so TV only grazes the notebook's 0.7 assert line and Display sits below it. The correctly-specified NestedMMM still beats the bad-control trap, but it partially recovers the mediated channels — the wide intervals are the honest signal. The latent awareness series itself is reconstructed from the sparse monthly survey with corr(recovered, true hidden series) = 0.492 (PyMC 5).
💡 Why ≥ 2 channels per mediator
The indirect decomposition is only interesting when a mediator aggregates several channels — with one channel per mediator, \(\beta_{c\to m}\gamma_m\) is just a re-parameterized direct effect. Aurora routes both brand channels through awareness, so the mediator equation is genuinely shared structure.
The latent-split toy: what the survey buys you
Here is the identification problem in its purest form. Suppose the mediator is fully latent — no survey at all. Collapse the two equations: \(y_t = (a_y + \gamma a_m) + \sum_c \big(\gamma\beta_{c\to m} + \delta_c\big) u_{c,t} + \varepsilon_t\). Only the sums \(\gamma\beta_{c\to m} + \delta_c\) touch the likelihood. Slide the split between indirect and direct any way you like — the fitted outcome is identical. The slider below holds each channel's total coefficient fixed and reallocates a share \(\theta\) of it to the mediated path. Watch the top panel (the outcome) refuse to move while the bottom panel (the implied awareness series) swings — and only the sparse survey points can break the tie.
Computed live in your browser — the same math as the notebook. Two channels' exposures \(u_{c,t}\) are built with the §0 transform (seeded spend → normalized geometric adstock → logistic saturation); the outcome is \(y_t = a_y + \sum_c \text{total}_c\, u_{c,t} + \varepsilon_t\), which does not depend on \(\theta\) at all. The implied awareness \(m_t = a_m + \theta \sum_c \text{total}_c u_{c,t} / \gamma\) only matches the survey dots (drawn at the true \(\theta = 0.98\)) when the slider is near 0.98 — the survey RMSE bottoms out there. Without the survey, every \(\theta\) has the same likelihood: the direct/indirect split would be decided by the priors, not the data.
2 · MultivariateMMM — cross-effects: ψ in the mean, ρ in the noise
Aurora sells two products, Original and Cold Brew. In summer Cold Brew steals from Original — a negative cross-effect — and both products ride the same demand wave, so even after media is accounted for their residuals move together. The multivariate model captures both at once, and the two mechanisms live in different parts of the model. Keeping them distinct is the whole point.
The MvNormal likelihood
Stack the \(K\) outcomes into \(\mathbf{y}_t = (y_{1,t}, \dots, y_{K,t})\):
\[ \mathbf{y}_t \;\sim\; \mathrm{MvNormal}(\boldsymbol\mu_t,\, \Sigma), \qquad \mu_{k,t} \;=\; a_k \;+\; \sum_{c} \beta_{k,c}\, u_{c,t} \;+\; \sum_{j \neq k} \psi_{j\to k}\, y_{j,t}. \]Two distinct objects link the outcomes:
- \(\psi_{j\to k}\) — the cross-effect, in the mean structure. It multiplies the observed outcome of the source product into the target's expected level: a directed causal arrow. A negative \(\psi\) is cannibalization (more Cold Brew sales ⇒ fewer expected Original sales). The framework builds the cannibalization arrow as \(\psi = -\,\mathrm{HalfNormal}(\sigma_\psi)\) — structurally \(\le 0\) by construction; the data decides only how negative.
-
\(\Sigma\) — the residual covariance, in the noise structure. It
captures a common shock lifting (or dropping) both products in the same week
with nothing causal flowing between them. The framework puts an LKJ prior on the
correlation and samples the Cholesky factor (\(\Sigma = LL^\top\) via
LKJCholeskyCov), exposing the posterior correlation as the DeterministicY_obs_correlation.
Why the split matters mathematically: both mechanisms generate co-movement. Conditioning the joint Normal, the regression of \(y_1\) on \(y_2\) picks up both the mean-structure term \(\psi\) and the noise-structure term \(\rho\,\sigma_1/\sigma_2\) — the joint distribution of two outcomes offers only one contemporaneous cross-moment, \(\mathrm{cov}(y_1, y_2)\), to pin down both parameters. What \(\psi\) does in the mean, residual correlation can largely absorb in the noise, and vice versa. The model tells "Cold Brew eats Original" apart from "the category was hot" only with the help of the prior's sign constraint and the media terms — which is why §4 treats this split as prior-determined, and why the stress series can make ψ flip sign just by changing its prior.
Measured summaries, curves recreated from them — the baked run's \(\psi_{\text{Cold Brew}\to\text{Original}}\): posterior mean −0.00028, sd 0.00027, 94% HDI (−0.00094, −0.00001), \(P(\psi<0) = 1.000\) — on that run the entire posterior sat below zero as a spike near zero (the curves above are that PyMC 5 run, unit-peak scaled). Under PyMC 6 the story inverts: ψ is weakly identified and relocates to a sizeable negative mean (≈ −1.13) with the posterior wider than the prior (contraction ≈ −0.39, overlap ≈ 0.03, shift ≈ 5 prior-sds) — not a pinned spike at zero. The honest read becomes "trust the sign (cannibalization), not the magnitude" — hold that thought for §2.3.
Measured — the posterior-mean residual correlation matrix
(get_correlation_matrix()): off-diagonal
+0.407. The causal cross-effect came out negative while the
residual correlation came out positive — the model correctly separating
cannibalization (mean structure) from the shared demand wave (noise structure).
⚠️ Never chart the multivariate μ as a prediction
In this notebook the multivariate model is fit with no trend or seasonality
configured (the default — model_config/trend_config
left None, so the graph is byte-identical to the historical baseline), so
its \(\boldsymbol\mu_t\) carries only the intercept, media, and cross-effects and is
near-flat — not a usable predicted-vs-observed trajectory. This is the notebook's
hard-won lesson: as configured here, MultivariateMMM estimates
cross-effects, not trajectories, and its value is the two estimands
charted above — \(\psi\) and the residual correlation. (Since 2026-07-08 the extension
models can carry trend — linear/piecewise/spline/GP — and Fourier seasonality
when a model_config/trend_config is supplied, per outcome and
gated on include_trend/include_seasonality; with those on,
\(\boldsymbol\mu_t\) becomes a meaningful trajectory. This page simply doesn't
configure them.) (Practical footnote: the MvNormal model requires cores=1
on macOS; chains=2, cores=1 is fine.)
2.3 · Did the data actually learn ψ? Informative ≠ important
We just verified the entire posterior of \(\psi\) sits below zero and called it
cannibalization. But look at how \(\psi\) is built:
\(\psi = -\,\mathrm{HalfNormal}(\sigma_\psi)\). Every prior draw is already
negative — "\(P(\psi<0)\approx 1\)" is true before seeing any data. Under a one-sided
prior, the sign statement is nearly vacuous. The honest replacement asks:
how much did the data move and narrow the parameter beyond the prior? The
framework's compute_parameter_learning diagnostic compares prior draws to
posterior draws with three quantities:
Contraction \(c \to 1\): the data pinned the parameter. \(c \approx 0\): the data was uninformative (prior-dominated). \(c < 0\): the posterior is wider than the prior — prior–data tension or weak identifiability. Overlap is the shared histogram mass of prior and posterior (bins, not a KDE, so the hard half-Normal edge at 0 is not smeared); shift counts prior standard deviations of pure location learning.
Measured on PyMC 5 — per-parameter contraction from the baked
compute_parameter_learning(prior_samples=2000, random_seed=0) run (the bars
above are that run). On PyMC 5, ψ was the outlier: contraction 1.000,
overlap 0.052. Under PyMC 6 even ψ is no longer pinned — its contraction
goes negative (≈ −0.39, posterior wider than the prior, shift ≈ 5 prior-sds), so ψ
joins the relocated tail rather than leading it. And 13 of the model's 19
free parameters already sat below the 0.1 line (the five not shown in the notebook's
tail table fall in between), 6 of them negative — posterior wider than prior, i.e.
prior–data tension, not mere prior-domination (worst: Y_obs_chol_cov[1] at −0.713
with shift 56 prior sds — verdict relocated: the evidence moved it without narrowing
it). The honest read under PyMC 6 is not "the data nailed ψ" but "nearly the whole
multivariate machinery, ψ included, is weakly identified."
And here is the punchline the sign test hid. On the PyMC 5 run, contraction ≈ 1 meant the data was informative about \(\psi\) — but informative about what? That run read the posterior location as pinned near −0.00028 (≈ 0.012% of Original's weekly sales) and concluded "essentially no direct cannibalization." Under PyMC 6 the diagnostic tells the opposite tale: ψ's contraction is negative (≈ −0.39) — the posterior is wider than the prior yet relocated ≈ 5 prior-sds to a sizeable negative mean (≈ −1.13). That is the signature of a weakly identified parameter, not a pinned one: the sign is real (cannibalization) but the magnitude is not trustworthy. The products also move together through the positive residual correlation (+0.41, the demand wave), a separate link. Either way the sign-only verdict ("cannibalization confirmed, \(P(\psi<0)=1\)!") is nearly vacuous — a structurally-guaranteed sign says nothing about magnitude or identifiability. How far that one-sided prior alone can steer the ψ-sign verdict is measured directly in stress 04 · extension traps.
Guardrail: contraction measures informativeness, not importance
Contraction tells you whether the data spoke, not what it said — and a negative contraction (posterior wider than prior, as PyMC 6 gives for ψ) means the parameter is weakly identified, so trust its sign, not its magnitude. Always read the posterior mean and interval next to the learning diagnostic: the diagnostic flags informativeness; the posterior location and its width say what to believe. Whether a true substitution exists in the data-generating process is a question this weakly-identified cross-effect parameterization cannot settle on its own (§4).
3 · CombinedMMM — the routed synthesis
CombinedMMM is Models 1 and 2 fused: several outcomes with cross-effects and
an MvNormal likelihood, and mediators routing media to those outcomes. For
outcome \(k\), with direct media coefficients \(\beta^{\text{direct}}_{k,c}\), each
mediator \(m\) routed to a subset \(O(m)\) of outcomes, and cross-effects \(\psi_{j\to k}\):
Collapsing the mediator into the outcome equation gives the per-outcome total coefficient on channel \(c\) — the mediation product-of-coefficients carried across multiple outcomes:
\[ \boxed{\; \text{coef}_{k,c} \;=\; \beta^{\text{direct}}_{k,c} \;+\!\!\sum_{m:\,k\in O(m)}\!\! \beta_{c\to m}\,\gamma_{k,m} \;} \]
The correctness point is the routing: a mediator's contribution to
outcome \(k\) counts only when \(k \in O(m)\) (the model gates indirect paths by
_get_affected_outcomes, and the
total_<channel>_<outcome> Deterministic is built as
direct + the routed indirect sum). When decomposing a Combined model's per-outcome channel
effect, reason from the boxed formula — it makes the routing explicit.
💡 Framework fix (2026-06-10): one sign-constrained RV per configured direction
CombinedMMM's first edition declared a free ψ for every ordered
outcome pair plus never-used diagonal entries — a duplicate-dims
pm.Normal("psi", dims=("outcome","outcome")) that ignored the configured
arrow's direction and sign, crashed PyMC's post-sampling convergence checks, and threw
divergences by the hundred. Rebuilt to share MultivariateMMM's machinery —
one sign-constrained RV per configured cross-effect direction, structural zeros
elsewhere — the same fit went from 357 → 0 divergences (of 600
draws), max r-hat 2.91 → 1.02, free parameters 31 → 28. The full before/after is
measured in Stress 04 · Extension Traps.
And the sequel matters just as much: on the repaired model, the same
cannibalization arrow on the same data landed at ψ = −1.25 (contraction 0.97) — where
the PyMC 5 multivariate fit pinned ψ ≈ 0 (under PyMC 6 the multivariate fit no
longer pins it near zero: it too relocates far negative, ≈ −1.1, but weakly identified).
Either way the unidentified ψ/ρ split lands wherever the structure and priors push it,
each model "confident". A bugfix is not identification.
4 · The identification warning: count the parameters
Under PyMC 5 everything above looked like it "worked" — proportion mediated ≈ truth, ψ apparently pinned near zero. Under PyMC 6 that gloss breaks: proportion mediated only partially recovers (~0.69 / ~0.40) and ψ relocates to a sizeable negative value while its posterior stays wider than the prior — weak identification laid bare. Which only sharpens the warning the math has been whispering throughout: extension structure adds parameters faster than data adds moments. Count them for Aurora's 104 weeks:
| Structure added | New parameters | New data moments to pay for them |
|---|---|---|
| Second outcome, joint likelihood | +1 intercept, +4 media betas, +1 residual scale | 104 new observations — fine, this part is paid for |
| Cross-effect ψ + residual correlation ρ | +2 (up to \(K(K-1) + \tfrac{K(K-1)}{2}\) for \(K\) outcomes) | +1: a single contemporaneous cross-moment \(\mathrm{cov}(y_1,y_2)\). Two parameters, one moment — the split is prior-determined. |
| Latent mediator (no survey) | +\(a_m\), +\(\beta_{c\to m}\) per routed channel, +\(\gamma\), +\(\sigma_{\text{obs}}\) | +0: only the sums \(\gamma\beta_{c\to m} + \delta_c\) enter the likelihood (the §toy above). The ~24 monthly survey points are the only anchor for the split. |
The measured fits confirm the count. The multivariate model's learning audit left 13 of 19 free parameters at contraction < 0.1 on the PyMC 5 run — and under PyMC 6 ψ joins them (its contraction goes negative), so the data narrowed even less of the machinery it bought. The ψ/ρ split resolves however the priors and weak identification allow — under PyMC 5 the sign-constrained arrow looked pinned near 0, under PyMC 6 it relocates far negative (≈ −1.1) with the posterior wider than the prior; the LKJ correlation gives +0.41 either way; the shared-mediator allocation resolves only because the survey anchors it (and only partially under PyMC 6). Change those priors, change the PyMC version, or drop the survey and the same data tells a different story with the same confidence — that is precisely what Stress 04 · Extension Traps pressure-tests: the ψ that flips sign with its prior, the sibling channel over-credited 2.6×, the wrong mediator that fits perfectly. Read it as the adversarial companion to this page.
⚠️ Extensions are cure and disease
Use NestedMMM when a base model is structurally blind to a brand channel;
use MultivariateMMM when products genuinely interact. But every added
arrow is an assumption wearing a posterior. The defenses are the ones this
series keeps measuring: anchor latent structure with data (surveys, lift tests —
notebook 05's calibration machinery applies to the
extension models' estimands too), audit prior-vs-posterior learning for every new
parameter, and never read a sign constraint back as evidence of the sign.
What to take away
What to take away
- All three extensions share one media transform \(u_{c,t} = f_{\text{sat}}(\mathrm{adstock}_{\alpha_c}(x_{c,t}/s_c))\) and differ only in how \(u\) is wired to the outcome(s): mediator paths (\(\beta_{c\to m}\gamma_m + \delta_c\)), cross-effects (\(\psi\) in the mean, \(\Sigma\) in the noise), or both with routing (\(\text{coef}_{k,c} = \beta^{\text{direct}}_{k,c} + \sum_{m: k\in O(m)} \beta_{c\to m}\gamma_{k,m}\)).
- Mediation recovers what the base model is blind to — but only partly under PyMC 6: Aurora's TV and Display come out partially mediated (proportion mediated ~0.69 / ~0.40 vs truth 0.988 / 0.967; the PyMC 5 run read ~1.0), with the latent awareness reconstructed from a sparse monthly survey. The correctly-specified NestedMMM still beats the bad-control trap; the wide intervals are the honest signal.
- A directed cross-effect (mean) and shared demand (residual correlation) are different links: under PyMC 6 the direct ψ is weakly identified and relocates to a sizeable negative mean (≈ −1.13, posterior wider than the prior — trust the sign, not the magnitude; the PyMC 5 run read it pinned ≈ 0) while the residuals share a +0.41 demand wave.
- Under a one-sided prior, \(P(\psi<0)\approx 1\) restates the prior. Report contraction/overlap/shift instead — and read them for identifiability: under PyMC 6 ψ's contraction is negative (posterior wider than prior) and it relocates far negative, so the honest verdict is "cannibalization by sign, but weakly identified — distrust the magnitude," not the PyMC 5 read of "essentially no cannibalization".
- Count parameters against moments before trusting an extension: ψ and ρ split one cross-moment; a fully latent mediator's direct/indirect split has likelihood weight zero. Those splits are prior-determined — Stress 04 is the measured demonstration.
Check your understanding
Commit to an answer before opening each one.
A directed cross-effect ψ and a residual correlation ρ both make two products' sales move together. Why can the data barely tell them apart?
ψ lives in the mean structure (a causal arrow from one observed outcome into the other's expected level) while ρ lives in the noise (a common shock lifting both), but the joint distribution of two outcomes offers only one contemporaneous cross-moment to pin down both parameters. Two parameters against one moment means the split is largely prior-determined — which is why stress 04 can flip ψ's sign by changing its prior. Sections 2 and 4 develop the counting argument.
For a fully latent mediator, what does the sparse awareness survey buy?
With no survey, only the sums of the mediated and direct coefficients enter the likelihood, so every split between the indirect and direct path fits the outcome identically — the decomposition would be decided by the priors alone. The monthly survey points are the only data that anchor the split: the implied awareness series matches them near the true mediated share and nowhere else. "The latent-split toy" section demonstrates this live.
The fitted model reports P(ψ < 0) = 1.000. Why is that statement nearly vacuous, and what should be reported instead?
ψ is constructed as a negated HalfNormal, so every prior draw is already negative — the sign statement is guaranteed before any data arrive. The honest replacement is the learning diagnostic (contraction, overlap, shift) read alongside the posterior location. The PyMC 5 run read contraction 1.000 and a posterior pinned near zero; under PyMC 6 the same fit gives a negative contraction (posterior wider than the prior) with ψ relocated far negative (≈ −1.13) — the mark of a weakly identified parameter, so the verdict is "cannibalization by sign, magnitude untrustworthy," not "no cannibalization." Section 2.3, "Informative ≠ important", is the reference.
💡 Run it yourself
This page mirrors nbs/math/math_06_extensions.ipynb, authored by
nbs/builders/build_math_06_extensions.py and documented in
nbs/README_math.md. The notebook fits both models (500 draws / 500 tune,
2 chains, cores=1, random_seed=0) and every claim above ends
in a seeded assert — if the notebook executes clean, this page is still
true. The story-first companion is
Aurora 03 · Extended Models; the adversarial
companion is Stress 04 · Extension Traps.
Source: github.com/redam94/mmm-framework.