Skip to content

Codex/ivashchenko filter - #314

Open
thorek1 wants to merge 16 commits into
particle-filterfrom
codex/ivashchenko-filter
Open

Codex/ivashchenko filter#314
thorek1 wants to merge 16 commits into
particle-filterfrom
codex/ivashchenko-filter

Conversation

@thorek1

@thorek1 thorek1 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

No description provided.

thorek1 and others added 16 commits July 30, 2026 19:02
A pruned second-order solution is linear in the augmented state

    z = [x1 ; x2 ; x1[past] ⊗ x1[past]]

(the pruned state-space representation of Andreasen, Fernández-Villaverde &
Rubio-Ramírez), and the observation is a plain selection from it, so a Kalman
filter applies. That is the quadratic Kalman filter of Monfort, Renne &
Roussellet (2015).

The transition is exactly linear in z and the conditional first and second
moments are closed form. Writing aug1 = ā + Sε, every block of the innovation
is w = Gε + H(ε⊗ε − vec(I)); the Gaussian third moment vanishes, so
Var(w) = GG' + H(I+K)H' with K the commutation matrix. H is constant, G is
state dependent and evaluated at the filtered mean each period. What is
approximated is the conditional *distribution* — the innovation is quadratic in
ε, so the recursion is the best linear projection rather than the exact
conditional mean — and the Kronecker block is treated as a free state rather
than constrained to equal the square of the first.

Verified in three steps of increasing strength, because the obvious test is the
weakest: on a linear model 𝐒₂ = 0 leaves the quadratic blocks inert, so
agreeing with the Kalman filter there (it does, exactly) proves only the
plumbing. So the augmented transition is also checked against a Monte-Carlo
evaluation of the package's own pruned recursion, and the filter against a
particle filter on a genuinely nonlinear model, where they agree to 0.05 log
points at a measurement-error variance of 1e-4 and both approach the inversion
filter's zero-measurement-error limit.

Initialisation solves (I − 𝒜)z̄ = c directly and the ergodic covariance by
doubling; iterating either would need thousands of steps on a model with roots
near unity, which cost 0.23 log points before it was fixed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Adds `filter = :quadratic_kalman` to the registry and routes it in
`get_loglikelihood`. It is gated to `algorithm = :pruned_second_order` — the only
case in which the augmented state space is linear — and falls back to the
inversion filter with a message elsewhere rather than erroring. Smoothing is
turned off, since no smoother is implemented. Missing observations are rejected
explicitly.

The implementation is now type generic, so forward-mode AD flows through the
closed-form moment algebra; there is no finite differencing anywhere inside the
filter. The gradient is checked against central differences on the likelihood
(agreement to 1.3e-7). A hand-written reverse-mode rrule is *not* implemented —
at 2·nVars + nPast² states that is a substantial separate piece, and forward mode
costs one solve per parameter.

SW07 accuracy, measured against a particle filter at matching per-observable
measurement error on the EA data and initial values from the nonlinearities
repository (the parameters at which the inversion filter is well behaved at
second order):

  T=30   QKF -382.87   PF -406.10 (sd 1.02)   gap 0.77/period
  T=60   QKF -748.15   PF -787.98 (sd 2.91)   gap 0.66/period
  T=138  QKF -2150.55  PF -2231.13 (sd 2.73)  gap 0.58/period

so the filter overstates the likelihood by roughly 0.6 per period there, against
0.001 per period on the small RBC. The difference tracks the strength of the
nonlinearity: max|𝐒₂| is 1451 on SW07 against 2.5 on the RBC. That is the
linear-projection approximation showing, not an implementation error — the
augmented transition is verified against a Monte-Carlo evaluation of the
package's own pruned recursion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
…e it ~7x faster

Reverse mode. The recursion is split out as `quadratic_kalman_recursion` and given
a hand-written `rrule`. The derivation is made tractable by noticing that G(z) is
*affine* in z — both the 𝐒₂ block and the Kronecker block are linear in ā, which
is affine in z — so vec(G) = g₀ + Λ(Pz) and the whole state dependence of the
innovation covariance collapses to one matrix. That removes the per-period
Kronecker adjoints entirely; Λ is built exactly, column by column, from the affine
map rather than by differencing.

All nine cotangents (𝒜, c, QH, g₀, Λ, Hm, data, z₀, Σ₀) agree with ForwardDiff to
~1e-16, and that comparison is now a test. One subtlety cost a wrong derivative
before it was found: the forward pass symmetrises F, so the cotangent reaching CP
and Hm is (F̄+F̄')/2 — omitting that left d/dHm wrong by 2% while every *other*
derivative still looked exact.

The rule covers the O(T·nz³) recursion, which dominates and scales with the
sample. Building the system matrices is O(1) in T and is left to ordinary AD, so
the two compose normally rather than nesting AD inside a pullback.

Speed. Two exact reformulations, neither changing the likelihood (-2150.547
before and after on Smets-Wouters):

  * q = x₁ₚ⊗x₁ₚ = vec(x₁ₚx₁ₚ') is symmetric, so carry vech instead of vec via
    duplication/elimination matrices: the Kronecker block drops from nPast² = 729
    to nPast(nPast+1)/2 = 378.
  * only the past states and the observables are ever read out of the x₁/x₂
    blocks, so retain those rows instead of all nVars: 67 rows become 34.

Together nz goes from 863 to 446 and the filter from 7.09 s to 1.02 s per
evaluation — 7x, against 7.24x predicted by the cubic scaling.

Not done: the top-level `get_loglikelihood` rrule does not dispatch to this path,
and a pre-existing guard refuses reverse-mode AD whenever measurement error is
active, so `Zygote.gradient` cannot yet reach it end to end. ForwardDiff works.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The recursion already had a verified hand-written adjoint, but nothing could
reach it: the top-level rrule looks for `rrule(calculate_loglikelihood, Val(filter), …)`
and silently returns a zero gradient when none exists. Three things were missing.

1. The build adjoint. Cotangents of (𝒜, c, QH, g₀, Λ) are now pushed back onto
   𝐒₁/𝐒₂ analytically, including the ergodic initialisation: z₀ = (I−𝒜)⁻¹c gives
   a transposed solve, and Σ₀ = 𝒜Σ₀𝒜' + Q₀ gives a second Lyapunov equation
   X = 𝒜'X𝒜 + Σ̄₀, after which Q̄₀ = X and 𝒜̄ += 2X𝒜Σ₀. Verified against
   ForwardDiff at ~4e-15 for ∂𝐒₁, ∂𝐒₂ and ∂data.

2. The filter now routes through `calculate_loglikelihood`, with an rrule at that
   interface returning ∂𝐒 in the expected position, scattered from the retained
   rows back onto the full solution matrices.

3. The reverse-mode guard refused any likelihood with measurement error. It is
   relaxed for this filter only, since its adjoint covers H.

One wiring bug worth recording: the top-level rrule never passed
`measurement_error` to the inner rrule. That was harmless while the guard made
measurement error impossible, but here it meant reverse mode differentiated the
H = 0 likelihood while the primal used H > 0 — a *finite* gradient that was
simply wrong (relative deviation 2.5). Zygote and ForwardDiff now agree to
6e-14, and that comparison is a test.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Measured before guessing: the filter spent 60% of its time not in the covariance
propagation but in allocation — 2509 MiB over a 138-period Smets-Wouters run,
roughly 18 MB per period, mostly nz×nz temporaries from `𝒜*Pc*𝒜'`, `G*G'` and
the `𝒞` products.

Three changes:

  * every buffer is allocated once per call and reused, with `mul!` throughout;
    the rank-nExo term G G' folds into Pp as a single gemm update rather than
    forming Q separately;
  * 𝒞 is a selection matrix, so `𝒞*zp`, `𝒞*Pp` and `CP*𝒞'` become indexing
    instead of three gemms;
  * the symmetrisations write both triangles in one pass rather than building
    (X+X')/2 as a fresh matrix.

0.986 s → 0.485 s and 2509 MiB → 171 MiB on SW07. The covariance propagation is
now 92% of the remaining runtime, so further gains have to come from that term.

One trap this surfaced: preallocation fixes the element type, so the promotion
has to cover *every* differentiable argument. Covering only 𝒜, Y and g₀ left
forward-mode AD working with respect to those three and failing with respect to
c, QH, Λ, Hm, z₀ and Σ₀ — caught by the cotangent tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Adds a full section to the Filters page: the augmented-state construction and
why pruning is what makes it possible, what is exact versus approximated, the
source and size of the likelihood bias, when the filter is and is not usable,
and a measured cost breakdown.

The two findings worth recording:

The bias is not what it looks like. It does not come from 𝐒₂ — zeroing its
ε⊗ε block leaves rank(Q) unchanged. It comes from kron(V,V) in the *first-order*
solution: x₁[past] = (deterministic) + Vε, so q = x₁[past]⊗x₁[past] inherits
Vε⊗Vε whatever 𝐒₂ is. The quadratic noise is intrinsic to carrying a Kronecker
term as a state. And because the observation has zero loading on q, the data can
never shrink the resulting fictitious uncertainty, so the error persists as the
measurement error goes to zero (converging to -1.52 rather than to 0).

But it is almost a level shift, so it is far less damaging than it first looks:
profiled against the exact inversion likelihood the gap varies by only ~0.2 log
points across a parameter grid and the mode is unchanged. Latent states — the
filter's actual purpose, per Kollmann (2015) — are good: 2.8% relative RMSE on
capital, ~11% on the unobserved shock processes. So it is fine for state
estimation and point estimation, and unsafe for model comparison, where the
level error differs across models with the shock-to-observable ratio.

Cost breakdown measured per period at nz=446: the two nz³ triple products are
91% of the loop (1.09 ms and 1.26 ms), everything else 9%. Against the inversion
filter's n_ε×n_ε solve that is 446³ vs 7³ — about 2.6e5 in flops. Structural, not
tunable; sparsity measures 10× slower since 𝒜 is ~50% dense.

Also records that this is Kollmann's filter, not the Monfort-Renne-Roussellet
quadratic Kalman filter: theirs has a linear transition and a *quadratic
measurement*, so the data loads on the Kronecker block directly and shrinks the
very uncertainty that is irreducible here.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
`run_quadratic_kalman` carried its own inlined doubling loop with looser
tolerances (1e-12, 60 iterations) than the `qkf_lyapunov` helper used by the
pullback (1e-15, 80), so the forward and reverse passes initialised at slightly
different ergodic covariances. Both now call one helper, which dispatches
Float64 problems to `solve_lyapunov_equation` — the same workspace-backed
doubling solver the pruned second-order moments code uses — and keeps the
self-contained loop as the fallback for AD element types, which that solver does
not accept.

The forward pass hands its converged covariance to the reverse pass, which was
re-solving the identical equation; passing it as `initial_guess` turns that
second solve into a residual check (46.6 ms -> 3.1 ms on SW07).

Measured on SW07 at pruned second order, 7 observables: 485 ms -> 425 ms per
likelihood, with the log-likelihood unchanged to the last digit
(-2264.4493051268087) and all 31 quadratic-Kalman tests passing, including the
hand-written rrule cotangents against ForwardDiff.

Warm-starting across parameter draws was measured and deliberately not done: the
guess only pays when it is exact, and a 1e-6 relative parameter move already
makes it a net loss.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The quadratic Kalman section explained the filter on its own terms but never put
it next to the linear one, which is where the intuition actually is: it is the
same recursion, and setting S2 = 0 collapses one onto the other exactly.

Adds a side-by-side of the two loops as they appear in the source, a table of the
structural differences, and short notes on the three that carry consequences —
the noise covariance becoming state-dependent (the conditional heteroskedasticity
a second-order solution adds), the innovation ceasing to be Gaussian (why the
linear filter is exact and this one is not), and the cost being cubic in a
squared dimension (n_past^6), which governs when the filter is usable.

Dimensions are the measured Smets-Wouters ones: 34 retained rows against an
augmented 446 = 2*34 + 27*28/2.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The third-order analogue of the quadratic filter, built on the same fact:
pruning truncates the Kronecker hierarchy at a fixed rung at every order, so the
pruned third-order solution is exactly linear in the augmented state
[x1; x2; x3; a*a; a*b; a*a*a].

The substance is the closure. Writing a_n = M a + v with v state-independent,
each new Kronecker block resolves back onto blocks the state already carries —
q11' onto q11, q12' onto q12 and q111, q111' onto q111 and q11 — and no
fourth-order block appears because a_n has no q11 component. Computing the new
blocks the obvious way, as kron(a_n, a_n), is quadratic in z and destroys the
linearity the filter rests on without failing loudly, so the affineness of the
step is asserted directly in the tests.

Validated on an RBC model (2 shocks, 3 past states): the step is affine to
3e-15, reproduces the pruned third-order recursion to 2e-19 on every block
including the Kronecker ones, its quadrature moments match Monte Carlo, and the
log-likelihood matches a converged bootstrap particle filter at 181.78 against
181.43 over 60 periods — 0.006 per period, better than the quadratic filter's
0.025 on the comparable model.

It only fits small models. The augmented dimension is 3nr + 2nPast^2 + nPast^3
and the covariance recursion is O(nz^3), so cost grows as nPast^9: fine to
nPast=10, marginal at 12, hopeless at Smets-Wouters' 27 (nz = 21243, 3.6 GB per
matrix). The build refuses above CUBIC_KALMAN_MAX_DIMENSION rather than
appearing to hang.

Not yet done, and noted in the docs: analytical assembly instead of quadrature,
vech compression of the Kronecker blocks, and a hand-written rrule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Four of them, all measured on the RBC test model (2 shocks, 3 past states) with
the log-likelihood unchanged to the last digit at every step (181.77600836054...):

- vech compression of the symmetric Kronecker blocks. q11 and q111 are symmetric
  and fully symmetric respectively, so the state carries one entry per sorted
  multi-index. Applied by index maps rather than duplication/elimination matrices,
  which here would cost more than the compression saves. nz drops 60 -> 40 on the
  test model and 21243 -> 4863 at Smets-Wouters size; since the recursion is
  O(nz^3) this is two orders of magnitude on a mid-sized model, and it moves the
  practical wall from nPast ~= 12 to ~= 20.

- An allocation-free step. The step does a few hundred flops but was allocating
  ~13 kB per call, and it runs (nz+1)*n_nodes times to build the transition plus
  n_nodes times per period — so it was entirely allocation-bound. With
  preallocated buffers it is 4.33 us/12960 B -> 2.71 us/0 B. The symmetric output
  blocks are written straight to their canonical slots, so the full nPast^3
  vector is never formed.

- An allocation-free recursion on preallocated buffers with in-place BLAS, and
  the observation applied by indexing its three selected rows instead of a gemm
  with a 0/1 matrix.

- The quadrature contracts its node evaluations with one gemm rather than n_nodes
  rank-one updates, and the transition build skips the variance it never uses.

End to end: 9.1 ms -> 5.9 ms on the test model, where the covariance recursion is
only ~13% of the time and the quadrature dominates. The gains are far larger on
models where nz is big enough for the O(nz^3) term to matter.

Tests grow to 16: the compression maps are checked to round-trip a genuine
symmetric Kronecker product, and the transition reference now forms every
Kronecker product in full before compressing, so it exercises the compressed
algebra rather than assuming it.

Still not carried over: analytical assembly of the transition in place of
quadrature, and a hand-written rrule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
The in-place step writes the row-major flattens out by indexing, so nothing
calls it any more; only the comments still use the term, which is now defined
where they can see it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
f(z, ·) is a polynomial of degree <= 3 in eps whose coefficients are affine in z.
Recovering that coefficient matrix C(z) once gives both moments in closed form:

  E[f]   = C(z) m,        m_a  = E[eps^a]
  Var(f) = C(z) Psi C(z)',  Psi_ab = E[eps^(a+b)] - E[eps^a] E[eps^b]

and since the shocks are independent standard normals, E[eps^a] factorises into
double factorials, so Psi is a closed form rather than a set of Isserlis
pairings. C(z) is recovered by interpolation on C(nExo+3, 3) points, which is
also where the tensor Gauss-Hermite rule is left behind: its node count grew as
npt^nExo (16384 for seven shocks at npt=4) against 120 for the coefficient basis.

Consequences: no quadrature per period at all — Q(z) is one matvec and two gemms
— and the build no longer blows up in the number of shocks.

The quadrature path is kept and the analytic assembly is tested against it rather
than assumed: A and c agree to 1e-9, Q(z) to 1e-8 relative at a non-trivial z,
and the monomial moment vector matches tensor Gauss-Hermite to 1e-10. The
log-likelihood is unchanged at 181.776008360546.

5.9 ms -> 2.3 ms on the test model (9.1 ms before this round of work started).
Tests 16 -> 21.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Forward mode now works end to end: the element type is threaded through the
system build, the step workspace and the recursion, so ForwardDiff duals flow
through. Gradients match central finite differences to 7e-11. The in-place BLAS
path is kept for Float64 and the AD path falls back to allocating solves, since
rdiv! has no Cholesky method for dual element types.

That is the right mode for this filter: it is capped at nPast ~ 20 by its own
cost, so a gradient is a handful of few-millisecond primal passes.

More important is what reverse mode was doing. There is no hand-written rrule for
this filter, and the top-level rrule's `llh_rrule === nothing` branch responds to
that by returning on_failure_loglikelihood with an all-zero gradient — no error,
no warning. Measured on the test model: Zygote returned exactly zeros where the
true gradient has entries up to 3e4. A sampler would have run on that and
produced garbage silently. Reverse mode now errors and names forward mode as the
alternative.

The existing measurement-error guard hid this in the common case, so the test
pins the new guard specifically by asking for a gradient *without* measurement
error, where that older guard cannot be what fires.

Tests 21 -> 24. Quadratic Kalman tests still 31/31 after the rrules.jl change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Reverse mode now works and matches central differences to ~1e-10, on every
measurement-error shape (none, diagonal, full covariance). The chain
S -> sys -> {f(z_k, eps_p)} -> (A, c, c0, Lambda) -> llh is differentiated in
three pieces, each verified against ForwardDiff *in isolation* so a regression
localises instead of just moving the end-to-end number:

- the step adjoint (5e-16). Everything the step builds from z and eps alone --
  aug, K2, K12, K3, the Q blocks -- is constant here, so only the paths through
  the solution matrices carry cotangents.
- the build adjoint (2e-15). The build is linear in the collected step
  evaluations, so those maps transpose directly and the work is replaying the
  step adjoint over the same (nz+1)*N points the forward pass visited.
- the recursion adjoint, mirroring the quadratic filter's verified one; the
  structural difference is Q = C Psi C' in place of GG' + Q_H, whose cotangent is
  2 Q_bar C Psi because P_bar_p is symmetrised before use.

Reverse mode is now the better choice here as well as the correct one: 12.0 ms
against a 2.3 ms primal and independent of parameter count, where forward mode is
44.3 ms for seven parameters and grows linearly.

Also fixes a 6.5x primal regression introduced with forward-mode support. The
promoted element type was stored as a field of the system, so `Matrix{sys.Tv}`
inferred as DataType rather than Type{Float64} and lost specialisation
throughout; the hot paths now read `eltype(sys.S1)`, which is inferrable, and the
primal is back to 2.27 ms from 14.94 ms.

The zero-gradient guard added in the previous commit is removed, since the
fallback it protected against is no longer reachable.

Tests 24 -> 28. Quadratic Kalman still 31/31 after the rrules.jl changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
Profiling the build showed it is (nz+1)*N step evaluations and essentially
nothing else, and that inside a step one operation dominated: contracting the
Kronecker input K3 against S3. That matrix is very wide and very sparse -- 8x1331
on a four-shock model, 23% dense -- so the product is memory-bound, and it
measured 5.5 of the 10.8 us a step took.

Only the structurally nonzero columns of S2 and S3 are now kept (536 of 1331 for
S3, 65 of 121 and 40 of 66 for the two S2 paths), which shrinks both the vector
that has to be built and the product that consumes it, and drops those iterations
from the branchy K3 loop as well.

Liveness is taken from the stored pattern of the *sparse* solution matrices, not
from numerical zeros of the densified copy. A column that merely happens to
vanish at one parameter draw may be nonzero at the next, and dropping it would
silently zero a real derivative rather than fail. The existing exactness test
guards this directly: it compares the restricted step against a reference built
from full Kronecker products.

Measured on a four-shock, six-past-state model (nz = 137):

  step            10.8 -> 6.4 us
  build           56.2 -> 34.0 ms
  step pullback   26.3 -> 18.0 us

The build's pullback also collapses two sums that were being accumulated one
column at a time, each allocating an nz-by-N array per pass.

Checked what else sparsity could buy and it does not: Psi is 9% dense and
block-diagonal by monomial parity, but it appears only in the smaller of the two
products forming Q, and its rank is N-1, so factoring removes just the constant
monomial.

Also validates the reverse-mode adjoint on a second, wider model: Zygote against
central differences is 1.1e-9 there, with the gradient at 4.8x the primal.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YdYHujHfAMxP3jt9kwzzT
@codecov-commenter

codecov-commenter commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0.99613% with 1789 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.68%. Comparing base (6344ea7) to head (3e4bd53).
⚠️ Report is 3 commits behind head on particle-filter.

Files with missing lines Patch % Lines
src/filter/cubic_kalman.jl 0.00% 747 Missing ⚠️
src/filter/ivashchenko_kalman.jl 0.00% 599 Missing ⚠️
src/filter/quadratic_kalman.jl 0.00% 401 Missing ⚠️
src/rrules.jl 8.00% 23 Missing ⚠️
src/get_functions.jl 47.61% 11 Missing ⚠️
src/MacroModelling.jl 42.85% 8 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           particle-filter     #314      +/-   ##
===================================================
- Coverage            84.16%   79.68%   -4.49%     
===================================================
  Files                   36       39       +3     
  Lines                31696    33493    +1797     
===================================================
+ Hits                 26678    26690      +12     
- Misses                5018     6803    +1785     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants