From 6f24e38d215be3c4de23582053e547a995fea464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Moran?= Date: Thu, 12 Feb 2026 17:22:50 +0100 Subject: [PATCH 1/2] Fix planned vs executed investment misalignment in TFP growth compute_tfp_growth() was using executed_productivity_investment (computed as total_capital_bought - replacement_cost) instead of planned_tfp_investment (the forward-looking decision from set_targets). These are fundamentally different calculations: - Planned: Based on available cash, hurdle rate, and budget constraints - Executed: Residual after depreciation is accounted for This caused TFP growth to be disconnected from firms' investment intentions: - Firms with high depreciation got less TFP growth than planned - Firms that over-bought capital got more TFP growth than planned (windfall) The fix changes compute_tfp_growth() to use this priority order: 1. planned_tfp_investment (TFP-specific portion of planned investment) 2. planned_productivity_investment (total planned, if TFP portion unavailable) 3. executed_productivity_investment (legacy fallback) 4. compute_productivity_investment() (final fallback for initial period) Fixes #59 --- macromodel/agents/firms/firms.py | 22 +++- .../func/test_productivity_growth.py | 124 ++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/macromodel/agents/firms/firms.py b/macromodel/agents/firms/firms.py index 475f36dd..415d3234 100644 --- a/macromodel/agents/firms/firms.py +++ b/macromodel/agents/firms/firms.py @@ -2125,18 +2125,30 @@ def compute_tfp_growth(self) -> np.ndarray: TFP growth based on: - Current TFP levels - Current production - - Executed productivity investment (if available, otherwise computed) + - Planned TFP investment (forward-looking, from set_targets) - Configuration parameters + Note: We use planned_tfp_investment (set in set_targets) rather than + executed_productivity_investment (computed from actual purchases minus + replacement). This ensures alignment between what firms intend to invest + and the resulting TFP growth. See bug #59. + Returns: np.ndarray: TFP growth rates for each firm """ - # Use executed productivity investment if available (from time series), - # otherwise fall back to computing it - if len(self.ts.executed_productivity_investment) > 0: + # Use planned TFP investment if available (from set_targets), + # falling back to total planned investment, then to executed + if len(self.ts.planned_tfp_investment) > 0: + # Preferred: use the TFP-specific portion of planned investment + productivity_investment = self.ts.current("planned_tfp_investment") + elif len(self.ts.planned_productivity_investment) > 0: + # Fallback: use total planned productivity investment + productivity_investment = self.ts.current("planned_productivity_investment") + elif len(self.ts.executed_productivity_investment) > 0: + # Legacy fallback: use executed investment productivity_investment = self.ts.current("executed_productivity_investment") else: - # Fallback for initial period or if execute_productivity_investment wasn't called + # Final fallback for initial period productivity_investment = self.compute_productivity_investment() # Get configuration parameters, using defaults if not specified diff --git a/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py b/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py index 325a9f1f..4c93c9d9 100644 --- a/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py +++ b/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py @@ -253,6 +253,130 @@ def test_no_sector_ids_fallback(self): assert np.allclose(sectoral_growth, simple_growth) +class TestPlannedVsExecutedInvestment: + """Test that TFP growth uses planned investment, not executed (bug #59). + + The issue is that planned_productivity_investment (forward-looking, from set_targets) + and executed_productivity_investment (capital_bought - replacement) are fundamentally + different calculations. TFP growth should be based on planned investment to align + with economic logic that firms invest intentionally. + """ + + def test_planned_vs_executed_produce_different_growth(self): + """Demonstrate that using planned vs executed investment produces different TFP growth. + + This test shows WHY the bug matters - if we use the wrong investment value, + we get the wrong TFP growth. + """ + growth_func = SimpleTFPGrowth(investment_effectiveness=0.1) + n_firms = 3 + + # Scenario: firms with same planned investment but different executed + # Firm A: high depreciation needs → executed < planned + # Firm B: normal case → executed = planned + # Firm C: over-bought capital → executed > planned + planned_investment = np.array([100_000.0, 100_000.0, 50_000.0]) + executed_investment = np.array([30_000.0, 100_000.0, 120_000.0]) # Very different! + + production = np.full(n_firms, 1_000_000.0) + current_tfp = np.ones(n_firms) + + tfp_growth_planned = growth_func.compute_tfp_growth( + current_tfp=current_tfp, + production=production, + productivity_investment=planned_investment, + base_growth_rate=0.0, + investment_elasticity=0.3, + ) + + tfp_growth_executed = growth_func.compute_tfp_growth( + current_tfp=current_tfp, + production=production, + productivity_investment=executed_investment, + base_growth_rate=0.0, + investment_elasticity=0.3, + ) + + # They should be different (this is why the bug matters) + assert not np.allclose(tfp_growth_planned, tfp_growth_executed), ( + "Planned and executed should produce different growth rates" + ) + + # Firm A: executed < planned → executed gives LESS growth + assert tfp_growth_executed[0] < tfp_growth_planned[0] + + # Firm C: executed > planned → executed gives MORE growth (windfall!) + assert tfp_growth_executed[2] > tfp_growth_planned[2] + + def test_investment_source_priority(self): + """Test that the correct priority order is used for investment source selection. + + Priority should be: + 1. planned_tfp_investment (TFP-specific portion) + 2. planned_productivity_investment (total planned) + 3. executed_productivity_investment (legacy fallback) + 4. compute_productivity_investment() (final fallback) + + This tests the logic that should be in compute_tfp_growth() in firms.py. + """ + # Simulate time series with all three values available + planned_tfp = np.array([80_000.0]) + planned_total = np.array([100_000.0]) + executed = np.array([40_000.0]) + + # Simulate the priority logic from compute_tfp_growth() + # When all are available, planned_tfp should be selected + ts_planned_tfp = [planned_tfp] + ts_planned_total = [planned_total] + ts_executed = [executed] + + if len(ts_planned_tfp) > 0: + selected = ts_planned_tfp[-1] + source = "planned_tfp" + elif len(ts_planned_total) > 0: + selected = ts_planned_total[-1] + source = "planned_total" + elif len(ts_executed) > 0: + selected = ts_executed[-1] + source = "executed" + else: + selected = None + source = "computed" + + assert source == "planned_tfp" + assert np.array_equal(selected, planned_tfp) + + # When only planned_total and executed available + ts_planned_tfp = [] + if len(ts_planned_tfp) > 0: + selected = ts_planned_tfp[-1] + source = "planned_tfp" + elif len(ts_planned_total) > 0: + selected = ts_planned_total[-1] + source = "planned_total" + elif len(ts_executed) > 0: + selected = ts_executed[-1] + source = "executed" + + assert source == "planned_total" + assert np.array_equal(selected, planned_total) + + # When only executed available (legacy case) + ts_planned_total = [] + if len(ts_planned_tfp) > 0: + selected = ts_planned_tfp[-1] + source = "planned_tfp" + elif len(ts_planned_total) > 0: + selected = ts_planned_total[-1] + source = "planned_total" + elif len(ts_executed) > 0: + selected = ts_executed[-1] + source = "executed" + + assert source == "executed" + assert np.array_equal(selected, executed) + + class TestProductivityGrowthEdgeCases: """Test edge cases and error conditions.""" From be2553de61acd26baeca03d0b3a215f10f455f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Moran?= Date: Thu, 12 Feb 2026 17:25:40 +0100 Subject: [PATCH 2/2] Style --- .../test_agents/test_firms/func/test_productivity_growth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py b/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py index 4c93c9d9..2e5b1986 100644 --- a/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py +++ b/tests/test_macromodel/unit/test_agents/test_firms/func/test_productivity_growth.py @@ -298,9 +298,9 @@ def test_planned_vs_executed_produce_different_growth(self): ) # They should be different (this is why the bug matters) - assert not np.allclose(tfp_growth_planned, tfp_growth_executed), ( - "Planned and executed should produce different growth rates" - ) + assert not np.allclose( + tfp_growth_planned, tfp_growth_executed + ), "Planned and executed should produce different growth rates" # Firm A: executed < planned → executed gives LESS growth assert tfp_growth_executed[0] < tfp_growth_planned[0]