Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Collective regimes

A collective regime carries one value function per stakeholder and takes a single shared action for all of them. Two further capabilities travel with it: feasibility that reads values rather than only states, and a transition whose branch depends on values at the target regime.

Six declarations express this, and each one goes inside a slot Regime already has, so a collective model has no extra constructor arguments to learn.

DeclarationWhere it is declaredWhat it expresses
CollectiveUtilityfunctions={"utility": ...}the regime’s stakeholders and their flow utilities
ParetoObjectiveCollectiveUtility(objective=...)how stakeholder action values are scalarized
ValueDependentConstraintconstraints={"name": ...}a feasibility predicate that may read values
ValueDependentTransitiontransition={"target": ...}a transition into one target, gated on values there
StakeholderRouteValueDependentTransition(routes=...)where one source stakeholder goes on each branch
ProjectedRegimeValuea constraint’s references, an edge’s gate_references, or a route’s fallbackanother regime’s current-period value, at a mapping

All six are frozen, keyword-only dataclasses defined in src/lcm/collective.py and exported from lcm. A type violation on any of them raises RegimeInitializationError.

For the modelling narrative — why a household declares its weights instead of summing them, what the dissolution flag means economically — see Households and value-dependent choice.

CollectiveUtility

utilities: Mapping[str, UserFunction | Phased | None]
objective: ParetoObjective | None = None

Declared as functions={"utility": CollectiveUtility(...)}. That declaration is what makes a regime collective: the utilities keys are the regime’s stakeholders, in insertion order, and that order fixes the trailing stakeholder axis of the regime’s value function and of every published array.

objective=None (the default) weights the stakeholders equally.

The declaration itself stays in the raw functions["utility"] slot. Construction derives the regime’s stakeholder tuple and Pareto objective from it, while the engine-facing decomposed_functions view exposes every nondelegated body and any delegated body already supplied. After model-level declarations are merged, successful finalization guarantees one utility_<stakeholder> entry per stakeholder. A utility entry may be:

The household maximizes the objective over the feasible action product and reads off each stakeholder’s own action value at that common choice.

a(x)=argmaxa:F(x,a)sλs(x)Qs(x,a),Vs(x)=Qs(x,a(x)).a^*(x) = \arg\max_{a\,:\,F(x,a)} \sum_s \lambda_s(x)\, Q^s(x, a), \qquad V^s(x) = Q^s(x, a^*(x)).

The following are rejected:

stakeholders and pareto_objective are derived, read-only fields. They are not parallel declaration routes and cannot be passed to Regime(...) or Regime.replace.

ParetoObjective

weights: Mapping[str, UserFunction | float]
normalization: str = "pointwise"

weights holds one weight λs\lambda_s per stakeholder, keyed by stakeholder name. A float is a constant. A callable is a function of the regime’s states and of period / age; every other argument it names becomes a free scalar parameter under the regime’s pareto_objective key (see Parameters). An argument spelled like one of the regime’s other functions does not receive that function’s output — it becomes a parameter you must supply.

A weight may not read an action. A weight that varies with the choice states a different objective per candidate, whose maximizer is a Pareto optimum of no fixed weighting.

normalization is annotated as a plain str and validated against two values:

Admissibility of a weighting — one finite, non-negative weight per stakeholder with a strictly positive total — is checked in two places, because a constant is knowable at construction while a function of a parameter is not. See Where each rule is enforced.

ValueDependentConstraint

predicate: UserFunction
references: Mapping[str, ProjectedRegimeValue] = field(
    default_factory=lambda: MappingProxyType({})
)

Declared inside constraints, beside the ordinary callables, so a regime has one constraint slot rather than two. The regime’s mask is the AND of both kinds, and a state cell whose mask is empty publishes the regime’s dissolution flag D.

predicate returns True where the cell is feasible. It may read:

references maps the name a reference enters the predicate under to the ProjectedRegimeValue supplying it. Two constraints of one regime may share a reference name only if they declare the identical reference.

A ValueDependentConstraint is only meaningful on a collective regime: Q_<s> exists only where stakeholders do. Declaring one on a singleton regime is rejected at Regime construction.

ProjectedRegimeValue

regime: RegimeName
projection: Mapping[StateName, UserFunction]
stakeholder: str | None = None

Another regime’s current-period value, read at mapped state coordinates. The reference regime is solved earlier in the same period — the solver orders each period’s active regimes topologically by these declarations — and its value function is interpolated at the projected coordinates: linear on continuous axes, lookup on discrete axes.

Reading the current period rather than the continuation is what a within-period participation constraint needs: a couple’s period-tt decision is checked against the values its members would have as singles in that same period tt.

regime names another regime of the model, active in every period the declaring regime is active. No transition edge between the two is required — a reference read works across otherwise unconnected regime islands.

stakeholder names whose value to read from a collective reference regime. It is required there and must be None for a singleton reference.

Where the declaration sits fixes what its projection may read and which states it owes a coordinate function for:

PositionProjects fromMay introduce free paramsOwes one coordinate per
ValueDependentConstraint.referencesthe DECLARING regime’s cellnostate of the reference regime’s value function
ValueDependentTransition.gate_referencesthe TARGET regime’s gridyes, as edge paramsstate of the reference regime’s value function
StakeholderRoute.fallbackthe TARGET regime’s gridyes, as edge paramsstate the reference regime carries in simulation

The fallback owes the larger set because a route does not only price the closed branch, it writes the routed row into the fallback regime, and forward simulation carries every one of that regime’s simulate states per subject. A state left unprojected would keep whatever the row held before the edge routed it there. The solve states are a subset, so the same projection still serves the fold’s value read.

A ProjectedRegimeValue on an age-specialized reference regime is measured against the grid of the period whose value is being folded; see Gate references and leg fallbacks on an age-specialized regime.

StakeholderRoute

fallback: ProjectedRegimeValue | Phased
target_stakeholder: str | None = None

Where one source stakeholder goes on each branch of a gated transition. A route owns four destinations, and simulation carries all four: the open branch’s regime (the transition’s own key) and role (target_stakeholder), and the closed branch’s regime and role (fallback.regime and fallback.stakeholder). A row landing in a singleton regime carries no role, so target_stakeholder=None means a singleton target.

A bare ProjectedRegimeValue fallback is both what the closed branch is worth and where it puts a row. Phased(solve=..., simulate=...) separates them, because what a household expects from leaving and what a settlement hands it are two objects:

Both sides of a Phased fallback must be ProjectedRegimeValue, and each is validated against the phase that reads it.

Three read-only properties resolve the declaration:

PropertyReturns
solve_fallbackthe ProjectedRegimeValue the closed branch is priced at
simulate_fallbackthe ProjectedRegimeValue a routed row’s regime, role and states come from
fallback_is_phasedwhether the two branches were declared separately

ValueDependentTransition

probability: UserFunction | MarkovTransition
gate: UserFunction
routes: Mapping[str, StakeholderRoute]
gate_references: Mapping[str, ProjectedRegimeValue] = field(
    default_factory=lambda: MappingProxyType({})
)
off_grid: Literal["pointwise", "reject"] = "pointwise"

Declared inside transition, keyed by target regime name, so target selection and value-dependent routing are one declaration of one semantic transition.

The key is always the gate-open target — the regime a row enters when the gate is true. A dissolution edge is therefore keyed by the continuing collective regime under gate = ~D_target, with each partner’s own regime as that partner’s route fallback. Keying it by one partner’s regime would send both partners there whenever the couple stays together.

probability accepts either a MarkovTransition or, as a convenience specific to ValueDependentTransition, a bare probability callable. The latter is wrapped in MarkovTransition in decomposed_transition, because that is the grammar the canonical per-target cell consumes. An ordinary per-target transition cell still requires an explicit MarkovTransition; a bare callable there is rejected as an unsupported deterministic per-target transition.

probability and gate are two distinct operations: probability selects whether this target edge is attempted at all, while gate keeps that target or takes the route’s stakeholder-specific fallback.

routes holds one route per source stakeholder, keyed by stakeholder name. A singleton source declares exactly one route, under any key.

A ValueDependentTransition may be repeated inside the two mappings of an outer Phased(solve=..., simulate=...) transition. A target is value-dependent in both phases or in neither. The two declarations must name the identical gate callable and equal routes, gate references, and off_grid contract; only probability may differ, allowing perceived and realized transition probabilities to diverge without changing the edge.

Gate operands

gate is a Boolean predicate evaluated pointwise on the target regime’s grid, in the target fold’s context. It may read:

OperandAvailable when
V_targetthe target regime is a singleton
V_target_<s>the target regime is collective, one per its stakeholders
D_targetthe target regime is collective — its dissolution flag
each gate_references keyalways, bound to that reference’s interpolated value
target states, params, period, agealways

Mutual consent is the strict, unanimous gate (V_target_f > V_single_f) & (V_target_m > V_single_m); “no dissolution this period” is ~D_target.

The whole V_target vocabulary is reserved to the engine, so a gate_references key spelled V_target, V_target_<s> or D_target is rejected rather than silently preempted by the built-in operand.

At the end of each period’s solve, the engine folds one gated continuation per declared edge and source stakeholder ss on the target regime’s grid,

Wˉs(x)=where(gate(x),  Vtargetroutes(x),  Vfallbacks(πs(x))),\bar W^s(x) = \operatorname{where}\big(\text{gate}(x),\; V^{\text{route}_s}_{\text{target}}(x),\; V^s_{\text{fallback}}(\pi_s(x))\big),

and the source’s continuation reads Wˉ\bar W in place of the raw target value.

off_grid

What the edge promises about a landing point between the target’s nodes.

Where each rule is enforced

The enforcement point is not uniform, and the distinction is load-bearing: a rule checked at model build cannot be repaired by an argument to solve, while a rule checked at evaluation only fires once the model runs.

RuleEnforced atException
normalization is "pointwise" or "none"ParetoObjective constructionValueError
weight keys match the regime’s stakeholdersRegime constructionRegimeInitializationError
a constant weight is finite and non-negative; the constants leave a positive totalRegime constructionRegimeInitializationError
a ValueDependentConstraint on a singleton regimeRegime constructionRegimeInitializationError
a regime-level reference projection introduces no free parameterRegime constructionRegimeInitializationError
a gate is a plain callable, not a MarkovTransitionRegime constructionRegimeInitializationError
routes covers the source’s stakeholder structureRegime constructionRegimeInitializationError
phased declarations make a target value-dependent in both phases and agree on the edgeRegime constructionRegimeInitializationError
taste shocks, a nonlinear certainty equivalent, or a non-GridSearch solver on a collective regimeRegime constructionNotImplementedError
the same three on the SOURCE regime of a ValueDependentTransitionRegime constructionNotImplementedError
a reference or fallback regime exists, and stakeholder matches its structuremodel buildModelInitializationError
a projection covers exactly the states its position owesmodel buildModelInitializationError
the same-period reference graph is acyclicmodel buildModelInitializationError
a gate reads D_target on a singleton targetmodel buildModelInitializationError
a gate or projection argument names a node of the target’s own DAGmodel buildModelInitializationError
a gate_references key aliases V_target / V_target_<s> / D_targetmodel buildModelInitializationError
off_grid="reject" on a target carrying a continuous statemodel buildModelInitializationError
an ungated transition between regimes of different stakeholder structuremodel buildNotImplementedError
the gate’s realized return dtype is Booleanevaluation — every solve(), and again in the simulate phaseRegimeInitializationError
a callable weight is finite, non-negative and positively totalled on the gridevaluation — every solve()InvalidParamsError

The last two are the ones easily mistaken for build-time checks.

A gate’s return annotation cannot constrain what a user function returns, so the realized dtype is checked where the gate is evaluated. A model whose gate returns a float builds without complaint and fails on the first solve(), naming the target regime and the phase. This matters because a gate selects its branch with a strict where, in which every nonzero value is true: a numeric gate would open the edge on every cell rather than express a probability.

A Pareto weighting is likewise a property of values once a weight is a function of a parameter or a state. A declaration admissible for one parameter draw need not be for the next, so the check runs on every solve, on the regime’s own grid, at every age the regime is active — not only the first time.

Parameters

Free arguments of these declarations reach get_params_template() in three places.

Per-stakeholder utilities appear under utility_<stakeholder>, one entry per stakeholder, at the top level of the regime’s template.

A value constraint’s predicate appears under the name the constraint has in constraints, at the top level of the regime’s template branch — for example template["couple"]["participation_f"]["slack"]. Its references contribute nothing, because a regime-level reference projection may introduce no free parameter.

Pareto weights appear under the pseudo-function key pareto_objective. Every stakeholder’s weight reads one shared namespace, so a parameter named in two weights is one parameter and appears once. A weight argument that names a state, period or age is wired at call time and never surfaces. The key is present only when some weight is a callable with a free argument.

Every callable of a gated transition nests under the target regime’s name, beside that target’s next_regime cell:

Template entryCallable
gatethe gate predicate
gate_ref_<reference key>_<state>one gate_references projection
leg_fallback_<fallback regime>_<state>one route fallback projection (solve)
simulate_leg_fallback_<fallback regime>_<state>the simulate side of a Phased fallback

A fallback entry is named by the regime it falls back to rather than by its routes key, because that is the identity both sides of the solve/simulate seam can spell. Two routes of one edge falling back to the same regime therefore share one entry, and their parameters are unioned there.

For a source regime with a gate parameter marriage_bonus, a parameterized gate reference, and a Phased fallback whose simulate side takes settlement_share:

template["source"]["target"] == {
    "next_regime": {},
    "gate": {"marriage_bonus": "float"},
    "gate_ref_V_outside_x": {"ref_share": "float"},
    "leg_fallback_fallback_x": {},
    "simulate_leg_fallback_fallback_x": {"settlement_share": "float"},
}

Solve and simulate

A gate that reads D_target consumes the addressed dissolution artifact from the complete solution:

solution = model.solve(
    params=params,
    log_level="debug",
)

result = model.simulate(
    params=params,
    initial_conditions=initial_conditions,
    solution=solution,
    log_level="debug",
)

simulate(solution=solution) validates and projects the required artifact. Omitting solution asks simulation to solve and thread the same result automatically.

Roles are carried per row

A row’s own stakeholder identity is what picks its route, and it moves with the row: it is set from a route’s target_stakeholder on entering a collective regime, cleared when the row lands in a singleton one.

Seed it in initial_conditions under own_stakeholder, in the model-wide vocabulary model.stakeholder_names_to_ids:

initial_conditions = {
    "wealth": jnp.array([1.0, 2.0, 3.0]),
    "regime_id": jnp.full(3, model.regime_names_to_ids["couple"]),
    "own_stakeholder": jnp.full(
        3, model.stakeholder_names_to_ids["f"], dtype=jnp.int32
    ),
}

The seed is demanded exactly where the answer turns on it, which is a property of the starting regime rather than of the model. A cohort starting in a collective regime is refused without it — rather than defaulted to whichever stakeholder happens to be declared first — when some collective regime in that regime’s forward closure declares a gated transition with more than one route. Because a row keeps its role across an ordinary regime transition, the closure is what decides: a two-leg transition the cohort runs into later demands the seed, and one in a regime the cohort can never reach demands nothing. A cohort starting in a singleton regime occupies no role and needs none. A declared code outside the model’s role vocabulary, or one naming a role the starting regime does not have, is rejected as an InvalidInitialConditionsError.

Simulation carries one fixed-size cohort. Dissolution does not split one row into two linked people.

Published columns

SimulationResult.to_dataframe() publishes:

Capabilities a collective regime does not have

Each of these raises at Regime construction, naming the regime slot to change:

The source regime of a ValueDependentTransition carries the same three restrictions, whether or not it is collective: it reads the folded continuation through the grid-search machinery, which a DC-EGM, taste-shock or certainty-equivalent source does not have.

A fold=True IID process is a further restriction, and a different one: a fold integrates the shock’s node axis away immediately after the period’s collective readout, so no same-period gate, value-constraint predicate or reference projection may read the shock’s realized value. That is rejected at Regime construction for a regime’s own declarations, and at model build for a folded regime read as another regime’s same-period endpoint.

Finally, an ungated transition between regimes of different stakeholder structure stays rejected at model build. Mixed singleton/collective topologies go through ValueDependentTransition, which is what lets a row change household structure without mixing values across it.

Derived engine views

Each declaration above stays in the raw slot where the author wrote it. Regime construction derives stored, read-only fields from those declarations. The decomposed_* properties separately compute engine-facing views from the current raw slots whenever they are read. Neither kind of output is a declaration route: none can be passed to Regime(...) or to Regime.replace.

DeclarationConstruction-derived fieldsOn-access engine view
CollectiveUtilitystakeholders, pareto_objectivedecomposed_functions: nondelegated and already-supplied bodies; complete after successful model finalization
ValueDependentConstraintvalue_constraints[name], same_period_refs[reference]decomposed_constraints: ordinary constraints only
ValueDependentTransitiongated_edges[target]decomposed_transition[target]: the selection probability

The declaration objects themselves stay where the author wrote them, in functions, constraints and transition. The engine reads the decomposed views (decomposed_functions, decomposed_constraints, decomposed_transition) rather than the raw slots. Reading either a stored derived field or a decomposed view therefore reveals what a declaration produced without creating a second way to declare it.

The derived edge type is _lcm.gated_edge.GatedEdge. It is engine-internal and not part of the public API: there is exactly one way to declare a gated edge, and it is ValueDependentTransition.

Model code reads these fields; model authors do not need them. Declare a household with the six objects above.

See also