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.
| Declaration | Where it is declared | What it expresses |
|---|---|---|
CollectiveUtility | functions={"utility": ...} | the regime’s stakeholders and their flow utilities |
ParetoObjective | CollectiveUtility(objective=...) | how stakeholder action values are scalarized |
ValueDependentConstraint | constraints={"name": ...} | a feasibility predicate that may read values |
Gate | Transition(gates=...) in Model(edges=...), keyed by target | a transition into one target, gated on its state |
StakeholderRoute | Gate(routes=...) | where one source stakeholder goes on each branch |
ProjectedRegimeValue | a constraint’s references or a route’s fallback | another 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 = NoneDeclared 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:
a callable, used in both phases;
Phased(solve=..., simulate=...), for different solve and simulation bodies; orNone, which delegates that body to autility_<stakeholder>entry supplied on the regime or by a model-levelfunctionsdeclaration.
The household maximizes the objective over the feasible action product and reads off each stakeholder’s own action value at that common choice.
The following are rejected:
an empty
utilitiesmapping, when theCollectiveUtilityis constructed;a stakeholder whose
utility_<s>is also declared directly infunctionsas a different function, when theRegimeis constructed; anda delegated (
None) stakeholder for which noutility_<s>body arrives after model-level declarations are merged, when the model finalizes the regime.
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 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:
"pointwise"(the default) divides by the total at each cell, so the weights sum to one wherever the objective is evaluated and a state-dependent declaration keeps one scale across the grid;"none"uses the declared weights as they stand. The scalarization is then not on the stakeholders’ own scale, and comparing values across cells whose totals differ compares different objectives.
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:
Q_<s>for each stakeholder of the declaring regime — that stakeholder’s action value at the candidate;each key of this constraint’s own
references, bound to that reference’s interpolated value;ordinary states, actions, functions and parameters.
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 = NoneAnother 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- decision is checked against the values its members would have as singles in that same period .
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:
| Position | Projects from | May introduce free params | Owes one coordinate per |
|---|---|---|---|
ValueDependentConstraint.references | the DECLARING regime’s cell | no | state of the reference regime’s value function |
StakeholderRoute.fallback | the TARGET regime’s grid | yes, as edge params | state 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 = NoneWhere 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:
the solve leg prices the source’s decision;
the simulate leg supplies the regime, the role and the state coordinates a routed row actually lands on.
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:
| Property | Returns |
|---|---|
solve_fallback | the ProjectedRegimeValue the closed branch is priced at |
simulate_fallback | the ProjectedRegimeValue a routed row’s regime, role and states come from |
fallback_is_phased | whether 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:
in the current period, as an
Exitof the source regime inRegime(exits=...);on landing, as a
ParticipationCheckinTransition(participation=...), whosereadsname each value the predicate compares.
At the end of each period’s solve, the engine folds one gated continuation per declared edge and source stakeholder on the target regime’s grid,
and the source’s continuation reads in place of the raw target value.
off_grid¶
What the edge promises about a landing point between the target’s nodes.
"pointwise"(the default) applies the predicate at the landing point, in both phases, and interpolates each branch’s value there. That value carries the ordinary interpolation error of any continuation — but it is a value one branch really delivers, and the branch the solve priced is the branch simulation routes down."reject"demands that no such point exists: the model refuses to build unless the target regime’s grid is reached exactly, i.e. it carries no continuous state. Declare it where a straddled gate would be an economic error rather than an approximation.
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.
| Rule | Enforced at | Exception |
|---|---|---|
normalization is "pointwise" or "none" | ParetoObjective construction | ValueError |
| weight keys match the regime’s stakeholders | Regime construction | RegimeInitializationError |
| a constant weight is finite and non-negative; the constants leave a positive total | Regime construction | RegimeInitializationError |
a ValueDependentConstraint on a singleton regime | Regime construction | RegimeInitializationError |
| a regime-level reference projection introduces no free parameter | Regime construction | RegimeInitializationError |
a gate’s predicate reads no V_target, V_target_<s> or D_target | Gate construction | GateReadsValueError |
a gate’s predicate is a plain callable, not a StochasticTransition | model build, binding the edge law to its source | RegimeInitializationError |
routes covers the source’s stakeholder structure | model build, binding the edge law to its source | RegimeInitializationError |
a gate sits on a target its Transition reaches; a target reached in both phases carries one equal Gate in both or none | model build | ModelInitializationError |
taste shocks, a nonlinear certainty equivalent, or a non-GridSearch solver on a collective regime | Regime construction | NotImplementedError |
| the same three on the SOURCE regime of a gated transition | model build, binding the edge law to its source | NotImplementedError |
a reference or fallback regime exists, and stakeholder matches its structure | model build | ModelInitializationError |
| a projection covers exactly the states its position owes | model build | ModelInitializationError |
| the same-period reference graph is acyclic | model build | ModelInitializationError |
| a predicate or projection argument names a node of the target’s own DAG | model build | ModelInitializationError |
off_grid="reject" on a target carrying a continuous state | model build | ModelInitializationError |
| an ungated transition between regimes of different stakeholder structure | model build | NotImplementedError |
| the predicate’s realized return dtype is Boolean | evaluation — every solve(), and again in the simulate phase | RegimeInitializationError |
| a callable weight is finite, non-negative and positively totalled on the grid | evaluation — 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:
value_<stakeholder>, one column per entry of the collective regime’s stakeholders, in declaration order;own_stakeholder, labelled frommodel.stakeholder_names_to_ids. A model with any collective regime publishes it for every regime, since a row that leaves a household still has to say it now occupies no role; a row in a singleton regime carries no value there. A model with no collective regime publishes no such column.
Capabilities a collective regime does not have¶
Each of these raises at Regime construction, naming the regime slot to change:
EV1 taste shocks. The collective argmax is the hard household maximum of the Pareto-weighted objective, not a smoothed one.
A nonlinear certainty equivalent. The per-stakeholder continuation is the linear expectation , so
certainty_equivalent=LinearExpectation()is the only admissible declaration.Any solver other than
GridSearch. The household argmax and per-stakeholder value readout run over the full action product.
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.
| Declaration | Construction-derived fields | On-access engine view |
|---|---|---|
CollectiveUtility | stakeholders, pareto_objective | decomposed_functions: nondelegated and already-supplied bodies; complete after successful model finalization |
ValueDependentConstraint | value_constraints[name], same_period_refs[reference] | decomposed_constraints: ordinary constraints only |
Gate | model.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¶
Households and value-dependent choice — the guide, with a worked marriage market.
Model and Regime — the slots these declarations sit in.
Transitions and phase specialization — what a
Transitionlaw accepts, and whatPhasedmeans elsewhere.Runtime, results, and persistence —
solveandsimulatearguments in general.