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 an argument Regime or Model already has — a regime slot, or a Transition in Model(edges=...) — 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
GateTransition(gates=...) in Model(edges=...), keyed by targeta transition into one target, gated on its state
StakeholderRouteGate(routes=...)where one source stakeholder goes on each branch
ProjectedRegimeValuea constraint’s 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, where a household goes when no action is feasible — 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)=arg⁡max⁡a : 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 carries the -inf sentinel.

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 solved 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. A reference read requires its value in every period the declaring regime is solved, so the reference regime is solved there too without being declared as a start. 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
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 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

Gate

predicate: UserFunction
routes: Mapping[str, StakeholderRoute]
off_grid: Literal["pointwise", "reject"] = "pointwise"

Declared in the source’s Transition in Model(edges=...), beside the law and keyed by the gated target:

edges = {
    "couple": Transition(
        law={"couple": StochasticTransition(func=probability_one)},
        gates={"couple": Gate(predicate=above_wealth_floor, routes=...)},
    ),
}

The law supplies the probability of reaching the gated target, exactly as for any other destination; the gate decides, once there, whether a row stays or takes its route’s fallback. The two are distinct operations: the law selects whether this target edge is attempted at all, the predicate keeps that target or takes the route’s stakeholder-specific fallback.

The Transition’s destinations include the gate-open target and every route’s fallback regime. A law that names its targets derives them: each route fallback regime is reached wherever its gated target is. A gated source declares a Transition even where every age has a single outgoing edge, because the gate adds routing that the edge alone does not state.

The key is always the gate-open target — the regime a row enters when the predicate is true. An edge that may dissolve a couple is therefore keyed by the continuing collective regime, 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.

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

One Gate per target holds at every age the target is reached and in both phases. A gate is never wrapped in ByAge or Phased; only a route’s fallback may be Phased. With Transition(law=Phased(solve=..., simulate=...), gates=...) the law differs by phase and the gates do not, so perceived and realized transition probabilities can diverge without changing the edge. With Phased edges, a target reached in both phases carries the equal Gate in both or none. A gate on a target its Transition never reaches is refused.

Predicate operands

predicate is a Boolean predicate evaluated pointwise on the target regime’s grid, in the target fold’s context. It reads target states, params, period and age.

A gate routes on states, never on a value. A predicate argument named V_target, V_target_<s> or D_target raises GateReadsValueError, a RegimeInitializationError, when the Gate is constructed. A move decided by comparing values is declared where its comparison is made:

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⁡(predicate(x),  Vtargetroutes(x),  Vfallbacks(πs(x))),\bar W^s(x) = \operatorname{where}\big(\text{predicate}(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’s predicate reads no V_target, V_target_<s> or D_targetGate constructionGateReadsValueError
a gate’s predicate is a plain callable, not a StochasticTransitionmodel build, binding the edge law to its sourceRegimeInitializationError
routes covers the source’s stakeholder structuremodel build, binding the edge law to its sourceRegimeInitializationError
a gate sits on a target its Transition reaches; a target reached in both phases carries one equal Gate in both or nonemodel buildModelInitializationError
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 gated transitionmodel build, binding the edge law to its sourceNotImplementedError
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 predicate or projection argument names a node of the target’s own DAGmodel buildModelInitializationError
off_grid="reject" on a target carrying a continuous statemodel buildModelInitializationError
an ungated transition between regimes of different stakeholder structuremodel buildNotImplementedError
the predicate’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 four 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 transition law and its gates belongs to the edges, so its parameters sit at its declaration path under params["edges"][source][target], not under the source regime:

Template path below params["edges"][source][target]Callable
[arg]the law’s StochasticTransition cell for the target, gated or not
["predicate"][arg]the gate’s predicate
["routes"][route]["fallback"][state][arg]one route fallback projection
["routes"][route]["fallback"]["solve"][state][arg]the solve side of a Phased fallback
["routes"][route]["fallback"]["simulate"][state][arg]the simulate side of a Phased fallback

A fallback is keyed by its routes key and each projection by the state it projects. Only callables that read a free argument appear. A value may also be supplied once for every edge callable of the source, at params["edges"][source][arg], or at the model level; a value under the source regime never reaches a gate or projection. See Edge parameter paths.

For a source regime whose gate predicate takes wealth_floor, and a Phased fallback whose simulate side takes settlement_share:

template["edges"]["source"]["target"] == {
    "predicate": {"wealth_floor": "float"},
    "routes": {"f": {"fallback": {"simulate": {"x": {"settlement_share": "float"}}}}},
}

Solve and simulate

A solution carries no dissolution artifact. A household landing in a gated target cell where no action is feasible takes its route’s fallback when simulated; SimulationResult.dissolution_fallbacks records those rows when runtime checks are on. Pass the complete solution to simulate(solution=...), or omit it to let simulation solve first:

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

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

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 gated transition 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 source restriction raises at model build, when the edge law is bound to its source, since a Regime on its own declares no edge.

Finally, an ungated transition between regimes of different stakeholder structure stays rejected at model build. Mixed singleton/collective topologies go through a Gate, 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 the declarations in functions and constraints. A Gate lives in a Transition in Model(edges=...), so its derived fields belong to the law Model binds for the source, published as model.graph.laws[source]. The decomposed_* properties separately compute engine-facing views from the current raw declarations 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
Gatemodel.graph.laws[source].gated_edges[target]model.graph.laws[source].decomposed_transition[target]: the law’s cell for the target

The declaration objects themselves stay where the author wrote them, in functions, constraints and the Model(edges=...) 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 a Gate in Transition(gates=...).

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

See also