Source code for bamengine.events.labor_market

"""
Labor market events for wage setting, applications, and hiring.

This module defines the labor market phase events that execute after planning.
Firms post wage offers, unemployed workers apply to firms, and firms hire
workers through a batch matching process.

Event Sequence
--------------
The labor market events execute in this order:

1. CalcInflationRate - Calculate inflation rate (configurable method)
2. AdjustMinimumWage - Update minimum wage based on inflation (periodic)
3. FirmsDecideWageOffer - Firms post wage offers with random markup
4. WorkersDecideFirmsToApply - Unemployed workers select firms to apply to
5. LaborMarketRound - Batch labor market matching (max_M times)
6. FirmsCalcWageBill - Calculate total wage bill from employed workers

Design Notes
------------
- Events operate on employer and worker roles (Employer, Worker)
- Economy-level state (min_wage, inflation) updated by CalcInflationRate/AdjustMinimumWage
- Loyalty rule: workers whose contracts expired (not fired) apply to previous employer first
- Wage offers constrained by minimum wage floor
- Contract duration: θ + Poisson(λ=10) periods
- Batch matching: all unemployed applicants simultaneously send their next application,
  conflicts are resolved randomly (up to n_vacancies per firm), and accepted workers
  are batch-hired

Examples
--------
Execute labor market events:

>>> import bamengine as be
>>> sim = be.Simulation.init(n_firms=100, n_households=500, seed=42)
>>> # Labor market events run as part of default pipeline
>>> sim.step()

Execute individual labor market event:

>>> event = sim.get_event("firms_decide_wage_offer")
>>> event.execute(sim)
>>> sim.emp.wage_offer.mean()  # doctest: +SKIP
1.15

Check unemployment after hiring:

>>> employed_count = sim.wrk.employed.sum()
>>> unemployment_rate = 1.0 - (employed_count / 500)
>>> unemployment_rate  # doctest: +SKIP
0.04

See Also
--------
bamengine.events._internal.labor_market : System function implementations
Employer : Labor hiring state
Worker : Employment state
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from bamengine.core.decorators import event

if TYPE_CHECKING:  # pragma: no cover
    from bamengine.simulation import Simulation


[docs] @event class CalcInflationRate: """ Calculate and store the inflation rate for the current period. The inflation rate measures the change in the average market price level. This is used by AdjustMinimumWage to index the minimum wage to inflation. Uses year-over-year comparison (4-period lookback). Algorithm --------- 1. Check if price history has at least 5 periods (:math:`t \\geq 4`) 2. If insufficient history, set :math:`\\pi_t = 0` and skip 3. Otherwise, calculate: :math:`\\pi_t = (\\bar{P}_t - \\bar{P}_{t-4}) / \\bar{P}_{t-4}` 4. Append :math:`\\pi_t` to inflation history Mathematical Notation --------------------- .. math:: \\pi_t = \\frac{\\bar{P}_t - \\bar{P}_{t-4}}{\\bar{P}_{t-4}} where: - :math:`\\pi_t`: inflation rate at period t - :math:`\\bar{P}_t`: average market price at period t Examples -------- Execute this event: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, seed=42) >>> event = sim.get_event("calc_inflation_rate") >>> event.execute(sim) Check inflation history: >>> # Need at least 5 periods for non-zero YoY inflation >>> for _ in range(5): ... sim.step() >>> sim.ec.inflation_history[-1] # doctest: +SKIP 0.023 Inflation requires 5 periods of history: >>> sim = be.Simulation.init(n_firms=10, seed=42) >>> event = sim.get_event("calc_inflation_rate") >>> event.execute(sim) >>> sim.ec.inflation_history[-1] 0.0 Notes ----- This event must execute before AdjustMinimumWage in each period. During the first 4 periods (t < 4), inflation is set to 0.0 since there is insufficient history. The inflation rate is stored in Economy.inflation_history for later use by minimum wage adjustment. See Also -------- AdjustMinimumWage : Uses inflation to update minimum wage Economy : Global economy state with price/inflation history bamengine.events._internal.labor_market.calc_inflation_rate : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import calc_inflation_rate calc_inflation_rate(sim.ec)
[docs] @event class AdjustMinimumWage: """ Periodically update the minimum wage based on realized inflation. The minimum wage is indexed to inflation to maintain real purchasing power. Updates occur every `min_wage_rev_period` periods (e.g., every 4 periods for annual revision). Only updates after sufficient price history exists. Algorithm --------- 1. Check if current period is a revision period: :math:`(t+1) \\mod \\text{min\\_wage\\_rev\\_period} = 0` 2. Check if sufficient price history exists: :math:`t > \\text{min\\_wage\\_rev\\_period}` 3. If both conditions met: - Retrieve most recent inflation rate: :math:`\\pi_t = \\text{inflation\\_history}[-1]` - Update: :math:`\\hat{w}_t = \\hat{w}_{t-1} \\times (1 + \\pi_t)` 4. Otherwise, skip revision (:math:`\\hat{w}_t = \\hat{w}_{t-1}`) Mathematical Notation --------------------- .. math:: \\hat{w}_t = \\hat{w}_{t-1} \\times (1 + \\pi_t) where: - :math:`\\hat{w}_t`: minimum wage at period t - :math:`\\pi_t`: annual inflation rate - Revision occurs only when: :math:`(t+1) \\mod M = 0`, where :math:`M` = min_wage_rev_period Examples -------- Execute this event: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, seed=42) >>> event = sim.get_event("adjust_minimum_wage") >>> event.execute(sim) Check minimum wage after revision period: >>> sim = be.Simulation.init(n_firms=10, seed=42, min_wage_rev_period=4) >>> initial_wage = sim.ec.min_wage >>> # Run 5 periods to trigger first revision >>> for _ in range(5): ... sim.step() >>> sim.ec.min_wage >= initial_wage # Should increase with positive inflation True Minimum wage unchanged before revision period: >>> sim = be.Simulation.init(n_firms=10, seed=42, min_wage_rev_period=4) >>> initial_wage = sim.ec.min_wage >>> sim.step() >>> sim.ec.min_wage == initial_wage True Notes ----- This event must execute after CalcInflationRate in each period. The revision only occurs on specific periods determined by min_wage_rev_period (e.g., if min_wage_rev_period=4, revisions occur at t=4, 8, 12, ...). The minimum wage is bidirectional: it can decrease during deflation. See Also -------- CalcInflationRate : Calculates inflation rate used for adjustment FirmsDecideWageOffer : Wage offers must satisfy minimum wage constraint Economy : Global economy state with min_wage field bamengine.events._internal.labor_market.adjust_minimum_wage : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import adjust_minimum_wage adjust_minimum_wage(sim.ec, sim.wrk)
[docs] @event class FirmsDecideWageOffer: """ Firms with vacancies post wage offers with random markup over previous offer. Firms that have open vacancies increase their wage offers to attract workers. The wage increase is a random shock, and the final offer must satisfy the minimum wage constraint. Firms without vacancies leave their wage unchanged. Algorithm --------- For each firm i: 1. Generate wage shock: :math:`\\varepsilon_i \\sim U(0, h_\\xi)` 2. If firm has vacancies (:math:`V_i > 0`): - Apply markup: :math:`w'_i = w_{i,t-1} \\times (1 + \\varepsilon_i)` - Enforce floor: :math:`w_i = \\max(w'_i, \\hat{w}_t)` 3. Otherwise: :math:`w_i = w_{i,t-1}` (unchanged) Mathematical Notation --------------------- .. math:: w_{i,t} = \\begin{cases} \\max(\\hat{w}_t, w_{i,t-1} \\times (1 + \\varepsilon_i)) & \\text{if } V_i > 0 \\\\ w_{i,t-1} & \\text{otherwise} \\end{cases} where: - :math:`w_i`: wage offer by firm i - :math:`\\hat{w}_t`: minimum wage at period t - :math:`\\varepsilon_i`: wage shock :math:`\\sim U(0, h_\\xi)` - :math:`h_\\xi`: maximum wage growth rate parameter (config) - :math:`V_i`: number of vacancies at firm i Examples -------- Execute this event: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, seed=42) >>> event = sim.get_event("firms_decide_wage_offer") >>> event.execute(sim) Check wage offers satisfy minimum wage: >>> (sim.emp.wage_offer >= sim.ec.min_wage).all() True Find firms with vacancies: >>> import numpy as np >>> has_vacancies = sim.emp.n_vacancies > 0 >>> has_vacancies.sum() # doctest: +SKIP 25 Average wage offer from hiring firms: >>> hiring_mask = sim.emp.n_vacancies > 0 >>> sim.emp.wage_offer[hiring_mask].mean() # doctest: +SKIP 1.15 Notes ----- This event must execute after FirmsDecideVacancies and AdjustMinimumWage. Only firms with vacancies adjust their wage offers. Firms without vacancies retain their previous wage offer (though it may not be used until they post vacancies again). The wage shock prevents firms from settling into static wage levels and introduces competition for workers. See Also -------- AdjustMinimumWage : Updates minimum wage floor FirmsDecideVacancies : Determines which firms have open positions WorkersDecideFirmsToApply : Workers select firms based on wage offers bamengine.events._internal.labor_market.firms_decide_wage_offer : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import firms_decide_wage_offer firms_decide_wage_offer( sim.emp, w_min=sim.ec.min_wage, h_xi=sim.config.h_xi, rng=sim.rng, )
[docs] @event class WorkersDecideFirmsToApply: """ Unemployed workers choose up to max_M firms to apply to, sorted by wage. Unemployed workers build an application queue by sampling firms and sorting them by wage offer (descending). Workers apply the loyalty rule: if their contract expired (not fired), they prioritize their previous employer. The ``job_search_method`` config parameter controls which firms are sampled: - ``"vacancies_only"``: Sample only from firms with open vacancies. - ``"all_firms"`` (default): Sample from ALL firms. Applications to firms without vacancies are "wasted" (the firm simply doesn't hire). Algorithm --------- For each unemployed worker j: 1. Sample min(max_M, n_firms) firms randomly from eligible pool 2. Sort sampled firms by wage offer (descending) 3. Apply loyalty rule — if worker's contract expired (not fired) AND previous employer is hiring, move previous employer to position 0 (top priority) 4. Store sorted application queue in worker's buffer 5. Reset contract_expired and fired flags Mathematical Notation --------------------- Let :math:`H` be the set of eligible firms: - If ``job_search_method="vacancies_only"``: :math:`H = \\{i : V_i > 0\\}` (firms with vacancies) - If ``job_search_method="all_firms"``: :math:`H = \\{1, ..., N\\}` (all firms) For unemployed worker j: .. math:: \\text{Sample}_j \\sim \\text{Random}(H, k=\\min(M, |H|), \\text{replace}=False) Then sort by wage: .. math:: \\text{Queue}_j = \\text{argsort}_{\\text{desc}}(w_i \\text{ for } i \\in \\text{Sample}_j) If loyalty applies (contract expired, not fired, previous employer hiring): .. math:: \\text{Queue}_j[0] = \\text{employer\\_prev}_j Examples -------- Execute this event: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, n_households=500, seed=42) >>> event = sim.get_event("workers_decide_firms_to_apply") >>> event.execute(sim) Check unemployed workers prepared applications: >>> import numpy as np >>> unemployed_mask = ~sim.wrk.employed >>> unemployed_mask.sum() # doctest: +SKIP 20 Inspect application queue for unemployed worker: >>> unemployed_ids = np.where(~sim.wrk.employed)[0] >>> if len(unemployed_ids) > 0: ... worker_id = unemployed_ids[0] ... targets = sim.wrk.job_apps_targets[worker_id] ... # First 3 targets (may include -1 if fewer than max_M firms hiring) ... targets[:3] # doctest: +SKIP array([45, 23, 67]) Check loyalty rule application: >>> # Workers with expired contracts should have previous employer at position 0 >>> # (if previous employer is hiring) >>> expired_mask = (sim.wrk.contract_expired == 1) & (~sim.wrk.employed) >>> expired_mask.sum() # doctest: +SKIP 5 Notes ----- This event must execute after FirmsDecideWageOffer (need wage offers for sorting). Only unemployed workers prepare applications. Employed workers are skipped. The loyalty rule implements realistic worker behavior: workers whose contracts expired naturally (not fired) prefer to stay with their previous employer if possible. Workers sample firms randomly then sort by wage. This means workers may miss the highest-wage firms if they are not in the random sample. The max_M parameter controls how many firms each worker can apply to. See Also -------- FirmsDecideWageOffer : Determines wage offers used for sorting LaborMarketRound : Processes applications from queue Worker : Employment state with application queue bamengine.events._internal.labor_market.workers_decide_firms_to_apply : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import ( workers_decide_firms_to_apply, ) workers_decide_firms_to_apply( wrk=sim.wrk, emp=sim.emp, max_M=sim.config.max_M, job_search_method=sim.config.job_search_method, rng=sim.rng, )
[docs] @event class FirmsCalcWageBill: """ Firms calculate total wage bill based on currently employed workers. The wage bill is the sum of wages for all workers employed by the firm. This is used by FirmsCalcBreakevenPrice to determine production costs. Algorithm --------- For each firm i: 1. Find all workers employed by firm i: :math:`E_i = \\{j : \\text{employer}_j = i\\}` 2. Sum wages: :math:`W_i = \\sum_{j \\in E_i} w_j` 3. Store in firm's wage_bill field Uses vectorized aggregation via np.bincount for efficiency. Mathematical Notation --------------------- .. math:: W_i = \\sum_{j \\in E_i} w_j where: - :math:`W_i`: total wage bill for firm i - :math:`E_i`: set of workers employed by firm i - :math:`w_j`: wage of worker j Examples -------- Execute this event: >>> import bamengine as be >>> sim = be.Simulation.init(n_firms=100, n_households=500, seed=42) >>> event = sim.get_event("firms_calc_wage_bill") >>> event.execute(sim) Check total wage bill: >>> sim.emp.wage_bill.sum() # doctest: +SKIP 552.5 Verify wage bill matches sum of worker wages: >>> import numpy as np >>> employed_mask = sim.wrk.employed >>> total_wages = sim.wrk.wage[employed_mask].sum() >>> total_wage_bill = sim.emp.wage_bill.sum() >>> abs(total_wages - total_wage_bill) < 1e-10 # Allow floating point tolerance True Check firms with no employees have zero wage bill: >>> no_labor_mask = sim.emp.current_labor == 0 >>> (sim.emp.wage_bill[no_labor_mask] == 0).all() True Notes ----- This event must execute after all LaborMarketRound rounds complete. The wage bill represents the total labor cost that will be paid in the production phase (FirmsPayWages event). The wage bill is shared with Borrower role (same underlying array) for memory efficiency and consistency with credit demand calculations. See Also -------- LaborMarketRound : Batch labor market matching that hires workers FirmsCalcBreakevenPrice : Uses wage bill to calculate production costs FirmsPayWages : Pays wages based on wage_bill Employer : Labor hiring state with wage_bill field bamengine.events._internal.labor_market.firms_calc_wage_bill : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import firms_calc_wage_bill firms_calc_wage_bill(sim.emp, sim.wrk)
[docs] @event class LaborMarketRound: """One round of batch labor market matching. All unemployed applicants simultaneously send their next application, conflicts are resolved randomly (up to ``n_vacancies`` per firm), and accepted workers are batch-hired. This event is called ``max_M`` times in the pipeline. See Also -------- bamengine.events._internal.labor_market.labor_market_round : Implementation """
[docs] def execute(self, sim: Simulation) -> None: from bamengine.events._internal.labor_market import labor_market_round labor_market_round(sim.emp, sim.wrk, theta=sim.config.theta, rng=sim.rng)