diff --git a/.gitignore b/.gitignore index bcd848b..63a2f28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.claude/ .uv-cache/ .matplotlib-cache/ __pycache__/ @@ -17,5 +18,4 @@ results/ *.db archive/ AGENTS.md - - +.vscode/ diff --git a/README.md b/README.md index 0ac25f5..38e7714 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,112 @@ # Spatial Market Lock-In ABM -A Sugarscape-style agent-based model of a spatial market. Mobile **buyers** traverse a 2-D lattice harvesting **money** from the environment and spending it on **oil** (their fuel) bought from fixed **sellers**. Research question: *under what conditions do buyers become locked in to a single seller, and how does that show up in prices?* +A Sugarscape-style agent-based model of a spatial market. Mobile **buyers** traverse a 2-D lattice harvesting **money** from the environment and spending it on **oil** (their fuel) bought from fixed **sellers**. Research question: *under what conditions do buyers become locked in to a single seller, how does lock-in show up in prices, and can sellers detect and exploit it?* -> **Reading conventions.** `[HEURISTIC]` = a value or form chosen for tractability, not derived from theory. `[OPEN]` = a genuine design choice not yet settled; a default is given so the model runs, but these are the first things to revisit. Nothing here adds a behavioural channel beyond what the spec calls for. +> **Reading conventions.** `[HEURISTIC]` = a value or form chosen for tractability, not derived from theory. Nothing here adds a behavioural channel beyond what the spec calls for. --- -## Running v1 +## Running the model The implementation uses `uv`, Mesa 3, and SolaraViz. ```bash uv sync --extra dev -uv run python scripts/run_model.py --seed 42 --steps 500 +uv run python scripts/run_model.py --seed 42 --steps 500 --scenario moderate_lockin uv run solara run src/spatial_market_lockin/viz.py uv run pytest ``` -Single-run scripts write CSV/JSON outputs to `outputs/run_seed__steps_` unless `--output-dir` is supplied. +**Named scenarios** (`--scenario` flag): + +| Scenario | Description | Typical switch rate | +|---|---|---| +| `degenerate` | Config defaults, near-degenerate | ~1% | +| `low_lockin` | a2=0, buyers survive, dispersed prices | ~30% | +| `moderate_lockin` | a2=0.5, prices converge, moderate lock-in | ~10% | + +**Single-run output** is written to `outputs/run_seed__steps_` unless `--output-dir` is supplied. Each run produces: + +- `model_timeseries.csv` — per-tick model metrics (prices, counts, lock-in rate, transactions). +- `transactions.csv` — the full transaction ledger (one row per oil purchase). +- `final_buyers.csv` / `final_sellers.csv` — agent state at the final tick. +- `run_metadata.json` — the run config plus run-summary diagnostics. + +**Comparison script:** + +```bash +uv run python scripts/visualize_metrics.py +``` + +Runs baseline vs lock-in premium side-by-side and saves `scripts/metrics_output.png`. + +--- + +## What is implemented + +- Mesa 3 `OrthogonalMooreGrid` with configurable toroidal boundary. +- Sparse environmental money field with stochastic regrowth. +- Fixed sellers with oil inventory, money, posted price, money metabolism, death at `money=0`, and optional respawn at the same cell (`respawn_sellers` toggle). +- Mobile buyers with money, oil, Cobb-Douglas welfare, visual range, projected-welfare movement, EMA price beliefs, loss aversion, death at `oil=0`, and instant respawn with generation tracking. +- Dynamic seller pricing game with four components: captive demand, competitor anchoring, liquidity pressure, lock-in premium — see §5.1. +- Seller loyalty tracking: each seller records consecutive purchase streaks per buyer; the lock-in premium term exploits this. +- Five lock-in measures: instantaneous reachability, lifetime distinct-seller count, suboptimal lock-in (physical trap), revealed-suboptimal lock-in (never-switcher behavioural trap), and streak-suboptimal lock-in (explored-then-trapped behavioural trap) — see §6. +- Per-tick DataCollector time series and run-level summary diagnostics — see §7. +- Full transaction ledger (every oil purchase recorded). +- CSV / JSON single-run exports. +- 141 focused tests covering config, movement, pricing, beliefs, lock-in, ledger, metrics, and exports. --- -## Current state and roadmap +## How one tick runs + +Each tick executes six phases in fixed order. Nothing runs in parallel within a tick; each phase completes before the next begins. + +**1 — Seller pricing.** Every living seller independently computes a new posted price from last tick's state. All sellers compute simultaneously — no seller sees a mid-tick rival change. The formula is `p_floor × (1 + a1·D_s + a2·C_s + a3·L_s + a4·Y_s)`; the four terms are described in §5.1. -The current rebuild is intentionally simple and should be treated as the working -debug layer, not the completed v1 model. +**2 — Buyer turns.** Each alive buyer acts in sequence: -**Implemented now:** +- **Scan.** Evaluate every cell within Chebyshev radius `v`. For each candidate project: money after harvesting it + oil after paying the movement cost + (if a seller is there) an oil top-up at the buyer's *believed* price for that seller. Pick the cell with the highest projected Cobb-Douglas welfare; ties broken by shorter distance, then lower coordinate. +- **Move and harvest.** Move to the chosen cell. Collect all money on it (cell zeroed). Burn oil: `psi_tick + distance × psi_move`. +- **Trade.** If a seller occupies the destination: observe its true posted price (update EMA belief per §5.3). Compute loss-aversion-adjusted decision price (§4.2) and buy the welfare-maximising quantity. Pay money, receive oil. Log the transaction and update loyalty streaks. +- **Death check.** If oil ≤ 0 after movement: record this buyer's lifetime seller count and revealed-suboptimal flag, remove the buyer, spawn a fresh replacement at a random cell (incremented generation, zero memory of past). -- Mesa 3 `OrthogonalMooreGrid` with configurable torus boundary. -- Sparse environmental money field with stochastic grow-back. -- Fixed sellers with oil inventory, posted `p_floor`, and oil regrowth. -- Mobile buyers with money, oil, Cobb-Douglas welfare, visual range, and jump movement. -- Buyer movement evaluates visible cells by projected welfare after harvest, movement oil cost, and any known-price oil purchase on seller cells. -- Take-all money harvest and mechanical oil purchase at fixed seller price. -- Buyer oil burn, death at `oil <= 0`, and instant respawn with generation tracking. -- Minimal Solara/Altair visualization for the grid, agents, diagnostics, and total money. -- Focused tests for config, grid, movement, purchasing, regrowth, respawn, and visualization data. +**3 — Seller metabolism.** Every living seller loses `seller_money_metabolism` money. A seller that reaches zero is removed. If `respawn_sellers = True`, a fresh replacement immediately appears at a random cell (new identity, full endowments, empty loyalty counts). -**Roadmap:** +**4 — Seller oil regrowth.** Each living seller's oil stock grows by `r_o`, capped at `O_max`. -1. Add a transaction ledger for every oil purchase: buyer, seller, generation, price, quantity, cost, and coordinates. -2. Track buyer seller-history for lifetime distinct-seller counts. -3. Add seller money metabolism, seller death, and optional seller respawn. (NOT SURE ABOUT THIS ONE) -4. Add core time-series metrics: average price, active counts, buyer death/respawn counts, seller counts, and seller oil/money summaries. -5. Add CSV/JSON exports and restore the CLI around the rebuilt model. -6. Add dynamic seller pricing from captive demand, competitor anchoring, and liquidity pressure. -7. Add price beliefs, uncertainty, risk/death aversion, and loss-aversion purchase gates. -8. Add lock-in and suboptimal-lock-in metrics once transactions and reachability are stable. +**5 — Money regrowth.** Each grid cell is independently selected with probability `money_regrowth_probability` and gains `money_regrowth_rate` money, capped at `money_max`. + +**6 — Snapshot.** DataCollector records all per-tick metrics (§7). --- ## 1. Framework and space -- **Engine:** Mesa 3 (Python), `OrthogonalMooreGrid` over a `G x G` lattice. -- **Visualization:** Mesa 3 SolaraViz for local interactive inspection of the grid, agent states, and live metrics. Static exported outputs can later be published with GitHub Pages. -- **Distance / movement:** Chebyshev (Moore); buyers may jump to any cell within visual range. `[HEURISTIC]` -- **Boundary:** toroidal by default, configurable via `torus`. `[HEURISTIC]` -- **Scheduler:** sellers post prices, buyers calculate movement intents from a pre-action snapshot, buyer intents resolve in seeded shuffled order, agents metabolize/die/respawn, then resources regrow. No privileged buyer ordering is fixed across runs; keeps the model controller-free (Req #9). +- **Engine:** Mesa 3 (Python), `OrthogonalMooreGrid` over a `G × G` lattice. +- **Visualization:** SolaraViz for local interactive inspection. +- **Distance / movement:** Chebyshev (Moore); buyers may jump to any cell within visual range `v`. `[HEURISTIC]` +- **Boundary:** toroidal by default (`torus=True`). `[HEURISTIC]` +- **Scheduler:** each tick runs in strict order — seller pricing → buyer movement loop (snapshot order) → seller metabolism / death / respawn → seller oil regrowth → money regrowth → DataCollector snapshot. No privileged buyer ordering is fixed across runs. --- ## 2. Environment and resources **Money** is an environmental field (the "sugar" analogue). -- Cell `c` holds `g_c >= 0` money, capacity `money_max`. -- Initial money is sparse: each cell starts with money only with probability `initial_money_probability`. -- Each tick, only a random subset of cells regrows: selected cells gain `money_regrowth_rate`, capped by `money_max`. Selection probability is `money_regrowth_probability`. `[HEURISTIC - sparse stochastic grow-back]` -- A buyer on `c` harvests all of `g_c` (take-all). `[HEURISTIC]` + +- Cell `c` holds `g_c >= 0` money, capped at `money_max`. +- Initial money is sparse: each cell starts with money only with probability `initial_money_probability`, drawn uniform on `[0, money_max]`. +- Each tick, a random subset of cells regrows: each cell is selected with probability `money_regrowth_probability` and gains `money_regrowth_rate`, capped by `money_max`. `[HEURISTIC]` +- A buyer landing on `c` harvests all of `g_c` immediately (take-all, cell zeroed). `[HEURISTIC]` **Oil** is seller inventory. -- Seller `s` holds `O_s <= O_max`, regrowing at `r_o` each tick. -- Sellers die when money reaches zero. By default `respawn_sellers=True`, replacing a dead seller at the same cell with fresh random endowments; disable it to study seller exit. -- Buyers get oil only by purchasing. Oil is the buyer's fuel: burned every tick and by movement distance; `f = 0` means death followed by immediate buyer respawn. -Loop: buyers harvest money -> buy oil -> burn oil to keep moving/harvesting; sellers take in money from sales -> burn money on metabolism. Both sides can die of exhaustion. +- Seller `s` holds `O_s <= O_max`, replenished by `r_o` per tick. +- Sellers earn money from oil sales and burn `seller_money_metabolism` per tick. A seller whose money reaches zero dies; if `respawn_sellers=True` a fresh seller is immediately placed at a random cell with a new identity (new `unique_id`, empty loyalty counts, full endowments). Buyers approach the replacement from the price prior — there is no inherited reputation. +- Buyers get oil only by purchasing from sellers. Oil is the buyer's fuel: burned every tick (`psi_tick`) and by movement distance (`psi_move × Chebyshev distance`). Oil reaching zero means death and instant respawn. + +**The resource loop:** buyers harvest money → buy oil → burn oil to keep moving and harvesting; sellers receive money from sales → burn money on metabolism. Both sides can die of resource exhaustion. --- @@ -84,200 +116,272 @@ Loop: buyers harvest money -> buy oil -> burn oil to keep moving/harvesting; sel | State | Symbol | Notes | |---|---|---| -| Position | `x_i` | jump within visual range `[HEURISTIC]` | -| Money held | `w_m` | spent on oil | -| Oil held (fuel) | `w_o` | death at `w_o = 0` | -| Oil metabolism | `m_oil` | per-tick burn `psi_tick` + movement burn `psi_move` | -| Money weight | `buyer_money_weight` | Cobb-Douglas preference weight - see §4 | -| Visual range | `v` | Chebyshev radius | -| Price beliefs | `(mu_log_s, sigma2_log_s)` | one log-price posterior per seller (§5.3) | -| Lifetime seller set | `Sellers_i` | distinct sellers ever transacted with (§6) | - -- **Perception (Req #4):** buyers see only within `v`; a seller's price is unknown until first visited (before that, the prior in §5.3 is used). -- **Bounded rationality (Req #5):** buyers jump to the visible cell with the most money; they do not solve a dynamic program or anticipate other buyers. +| Position | `x_i` | jump within visual range `v` | +| Money | `w_m` | harvested from cells, spent on oil | +| Oil (fuel) | `w_o` | death at `w_o = 0` | +| Beliefs | `{seller_id: float}` | EMA believed price per seller; unvisited = `prior_price_mean` (§5.3) | +| Last seller | `last_seller` | pointer to most recent seller transacted with | +| Lifetime seller set | `lifetime_sellers` | distinct sellers ever bought from (§6) | +| Generation | `generation` | increments each time this buyer slot respawns | + +- **Perception:** buyers see only within Chebyshev radius `v`; a seller's price is unknown until first visited (`prior_price_mean` used before that). +- **Bounded rationality:** buyers jump to the visible cell with the highest projected Cobb-Douglas welfare. They do not solve a dynamic program, do not anticipate other buyers, and do not strategically time their visits. ### 3.2 Sellers (M) | State | Symbol | Notes | |---|---|---| -| Position | `y_s` | fixed | -| Money | `M_s` | death at `M_s = 0`; respawn toggle defaults on | -| Oil stock | `O_s <= O_max` | regrows at `r_o` | -| Money metabolism | `mu_s` | burned per tick | -| Posted price | `p_s(t)` | set by §5.1 | +| Position | `y_s` | fixed for the seller's lifetime | +| Money | `M_s` | death at `M_s = 0` | +| Oil stock | `O_s` | replenished `r_o` per tick, capped at `O_max` | +| Posted price | `p_s(t)` | updated each tick by §5.1 | +| Loyalty counts | `{buyer_id: int}` | consecutive purchase streak per buyer | +| Generation | `generation` | increments on respawn at same cell | -- Sellers see the whole grid (needed for the captive-demand term). This is not a controller - each seller acts only on its own price. +- Sellers see the whole grid (needed for the captive-demand and competitor-anchoring terms). Each seller acts only on its own price — there is no coordination or communication between sellers. --- -## 4. Buyer welfare: Sugarscape sugar-spice (Cobb-Douglas) +## 4. Buyer welfare: Cobb-Douglas -Identical in form to the two-commodity Sugarscape (Epstein & Axtell, *Growing Artificial Societies*). The buyer holds two goods - money `w_m` and oil `w_o` - valued by a Cobb-Douglas welfare function whose exponents are metabolic shares: +The buyer holds two goods — money `w_m` and oil `w_o` — valued by a Cobb-Douglas welfare function: ``` -W(w_m, w_o) = w_m^alpha * w_o^(1-alpha) -alpha = buyer_money_weight / (buyer_money_weight + psi_tick) +W(w_m, w_o) = w_m^α × w_o^(1−α) +α = buyer_money_weight / (buyer_money_weight + psi_tick) ``` -- `psi_tick` = fixed oil metabolism; higher `psi_tick` lowers `alpha` and therefore prioritizes oil. -- `buyer_money_weight` = buyer taste-for-money / greed preference weight. Money is not burned by buyers in v1. +`psi_tick` sets oil's implicit weight: a high oil burn rate makes oil precious (low α), making buyers eager to purchase and reluctant to stray far from sellers. `buyer_money_weight` raises α, making buyers accumulate money and explore further for it. + +**Movement = local projected-welfare search.** Each tick the buyer evaluates all cells within visual range. For each candidate cell `c` it projects: +- money after harvesting: `w_m + g_c` +- oil after paying movement cost: `w_o − (psi_tick + distance × psi_move)` +- if `c` contains a seller: an additional oil purchase at the buyer's *believed* price for that seller, at the quantity that maximises projected welfare -**Movement = local projected-welfare search.** Each tick the buyer evaluates all cells within visual range. For each candidate cell `c`, it projects money after harvesting `g_c` and oil after paying `psi_tick + d(x_i,c) * psi_move`. If `c` contains a seller, the buyer also projects a known-price oil purchase that maximizes its Cobb-Douglas welfare. It then moves to the cell with highest projected welfare. Ties break by shorter distance. +The buyer moves to the highest-scoring cell. Ties broken by shorter distance, then lower coordinate. -### 4.1 Reservation price and trade (MRS) +### 4.1 Optimal purchase quantity -The welfare function's marginal rate of substitution is the buyer's **reservation price for oil** (money it will pay per unit oil): +At a seller with believed price `p`, the Cobb-Douglas unconstrained optimum is: ``` -RP_i = (dW/dw_o)/(dW/dw_m) = ((1-alpha) * w_m)/(alpha * w_o) +q* = ((1 − α) × w_m / p) − (α × w_o) ``` -Starving (low `w_o`) -> `RP` high; cash-rich (high `w_m`) -> `RP` high. Exactly the Sugarscape intuition. With known fixed price in the current layer, the buyer chooses the oil quantity that maximizes post-trade welfare, capped by money `w_m/p_s` and inventory `O_s`. Every completed trade records `p_s` -> feeds the average-price measurement (§7). - -### 4.2 Required biases (Req #6) - now decoupled from welfare +Capped by `min(q*, w_m/p, O_s)` (budget, inventory). If `q* ≤ 0` the buyer is already oil-rich relative to money and buys nothing. -The Sugarscape welfare function contains no risk or loss aversion, so the mandated biases move to the **purchase-under-uncertainty** decision: +### 4.2 Loss aversion -- **Risk / death aversion** `rho`: the buyer keeps an oil safety buffer scaled by `rho`, and inflates the believed price of not-yet-visited sellers by a premium proportional to `rho * sigma_s` (posterior price std) - caution toward unknown sellers. `[HEURISTIC]` -- **Loss aversion** `lambda > 1`: paying above the buyer's reference (believed) price is weighted by `lambda`, shading `RP` down against pricier-than-expected sellers - reinforcing attachment to the known seller. `[HEURISTIC]` +Loss aversion gates the *purchase decision*, not the welfare function itself. -These add nothing to the Cobb-Douglas welfare itself; they gate the buy/switch decision. +**Loss aversion** `λ ≥ 1`: if the actual posted price exceeds the buyer's believed price (the pre-visit EMA belief), the overage is weighted by `λ` in the purchase decision: `decision_price = believed + λ × (posted − believed)`. The buyer pays the actual posted price if it buys, but the inflated decision price shrinks the quantity it decides to buy. Makes unexpectedly expensive sellers psychologically costlier than arithmetically expensive ones. --- -## 5. Game-theoretic structure (Req #8) +## 5. Game-theoretic structure -### 5.1 Seller pricing game (spatial oligopoly) +### 5.1 Seller pricing game -Players: the `M` sellers; each `s` posts `p_s(t)` and its money inflow depends on rivals' prices through buyer reallocation. Three components, exactly as specified: +Each tick every seller independently sets its posted price using last tick's state (simultaneous update — no seller sees a rival's mid-tick change): ``` -p_s(t) = p_floor * ( 1 + a1*D_s(t) + a2*C_s(t) + a3*L_s(t) ) +p_s(t) = p_floor × (1 + a1×D_s + a2×C_s + a3×L_s + a4×Y_s) ``` -1. **Captive demand** `D_s = n_s/n_bar`: `n_s` = buyers strictly closer to `s` than to any other seller (strict Voronoi count); `n_bar = N/M`. Market power. -2. **Competitor anchoring** `C_s = (p_tilde_-s(t-1) - p_floor)/p_floor`: distance-weighted average of *other* sellers' previous-tick prices, kernel `w_ss' = exp(-d(y_s,y_s')/ell)`. The strategic coupling between sellers. -3. **Liquidity pressure** `L_s = clip((mu_s*T_h - M_s)/(mu_s*T_h), 0, 1)`: distress markup when money won't cover metabolism over horizon `T_h`. +**D_s — captive demand share.** +`D_s = n_s / n_bar` where `n_s` is the count of buyers strictly closer to seller `s` than to any other seller (strict Voronoi — ties go to nobody), and `n_bar = N/M`. Represents geographic market power. A seller surrounded by many nearby buyers charges more. + +**C_s — competitor anchoring.** +Distance-weighted average of rivals' previous-tick prices, expressed as fractional deviation from `p_floor`. Kernel weight `exp(−distance/ell)` — nearer rivals anchor more strongly. When `a2 > 0`, prices across the market converge over time. `a2 < 1.0` is enforced as a stability constraint: at `a2 ≥ 1` competitor anchoring amplifies instead of damping price deviations and prices diverge exponentially. + +**L_s — liquidity pressure.** +`clip((μ_s × T_h − M_s) / (μ_s × T_h), 0, 1)` — how close the seller is to bankruptcy relative to a solvency horizon of `T_h` ticks. Zero when the seller has more than enough money; one when broke. A distressed seller pushes price up to earn more. + +**Y_s — lock-in premium.** +`Y_s = captive_buyers_of_s / n_bar` where a buyer is counted as captive if its consecutive purchase streak from this seller is ≥ `loyalty_threshold_k`. When `a4 > 0`, sellers that have accumulated captive buyers charge a premium over the baseline formula — they are detecting and exploiting behavioural lock-in. The streak resets to zero the moment a buyer purchases from a different seller, and is deleted when a buyer dies. -`a1,a2,a3 >= 0`, `ell`, `T_h`, `p_floor` all `[HEURISTIC]`. Uses last-tick rival prices and never solves for equilibrium -> bounded-rational best response (Req #5). +**Key interactions:** +- `a2` governs price convergence — high `a2` makes all sellers charge similar prices, removing the economic incentive to switch. +- `a4` + `loyalty_threshold_k` govern exploitation — sellers need `k` consecutive purchases before the premium appears; once it does it grows as more buyers accumulate streaks. +- `a4` and `a2` interact: if `a2` is high and prices converge, the lock-in premium creates the only remaining price dispersion. ### 5.2 Buyer-seller transaction (posted-price Stackelberg) -- **Current simple layer:** sellers post fixed `p_floor`; buyers know that price and include the welfare effect of buying oil when evaluating seller cells. - **Leader:** seller commits to `p_s(t)`. -- **Follower:** buyer with reservation `RP_i` (§4.1) and beliefs buys iff `p_s <= RP_i`, `w_m >= p_s*q`, `O_s >= q`. -- **Payoffs:** seller `+p_s*q` money / `-q` oil; buyer `+q` oil / `-p_s*q` money. -- **Quantity `q`:** chosen so the buyer moves toward the Cobb-Douglas optimum where MRS equals seller price, capped by money and inventory. +- **Follower gate:** buyer computes `decision_price` (loss-adjusted, §4.2) and optimal quantity `q*` at that price. If `q* ≤ 0` no transaction occurs. +- **Settlement:** buyer pays `q* × p_s` (actual posted price, not decision price), receives `q*` oil. Seller receives money, delivers oil. +- **Records:** transaction appended to the permanent ledger; buyer's `lifetime_sellers` updated; loyalty streak incremented (or reset if switching). -### 5.3 Learning: Bayesian price beliefs (Req #7) +### 5.3 EMA price beliefs -Price hidden until first visit; updated on seller visits via conjugate Normal learning on log price. README price-scale defaults are converted internally so `m0` remains the prior expected price and `sigma0`, `sigma_obs` remain price-scale standard deviations: +Price is hidden until first visit; each subsequent visit updates the buyer's belief via an exponential moving average: ``` -Prior: log(p_s) ~ N(mu0_log, sigma0_log^2) -Observation: log(posted price) ~ N(log(p_s), sigma_obs_log^2) - -For one observed posted price p_obs: - prec = 1/sigma_log^2 + 1/sigma_obs_log^2 - mu_log' = (mu_log/sigma_log^2 + log(p_obs)/sigma_obs_log^2) / prec - sigma_log2' = 1/prec +Initial belief (unvisited): prior_price_mean +Update on each visit: belief = (1 − α) × old_belief + α × observed_price ``` -The posterior expected price and standard deviation feed the §4.2 price premium and buy decision. `[HEURISTIC - log-normal positivity]` +`α = belief_update_weight ∈ (0, 1]`. At `α = 1` the buyer ignores all history and trusts only the latest price. At small `α` the belief moves slowly, giving recent observations low weight relative to accumulated history. + +For unvisited sellers the buyer uses `prior_price_mean` as a cold-start belief. This feeds both the movement scoring (projected welfare at a seller cell uses the believed price) and the loss-aversion reference point in §4.2 (captured before the EMA update fires, so the reference is what the buyer expected before arriving). + +**Known limitation:** beliefs do not decay between visits — a belief formed when a seller charged 1.5 is unchanged until the buyer returns. A seller that raises prices is still seen as cheap until revisited. --- ## 6. Lock-in: definition and measurement -Two complementary measures. +Four complementary measures, from most structural to most behavioural: -1. **Instantaneous reachability.** Reachable set `R_i(t) = { s : travel_tick_cost + movement_cost <= w_o - safety }`, with movement cost based on Chebyshev distance and `psi_move`. Buyer is locked in at `t` if `|R_i(t)| <= 1` (cannot defect to any alternative). Lock-in rate = time-averaged fraction of living buyers locked in. -2. **Lifetime distinct-seller count.** Number of distinct sellers a buyer ever transacts with, `|Sellers_i|`. A buyer that only ever uses one seller over its whole life is revealed-locked-in; report the distribution across buyers (mass at 1 = strong lock-in). +**1. Instantaneous reachability (per tick).** +Reachable set `R_i(t) = {s : psi_tick + distance × psi_move ≤ w_o}`. A buyer is locked in at tick `t` if `|R_i(t)| ≤ 1`. `lock_in_rate` = fraction of alive buyers locked in this tick. Measures *physical* trapping — not enough oil to reach alternatives. -A **suboptimal-lock-in** flag (locked in *and* a cheaper believed seller exists but is unreachable) separates harmless attachment from welfare-reducing trapping. +**2. Lifetime distinct-seller count (per buyer life).** +`|lifetime_sellers|` at death. Appended to `model.lifetime_seller_counts` on each death. `revealed_lock_in` = fraction of completed lives with count = 1. `switch_rate` = fraction with count ≥ 2. Measures *behavioural* loyalty over a full life. ---- +**3. Suboptimal lock-in (per tick).** +A buyer is suboptimally locked in if it is reachability-locked (measure 1) AND believes some unreachable seller is cheaper than its best reachable option. `suboptimal_lock_in_rate` = fraction of alive buyers meeting both conditions. Separates harmful trapping from harmless loyalty to an already-cheap seller. + +**4. Revealed-suboptimal lock-in (per buyer life).** +A buyer is revealed-suboptimally locked in if it used at most two sellers its whole life AND believed some other seller was cheaper. Reachability is irrelevant here — this catches buyers that *could* have switched but never did (or barely tried), paying above their own estimate of the market price. `revealed_suboptimal_rate` is a headline welfare-loss measure: it jumps sharply when the lock-in premium is active. Observed: 0.4% without exploitation → 58.5% with `a4=1.0`. -## 7. Outputs / measurements +**5. Streak-suboptimal lock-in (per buyer life).** +A buyer is streak-suboptimally locked in if its current consecutive purchase streak with one seller is ≥ `loyalty_threshold_k` AND it believes some other seller is cheaper. Complements measure 4: catches buyers who explored early in life but ended up behaviourally trapped — the case measure 4 misses when `lifetime_sellers > 2`. `streak_suboptimal_rate` is reported alongside `revealed_suboptimal_rate` in both the dashboard and the run summary. -- **Average price** (primary): mean completed-transaction price, market-wide and per seller, tracked over time; plus cross-seller price dispersion (CV). -- **Lock-in rate** and the **lifetime distinct-seller distribution** (§6). -- Buyer and seller active counts; buyer-money distribution; per-tick and cumulative death/respawn counts. -- Single-run exports: model time series, transactions, final buyers, final sellers, and run metadata as CSV/JSON. +**Calibration note:** `switch_rate` is the primary calibration dial. A near-zero switch rate (< 2%) means lock-in is trivial — buyers never had a real choice. Interesting lock-in dynamics require switch_rate in the 5–25% range, achieved by tuning the `moderate_lockin` or `low_lockin` scenario substrate. --- -## 8. Requirement traceability +## 7. Metrics -| # | Requirement | Where satisfied | -|---|---|---| -| 1 | Discrete agents with identity | typed buyers/sellers with IDs (§3) | -| 2 | Internal states change over time | money, oil, beliefs, prices evolve (§2-§5) | -| 3 | Spatially localized | 2-D lattice; fixed sellers, mobile buyers (§1,§3) | -| 4 | Perceive & interact | visual range, harvest, transactions (§3) | -| 5 | Bounded rationality | local money search; myopic pricing (§4,§5.1) | -| 6 | Risk & loss aversion | `rho`, `lambda` on the purchase decision (§4.2), decoupled from welfare | -| 7 | Learning / adaptation | Bayesian price beliefs (§5.3) | -| 8 | Strategic, formalized game | pricing game + posted-price Stackelberg (§5) | -| 9 | No central controller | local pricing, local decisions, fixed-rule environment (§1) | -| 10 | Nontrivial emergence | §9 | -| 11 | Sensitivity analysis | **removed for now per request; reinstate before reporting results** | +### Per-tick time series (DataCollector) ---- +Recorded once per tick in `model_timeseries.csv`. Each row is one tick. -## 9. Emergent behaviour (Req #10) +| Column | What it measures | How computed | +| --- | --- | --- | +| `avg_posted_price` | Mean price sellers are charging | Mean of `posted_price` across all living sellers | +| `price_cv` | Cross-seller price dispersion | Std / mean of posted prices (0 = perfectly equal, higher = more spread) | +| `avg_transaction_price` | Mean price buyers actually paid | Mean price across all purchases completed this tick | +| `num_transactions` | Market activity | Count of oil purchases completed this tick | +| `lock_in_rate` | Physical trapping | Fraction of alive buyers that can reach ≤ 1 seller with their current oil | +| `suboptimal_lock_in_rate` | Harmful physical trapping | Fraction of alive buyers that are physically locked in AND believe an unreachable seller is cheaper | +| `living_buyers` | Population | Count of alive buyers | +| `living_sellers` | Population | Count of alive sellers | +| `step_buyer_deaths` | Buyer mortality this tick | Buyers whose oil hit 0 | +| `step_buyer_respawns` | Replacements spawned this tick | Always equals `step_buyer_deaths` | +| `step_seller_deaths` | Seller mortality this tick | Sellers whose money hit 0 | +| `cumulative_seller_deaths` | Total seller turnover | Running total of seller deaths | +| `total_money` | Environmental richness | Sum of money across all grid cells | -Not imposed; logged as it arises: spatial price gradients and seller territories; buyer sorting into lock-in basins; seller bankruptcy / shrinking `M`; money boom-bust cycles; price-war vs price-matching bands from the competitor-anchoring term. +### Run-level summary ---- +Computed over all completed buyer lives and saved to `run_metadata.json`. + +| Key | What it measures | How computed | +| --- | --- | --- | +| `switch_rate` | Revealed mobility | Fraction of completed lives that used ≥ 2 distinct sellers | +| `switch_rate_traders` | Mobility among active buyers | Same, restricted to lives with ≥ 1 purchase | +| `revealed_lock_in` | Lifetime single-seller loyalty | Fraction of lives that used exactly 1 seller | +| `revealed_suboptimal_rate` | Never-/barely-switched welfare loss | Fraction of lives using ≤ 2 sellers while believing a cheaper alternative existed — buyers that could have switched but barely tried | +| `streak_suboptimal_rate` | Explored-then-trapped welfare loss | Fraction of lives where the buyer's current streak ≥ k yet it believes a cheaper seller exists — buyers who explored but ended up behaviourally trapped | +| `never_transacted` | Isolated buyers | Fraction of completed lives with zero purchases | +| `avg_lifespan` | Buyer survival | Average ticks alive per buyer life | +| `price_spread` | End-state price dispersion | max / min posted price at final tick | +| `avg_posted_price` / `avg_transaction_price` / `price_cv` | Time-averaged price metrics | Mean over all ticks | +| `avg_lock_in_rate` / `avg_suboptimal_lock_in_rate` | Time-averaged lock-in rates | Mean over all ticks | -## 10. Parameters - -| Symbol | Meaning | Default | Plausible range | Notes | -|---|---|---|---|---| -| `N` | buyers | 200 | - | | -| `M` | sellers | 8 | 2-20 | | -| `G` | grid side | 50 | - | | -| `v` | visual range | 5 | 2-15 | | -| `psi_tick` | buyer oil/tick | 1 | 0.1-2 | `[H]` | -| `psi_move` | buyer oil/Chebyshev step moved | 0.5 | 0-2 | `[H]` | -| `buyer_money_weight` | money Cobb-Douglas weight | 0.5 | 0.1-5 | `[H]` | -| `money_regrowth_rate` | money regrowth amount | 1 | 0.1-5 | `[H]` | -| `money_max` | money cell cap | 4 | - | `[H]` | -| `initial_money_probability` | initial money-cell probability | 0.05 | 0-1 | `[H]` | -| `money_regrowth_probability` | per-tick regrowth-cell probability | 0.02 | 0-1 | `[H]` | -| `r_o` | oil regrowth/seller | 2 | - | `[H]` | -| `O_max` | seller oil cap | 50 | - | `[H]` | -| `mu_s` | seller money metabolism | 1 | - | `[H]` | -| `rho` | risk/death aversion | 1 | 0-3 | `[H]` | -| `lambda` | loss aversion | 2.25 | 1-4 | `[H]` | -| `a1` | captive-demand markup | 0.3 | 0-1 | `[H]` | -| `a2` | competitor anchoring | 0.4 | 0-1 | `[H]` | -| `a3` | distress markup | 0.3 | 0-1 | `[H]` | -| `ell` | competitor distance scale | 10 | - | `[H]` | -| `T_h` | solvency horizon | 20 | - | `[H]` | -| `p_floor` | cost floor | 1 | - | `[H]` | -| `m0, sigma0` | price prior | 2, 1 | - | `[H]` | -| `sigma_obs` | price obs. noise | 0.5 | - | `[H]` | +### Expected dynamics + +**Baseline (`a4=0`):** prices stabilize modestly above `p_floor`, `price_cv` stays low (competitor anchoring `a2` pulls prices together), `revealed_suboptimal_rate` and `streak_suboptimal_rate` stay near zero because sellers have no reason to diverge. + +**Lock-in exploitation (`a4>0`):** `avg_posted_price` climbs as streak counts accumulate over ~50–100 ticks. `revealed_suboptimal_rate` jumps sharply (0.4% → 58.5% at `a4=1.0`); `streak_suboptimal_rate` rises in parallel, capturing buyers who explored early but were eventually trapped. Buyer lifespans shorten because oil becomes expensive. Seller deaths increase as the buyer population thins. This is the self-destructive regime: individual sellers rationally exploit captive buyers, collectively starving the market they depend on. --- -## 11. V1 implementation choices +## 8. Sensitivity Analysis + +### All sweepable parameters -1. `buyer_money_weight` is a preference weight; buyers do not burn money. -2. Risk and loss aversion gate purchase/switching, not Cobb-Douglas welfare. -3. The grid is toroidal by default. -4. Buyers always respawn after death; sellers respawn by configurable toggle, default on. -5. Money harvest is take-all with sparse stochastic grow-back. -6. Buyers jump within visual range to maximize projected Cobb-Douglas welfare. -7. Seller reachability uses estimated travel ticks plus distance-based movement cost. -8. Price beliefs are log-normal with price-scale defaults converted internally. -9. The optional suboptimal-lock-in flag is logged. +| Parameter | Config key | Default | Effect on | +|---|---|---|---| +| Competitor anchoring weight | `a2` | 0.5 | Price convergence, switch rate | +| Lock-in premium weight | `a4` | 0.0 | Exploitation intensity, revealed_suboptimal, streak_suboptimal | +| Loyalty streak threshold | `loyalty_threshold_k` | 3 | How fast exploitation kicks in | +| Loss aversion | `loss_aversion` | 2.25 | Switching psychological cost | +| Belief update weight | `belief_update_weight` | 0.3 | Belief adaptation speed, inertia toward known sellers | +| Visual range | `visual_range` | 5 | Market accessibility, reachability lock-in | +| Oil burn per tick | `psi_tick` | 1.0 | Survival pressure, α, seller dependency | +| Movement oil cost | `psi_move` | 0.2 | Mobility cost, geographic lock-in | +| Number of sellers | `num_sellers` | 8 | Market concentration | +| Number of buyers | `num_buyers` | 200 | Demand density, D_s values | +| Captive demand weight | `a1` | 1.0 | Geographic markup strength | +| Liquidity pressure weight | `a3` | 1.0 | Seller distress response | +| Competitor distance scale | `competitor_anchoring_length_scale` | 10.0 | Spatial reach of price anchoring | +| Solvency horizon | `solvency_horizon_ticks` | 20.0 | Seller distress sensitivity | +| Money Cobb-Douglas weight | `buyer_money_weight` | 0.5 | α, oil vs money preference | +| Seller money metabolism | `seller_money_metabolism` | 1.0 | Seller mortality rate | +| Price prior mean | `prior_price_mean` | 2.0 | Cold-start belief for unvisited sellers | +| Money regrowth rate | `money_regrowth_rate` | 1.0 | Environmental richness | +| Money regrowth probability | `money_regrowth_probability` | 0.02 | Money supply density | +| Seller respawn toggle | `respawn_sellers` | True | Seller exit dynamics | +| Grid size | `grid_size` | 50 | Market geography | + +### The 6 parameters to prioritise + +These six span the full causal chain from market structure to exploitation to welfare loss. + +**1. `a2` — competitor anchoring weight** `[0.0 → 0.9]` +The primary price-structure lever. At `a2=0` prices diverge freely, giving buyers a genuine economic reason to switch. As `a2` rises, prices converge across sellers, eliminating the benefit of switching even when switching is physically possible. Governs the baseline level of lock-in that exists *before* any exploitation is added. All other parameters should be interpreted relative to a chosen `a2`. + +**2. `a4` — lock-in premium weight** `[0.0 → 2.0]` +The exploitation intensity dial. At `a4=0` the model is a standard spatial oligopoly. As `a4` rises, sellers with captive buyers charge increasingly above the market, driving both `revealed_suboptimal_rate` and `streak_suboptimal_rate` up and buyer lifespans down. The interaction with `a2` is critical: at high `a2` (convergent prices) the lock-in premium is the only source of price dispersion, making its effect cleaner and larger. + +**3. `belief_update_weight` (α)** `[0.05 → 1.0]` +The belief adaptation dial. At `α = 1` buyers immediately replace their belief with each new observation — maximum reactivity, minimal inertia. At small `α` beliefs change slowly, so a seller that has raised prices since the buyer's last visit is still seen as cheap. Slow beliefs create inertia toward known sellers even when prices diverge, indirectly sustaining loyalty counts. Interacts with `a4`: if beliefs adapt slowly, buyers are slow to notice the loyalty markup has fired, letting it compound before they reduce purchases. + +**4. `visual_range` (v)** `[2 → 15]` +Market accessibility. A small visual range means buyers can only see a few cells — sellers outside the range are invisible and effectively unreachable regardless of oil. This is a structural, geography-driven form of lock-in independent of prices or beliefs. Interacts with grid density (`num_sellers / grid_size²`): the same visual range means more sellers are visible on a dense market than a sparse one. + +**5. `loyalty_threshold_k`** `[1 → 10]` +The patience parameter for exploitation. At `k=1`, a single purchase qualifies a buyer as loyal and the markup fires immediately. At `k=10`, a buyer must purchase ten consecutive times before the seller raises the price. Low `k` with high `a4` creates aggressive rapid exploitation; high `k` with high `a4` creates delayed but sustained exploitation. This shapes the *temporal profile* of the exploitation signal in the time series. + +**6. `loss_aversion` (λ)** `[1.0 → 4.0]` +The switching psychological tax. When a buyer visits a seller whose actual price exceeds the buyer's EMA belief, the overage is amplified by `λ` in the purchase decision. As sellers raise prices via the loyalty markup, the buyer's belief lags behind (governed by `α`) — this gap is exactly what `λ` penalises. High `λ` means buyers tolerate less overpricing before deciding not to buy. Interacts with `a4`: once the loyalty markup has pushed prices above the buyer's belief, `λ` determines whether buyers stop buying or continue buying at reduced quantity. Interacts with `α`: slow belief adaptation (small `α`) means the gap between posted price and belief grows larger before correcting, making `λ` hit harder. + +--- + +## 9. Parameters + +| Symbol | Config key | Default | Notes | +|---|---|---|---| +| `G` | `grid_size` | 50 | Grid side length | +| `N` | `num_buyers` | 200 | Buyer count | +| `M` | `num_sellers` | 8 | Seller count | +| `v` | `visual_range` | 5 | Buyer Chebyshev sight radius | +| `psi_tick` | `psi_tick` | 1.0 | Oil burned per tick `[H]` | +| `psi_move` | `psi_move` | 0.2 | Oil burned per unit Chebyshev distance `[H]` | +| `buyer_money_weight` | `buyer_money_weight` | 0.5 | Cobb-Douglas money preference `[H]` | +| `money_max` | `money_max` | 4.0 | Cell money cap `[H]` | +| `money_regrowth_rate` | `money_regrowth_rate` | 1.0 | Per-tick money added to selected cells `[H]` | +| `money_regrowth_probability` | `money_regrowth_probability` | 0.02 | Probability each cell regrows `[H]` | +| `r_o` | `r_o` | 2.0 | Oil replenished per seller per tick `[H]` | +| `O_max` | `o_max` | 50.0 | Seller oil capacity `[H]` | +| `μ_s` | `seller_money_metabolism` | 1.0 | Seller money burned per tick `[H]` | +| `λ` | `loss_aversion` | 2.25 | Loss aversion (λ ≥ 1) `[H]` | +| `α` | `belief_update_weight` | 0.3 | EMA step size, in (0, 1] `[H]` | +| `a1` | `a1` | 1.0 | Captive-demand markup weight `[H]` | +| `a2` | `a2` | 0.5 | Competitor-anchoring weight (< 1.0) `[H]` | +| `a3` | `a3` | 1.0 | Liquidity-pressure weight `[H]` | +| `a4` | `a4` | 0.0 | Loyalty-markup weight `[H]` | +| `K` | `loyalty_threshold_k` | 3 | Consecutive purchases for loyalty `[H]` | +| `ell` | `competitor_anchoring_length_scale` | 10.0 | Competitor anchoring distance decay `[H]` | +| `T_h` | `solvency_horizon_ticks` | 20.0 | Seller solvency horizon `[H]` | +| `m0` | `prior_price_mean` | 2.0 | Cold-start belief for unvisited sellers `[H]` | +| `p_floor` | `p_floor` | 1.0 | Seller cost floor `[H]` | +| — | `respawn_sellers` | True | Replace dead sellers at a random cell | --- -## 12. Build order -1. **Substrate, biases off** - sparse money landscape, oil, local money-search movement, harvest, flat price. Verify the resource loop and respawn. -2. **Pricing game** - the 3-component seller rule; check price gradients and competitor coupling; start measuring average price. -3. **Beliefs + biases** - Bayesian price learning, then `rho`/`lambda`; check lock-in (both measures) appears. + diff --git a/images/overlay_correlation_moderate_lockin.png b/images/overlay_correlation_moderate_lockin.png new file mode 100644 index 0000000..a4e4800 Binary files /dev/null and b/images/overlay_correlation_moderate_lockin.png differ diff --git a/images/scenario_diagnostics.png b/images/scenario_diagnostics.png new file mode 100644 index 0000000..95cd293 Binary files /dev/null and b/images/scenario_diagnostics.png differ diff --git a/images/seller_diag_moderate_lockin.png b/images/seller_diag_moderate_lockin.png new file mode 100644 index 0000000..bf03f55 Binary files /dev/null and b/images/seller_diag_moderate_lockin.png differ diff --git a/progress_reports/progress_report.md b/progress_reports/progress_report.md new file mode 100644 index 0000000..ebe0301 --- /dev/null +++ b/progress_reports/progress_report.md @@ -0,0 +1,281 @@ +# Sensitivity Analysis — Preparation Log + +Validating the model and establishing a clean baseline **before** committing to +a full sensitivity analysis (SA). Goal of the SA: 4–5 parameters, swept on a +defensible baseline, measured against the lock-in responses below. + +**Session date:** 2026-06-20 + +--- + +## TL;DR — where we are + +- **Test suite repaired and extended** — was 0-collectable (stale imports); + now **153 passing**, including a determinism guard, per-seller metric, and + purchase-conditioned switching metrics. +- **Determinism bug found & fixed** — `revealed_suboptimal_rate` was + non-deterministic (identity-hashed set iteration). +- **Root cause of "dead switching" found** — the buyer cold-start belief + prior (`prior_price_mean=2.0`) sat *above* all equilibrium prices, so buyers + never explored. It was the anchor, **not `a2`**. +- **Baseline recalibrated** — `prior_price_mean=1.7` (in the scenario + substrate). `moderate_lockin` now gives switch_rate **16% ± 2** with a real + lock-in signal (~20%), confirmed across 6 seeds. +- **Per-seller price/oil metric added**; oil confirmed *not* a bottleneck. +- **Run length settled** — a 1500-tick run showed sustained ~130-tick + oscillations and that completed-life metrics are biased in short windows. + **SA must use `steps ≥ 1500`.** (This also resolves `never_transacted`: 0.08 + at long horizon.) +- **Open decisions:** the 4–5 SA parameter set, and whether the prior is an + SA axis or held fixed. (Deferred by user.) + +--- + +## Responses we measure (outputs) + +| Response | Meaning | +|---|---| +| `switch_rate` | Primary calibration dial. Target band **5–25%**. | +| `revealed_suboptimal_rate` | Headline welfare-loss / behavioural lock-in. | +| `streak_suboptimal_rate` | Explored-then-trapped welfare loss. | +| `never_transacted` | Buyers that never trade (see caveat in §6). | +| `price_cv` / `avg_posted_price` | Cross-seller price dispersion / level. | +| `avg_lifespan` | Buyer survival. | + +## Candidate inputs (to finalise) + +Investigation reshaped the README's original 6. **Live levers:** +`prior_price_mean` (dominant), `a4`, `loyalty_threshold_k`, `loss_aversion`, +`belief_update_weight`, money supply / density. **Demoted:** `a2` +(weak/inverted), `visual_range`, `r_o`/`o_max` (oil never binds). + +--- + +## Workflow + +1. Green baseline — `uv run pytest`. +2. Determinism — same seed ⇒ identical summary. +3. Scenario diagnostics — do the scenarios behave as documented? +4. Replicate-seed variance — error bars + seeds-per-design-point. +5. Burn-in / horizon — **`steps ≥ 1500`** (sustained ~130-tick oscillations + + completed-life metrics are biased below ~1000 ticks; see §7). +6. OAT screening of the chosen parameters. +7. Runtime budget + grid decision. +8. Proper SA (Sobol/Morris — needs `SALib`, not yet a dependency). + +--- + +## 1. Test suite repaired & extended + +The suite **could not collect** — drift from the simplified model: + +- `test_pricing.py` imported `loyalty_markup` (renamed → `lock_in_premium`): + blocked the whole run. Fixed by rename. +- `test_lockin.py` / `test_suboptimal_lockin.py` passed a `safety=` buffer arg + removed from the model. Dropped the no-op kwarg; deleted the dead + `test_is_locked_in_respects_safety_buffer`. + +Added **`tests/test_model_end_to_end.py`** — a whole-model contract test: +determinism, summary-key contract, DataCollector schema/bounds, population +conservation, death bookkeeping, ledger consistency, per-seller series. + +→ **153 passing.** + +## 2. Determinism bug (found by step 2) + +Same seed produced bit-identical results on every metric **except** +`revealed_suboptimal_rate` (e.g. 0.0367 vs 0.0735). + +**Cause:** `is_revealed_suboptimal` picked a reference seller via +`next(iter(lifetime_sellers))` for 2-seller lives. Seller objects hash by +identity → set-iteration order varies between process runs → comparison flips. + +**Fix (`lockin.py`):** reference = cheapest believed price among *used* sellers +(order-independent); "others" excludes all used sellers. + regression test. +After fix: byte-identical across runs. + +## 3. Scenario diagnostics — switching looked dead + +`scripts/diagnose_scenarios.py`, 300 ticks, seed 42 (**pre-recalibration**): + +| Metric | degenerate | low_lockin (a2=0) | moderate_lockin (a2=0.5) | +|---|---|---|---| +| switch_rate | 0.000 | 0.097 | 0.093 | +| never_transacted | 0.696 | 0.251 | 0.191 | +| revealed_suboptimal | 0.305 | 0.003 | 0.012 | + +- `degenerate` is **degenerate** (70% never trade, lifespan ~10) — null ref. +- `low_lockin` and `moderate_lockin` switched **identically (~9%)** — `a2` was + *not* separating them, contrary to the documented ~30% vs ~10%. +- Burn-in ≈ **100–150 ticks** (damped oscillations) → use `steps ≥ 200`. + +## 4. Grid-size experiment + +`--grid-size 25` (vs 50; same agent counts → ~4× density), seed 42: + +| Metric | scenario | grid 50 | grid 25 | +|---|---|---|---| +| switch_rate (traders) | low_lockin | 0.129 | **0.318** | +| | moderate_lockin | 0.115 | **0.366** | +| never_transacted | low_lockin | 0.251 | **0.680** | + +Density strongly raises switching **among traders**, but also raises +`never_transacted` (smaller world = scarcer money). Confounded knob — shrinking +the grid changes density *and* money supply together. + +## 5. Root cause — the belief prior, not a2 + +`scripts/diagnose_sellers.py` (per-seller price/oil + buyer exploration): + +- Steady-state posted prices sit in **1.06–1.57** (mean ~1.27) — *entirely + below* the old prior `prior_price_mean = 2.0`. +- Buyers navigate by projected welfare on **beliefs**; every *unvisited* seller + is valued at the flat prior. Above-equilibrium prior ⇒ every unexplored seller + looks dearer than the known one ⇒ **no exploration**. +- Exploration was near-zero: buyers knew **0.68 sellers on average, 87.5% knew + ≤1**, out of 20 reachable. `a2` disperses *real* prices, but that dispersion + is **invisible** to buyers — hence `a2` looked dead. +- **Oil is not the bottleneck:** mean seller oil ~33, min ever 2.0, 0% near + empty, half the sellers never sell a drop. `r_o=2`, `o_max=50` are ample. + +## 6. Prior recalibration → BASELINE + +**Decision:** recalibrate the prior. Sweep (3 seeds, 200 ticks): + +| prior | switch (low_lockin) | switch (moderate_lockin) | +|---|---|---| +| 1.3 | 0.273 | 0.366 | +| 1.5 | 0.202 | 0.235 | +| **1.7** | **0.129** | **0.179** | +| 1.9 | 0.112 | 0.084 | + +`prior_price_mean` is a **strong, monotonic switching dial**. **1.7** lands both +scenarios in-band → set in the `scenarios.py` substrate (config default +untouched; tests stay green). + +**Multi-seed baseline confirmation** (6 seeds, 200 ticks, `scripts/baseline_confirmation.py`): + +| Scenario | switch_rate | rev_subopt | streak_sub | never_tx | price_cv | +|---|---|---|---|---|---| +| low_lockin | 0.140 ± 0.031 | 0.001 | 0.023 | 0.423 | 0.110 | +| **moderate_lockin** | **0.163 ± 0.023** | **0.113 ± 0.033** | 0.032 | 0.449 | 0.090 | + +→ **`moderate_lockin` is the SA baseline**: in-band switching + a stable ~11% +lock-in welfare signal for the exploitation levers to move. `low_lockin` is the +low-lock-in reference. The `a2` inversion remains mild (it is not a primary lever). + +**`never_transacted` money-supply check** (same script, Part B): *not* a clean +knob. Raising `money_regrowth_probability` 0.02→0.05 cut never_tx (0.45→0.35) +but **doubled** switch_rate (0.18→0.43); raising the regrowth *rate* made never_tx +**worse** (0.63). The ~40% is largely a **selection artifact** — never_transacted +is measured over *completed (dead)* lives, and traders rarely die in-window, so +dead lives over-represent non-traders. Money supply is itself a strong switch +lever (candidate), but is not the way to "fix" never_transacted. + +## 7. Stationarity & run length (1500-tick run) + +A 1500-tick run (`diagnose_scenarios.py --steps 1500`) revealed two things short +runs hid: + +**a) Persistent oscillations, not burn-in.** Transactions/tick, total +environmental money, and lock-in rate oscillate with a **~130-tick period for +the whole run** — a sustained limit cycle (Sugarscape money depletion ↔ +regrowth). Prices, by contrast, settle by ~tick 80. So instantaneous metrics +must be averaged over **many** cycles, not a 2-cycle window. + +**b) Run length biases all completed-life metrics.** `avg_lifespan ≈ 200`, so a +short window only counts the fast-dying *non-traders*; long-lived traders are +still alive and uncounted. As the window grows: + +| Metric (moderate_lockin) | 200 ticks | 300 ticks | 1500 ticks | +|---|---|---|---| +| switch_rate | 0.163 | 0.215 | **0.240** | +| revealed_suboptimal | 0.113 | 0.198 | 0.108 | +| never_transacted | 0.449 | 0.128 | **0.076** | +| avg_lifespan | — | 288 | **201** | + +→ The `never_transacted` "problem" was a **short-window selection artifact**: at +1500 ticks it drops to 0.076, *below* the 10% target. **Implication for SA: use +`steps ≥ 1500`** so lifetime metrics are unbiased and instantaneous metrics span +~10 cycles. (Supersedes the earlier "≥200 ticks" burn-in note.) + +Also: the **living-buyer/seller series are constant by design** (instant 1:1 +respawn) — uninformative as time series. Diagnostic panels now plot turnover +(per-tick + cumulative deaths) instead. Whether populations *should* be fixed is +a modelling choice: fixed = clean SA baseline; `respawn_sellers=False` (or a new +buyer-attrition toggle) = the "market collapse" regime, best studied separately. + +## 8. Per-seller price metric (added) + +Mesa `agenttype_reporters` on `Seller` now record `posted_price`, `oil`, `money` +per tick → `model.datacollector.get_agenttype_vars_dataframe(Seller)`. Gives +individual seller trajectories + dispersion, not just the aggregates. Covered by +a test. (`model.py`.) + +## 9. Purchase-conditioned switching metrics (added) + +The per-life `switch_rate` weights every life equally regardless of how many +times it traded — a 1-purchase life is pinned to one seller *structurally*, not +by choice. Added per-life purchase counts (`model.lifetime_purchase_counts`, +paired with the seller counts) and three summary metrics: + +- `switch_rate_min2_purchases` / `switch_rate_min3_purchases` — switch rate among + lives with ≥2 / ≥3 purchases (removes the "no opportunity" floor). +- `switching_propensity` — mean of `(distinct−1)/(purchases−1)`, the lifespan- + neutral share of purchase transitions that reached a new seller. + +The unweighted `switch_rate` is kept as the headline; these are companions. +Lifetime-*weighting* was rejected: exploitation shortens lifespans, so weighting +by lifetime would down-weight the harmed buyers and mask the effect. + +## 10. Metric co-movement (correlation overlay) + +`scripts/overlay_correlation.py --scenario moderate_lockin --steps 1500` +(`images/overlay_correlation_moderate_lockin.png`). Pearson, ticks ≥ 100: + +| pair | r | +|---|---| +| transactions ↔ total_money | **+0.69** | +| transactions ↔ avg_txn_price | **+0.70** | +| price_cv ↔ avg_txn_price | +0.61 | +| lock_in_rate ↔ price_cv | **−0.40** | +| lock_in_rate ↔ total_money | +0.42 | + +The oscillation is a **boom–bust cycle**: money, transactions, and transaction +price rise and fall together (rich phase → more trading → demand pushes prices +up). Price dispersion is **counter-cyclical to physical lock-in** (−0.40): when +buyers are oil-trapped, prices have converged. Confirms the ~130-tick limit +cycle is an ecological money depletion↔regrowth loop, not noise. + +> **Scenario rename (this session):** `readme_default → degenerate`, +> `can_switch → low_lockin`, `partial_lockin → moderate_lockin`, applied across +> code, tests, scripts and CLI. Default scenario is now `degenerate`. + +--- + +## Decisions + +| Decision | Status | +|---|---| +| Fix baseline by recalibrating the prior | done (`prior_price_mean=1.7`) | +| SA baseline scenario | `moderate_lockin` | +| Per-seller price as a metric | added (agenttype_reporter) | +| Don't sweep oil (`r_o`,`o_max`) / `visual_range` | (not binding) | +| **4–5 SA parameter set** | open | +| **Prior as SA axis vs fixed at 1.7** | open | + +## Artifacts + +- **Scripts:** `diagnose_scenarios.py` (scenario time series; `--grid-size`, + `--prior-price-mean`), `diagnose_sellers.py` (per-seller price/oil/exploration), + `baseline_confirmation.py` (multi-seed + money check), + `overlay_correlation.py` (metric overlay + correlation heatmap), `sweep_a2.py`. +- **Figures:** written to `images/` — `scenario_diagnostics.png` (baseline), + `seller_diag_moderate_lockin.png`, + `overlay_correlation_moderate_lockin.png`. Regenerate via the scripts above. + +## Next steps + +1. Lock the 4–5 SA parameter set + the prior-as-axis question. +2. OAT screening of the chosen parameters on `moderate_lockin` (5–6 seeds each). +3. Add `SALib`; run Sobol/Morris for the official analysis. diff --git a/scripts/baseline_confirmation.py b/scripts/baseline_confirmation.py new file mode 100644 index 0000000..70f499e --- /dev/null +++ b/scripts/baseline_confirmation.py @@ -0,0 +1,91 @@ +"""Multi-seed baseline confirmation + never_transacted money-supply check. + +Part A: run the two live scenarios across several seeds on the recalibrated +baseline (prior=1.7, in the substrate) and report mean +/- std of the headline +metrics, so the baseline has error bars before the SA. + +Part B: probe whether the ~13-21% never_transacted is a money-supply artifact, +by sweeping money_regrowth_probability on moderate_lockin. + +Usage: + uv run python -u scripts/baseline_confirmation.py [--steps 200] [--seeds 6] +""" + +from __future__ import annotations + +import argparse +import statistics +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import scenario_overrides + +KEYS = [ + ("switch_rate", "switch"), + ("switch_rate_traders", "switch_tr"), + ("revealed_suboptimal_rate", "rev_subopt"), + ("streak_suboptimal_rate", "streak_sub"), + ("never_transacted", "never_tx"), + ("price_cv", "price_cv"), + ("avg_lifespan", "lifespan"), +] + + +def run(steps: int, seed: int, **overrides) -> dict: + m = SpatialMarketModel(ModelConfig(seed=seed, steps=steps, **overrides)) + m.run() + return summarize_run(m) + + +def fmt_mean_std(label: str, runs: list[dict]) -> str: + cells = [] + for key, _ in KEYS: + vals = [r[key] for r in runs] + cells.append(f"{statistics.mean(vals):>8.3f}±{statistics.pstdev(vals):>5.3f}") + return f"{label:<22}" + " ".join(cells) + + +def header() -> str: + return f"{'':<22}" + " ".join(f"{short:>14}" for _, short in KEYS) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", type=int, default=200) + parser.add_argument("--seeds", type=int, default=6) + args = parser.parse_args() + seeds = list(range(1, args.seeds + 1)) + + print(f"=== PART A: multi-seed baseline ({len(seeds)} seeds, {args.steps} ticks) ===") + print(header()) + print("-" * len(header())) + for scenario in ("low_lockin", "moderate_lockin"): + ov = scenario_overrides(scenario) + runs = [run(args.steps, s, **ov) for s in seeds] + print(fmt_mean_std(scenario, runs)) + sys.stdout.flush() + + print(f"\n=== PART B: never_transacted vs money supply (moderate_lockin) ===") + print(header()) + print("-" * len(header())) + base = scenario_overrides("moderate_lockin") + money_seeds = seeds[:3] + for prob in (0.02, 0.05, 0.10): + ov = {**base, "money_regrowth_probability": prob} + runs = [run(args.steps, s, **ov) for s in money_seeds] + print(fmt_mean_std(f"regrow_prob={prob}", runs)) + sys.stdout.flush() + # Also bump the per-cell regrowth amount at the default probability. + ov = {**base, "money_regrowth_rate": 2.0} + runs = [run(args.steps, s, **ov) for s in money_seeds] + print(fmt_mean_std("regrow_rate=2.0", runs)) + + print("\nDONE") + + +if __name__ == "__main__": + main() diff --git a/scripts/diagnose_scenarios.py b/scripts/diagnose_scenarios.py new file mode 100644 index 0000000..9c04024 --- /dev/null +++ b/scripts/diagnose_scenarios.py @@ -0,0 +1,171 @@ +"""Diagnostic: run the named scenarios and visualise what the model is doing. + +Drives ``metrics.summarize_run`` (run-level) and the DataCollector time series +(per-tick) for each named scenario, prints a side-by-side summary table, and +saves an overlaid time-series + run-metric figure so the dynamics under each +parameter setup are legible at a glance. + +This is the "look before you sweep" step that precedes sensitivity analysis: +confirm the scenarios still behave as documented (switch ~1% / ~30% / ~10%) and +see where metrics stabilise (burn-in) before choosing SA horizons. + +Usage: + uv run python scripts/diagnose_scenarios.py [--steps 300] [--seed 42] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.gridspec as gridspec +import numpy as np +import pandas as pd + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import scenario_names, scenario_overrides + +COLORS = {"degenerate": "#6c6c6c", "low_lockin": "#2166ac", "moderate_lockin": "#d6604d"} + +# Run-level summary rows (from metrics.summarize_run), in display order. +SUMMARY_ROWS = [ + ("switch_rate", "Switch rate"), + ("switch_rate_traders", "Switch rate (traders)"), + ("revealed_lock_in", "Revealed lock-in (1 seller)"), + ("revealed_suboptimal_rate", "Revealed suboptimal"), + ("streak_suboptimal_rate", "Streak suboptimal"), + ("never_transacted", "Never transacted"), + ("avg_lifespan", "Avg lifespan (ticks)"), + ("avg_posted_price", "Avg posted price"), + ("avg_transaction_price", "Avg transaction price"), + ("price_cv", "Price CV"), + ("avg_lock_in_rate", "Avg lock-in rate"), + ("completed_lives", "Completed lives"), +] + +# Per-tick panels: (DataCollector column, title, y-label). +TS_PANELS = [ + ("avg_posted_price", "Avg posted price", "Price"), + ("price_cv", "Price dispersion (CV)", "CV"), + ("num_transactions", "Transactions per tick", "Count"), + ("avg_transaction_price", "Avg transaction price", "Price"), + ("lock_in_rate", "Instantaneous lock-in rate", "Fraction"), + ("suboptimal_lock_in_rate", "Suboptimal lock-in rate", "Fraction"), + # Living counts are pinned by instant respawn (constant) -> uninformative as + # series. Plot turnover instead: per-tick buyer deaths + cumulative deaths. + ("step_buyer_deaths", "Buyer deaths per tick", "Count"), + ("cumulative_buyer_deaths", "Cumulative buyer deaths", "Count"), + ("cumulative_seller_deaths","Cumulative seller deaths", "Count"), + ("total_money", "Total environmental money", "Money"), +] + + +def run(name: str, steps: int, seed: int, grid_size: int | None = None, + prior_price_mean: float | None = None) -> SpatialMarketModel: + overrides = scenario_overrides(name) + if grid_size is not None: + overrides["grid_size"] = grid_size + if prior_price_mean is not None: + overrides["prior_price_mean"] = prior_price_mean + model = SpatialMarketModel(ModelConfig(seed=seed, steps=steps, **overrides)) + model.run() + return model + + +def timeseries(model: SpatialMarketModel) -> pd.DataFrame: + df = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) + df["tick"] = range(len(df)) + return df + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", type=int, default=300) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--grid-size", type=int, default=None, + help="override grid_size for all scenarios (default: scenario value)") + parser.add_argument("--prior-price-mean", type=float, default=None, + help="override prior_price_mean (buyer cold-start belief)") + args = parser.parse_args() + + names = scenario_names() + grid_note = f", grid={args.grid_size}" if args.grid_size else "" + prior_note = f", prior={args.prior_price_mean}" if args.prior_price_mean else "" + print(f"Running {len(names)} scenarios x {args.steps} ticks " + f"(seed={args.seed}{grid_note}{prior_note})...") + models = {name: run(name, args.steps, args.seed, args.grid_size, + args.prior_price_mean) for name in names} + frames = {name: timeseries(m) for name, m in models.items()} + summaries = {name: summarize_run(m) for name, m in models.items()} + + # --- console summary table --- + width = 26 + 18 * len(names) + print("=" * width) + print(f"{'Metric':<26}" + "".join(f"{n:>18}" for n in names)) + print("-" * width) + for key, display in SUMMARY_ROWS: + cells = "".join(f"{summaries[n].get(key, float('nan')):>18.4f}" for n in names) + print(f"{display:<26}{cells}") + print("=" * width) + + # --- figure --- + fig = plt.figure(figsize=(16, 22)) + grid_label = f"grid={args.grid_size}" if args.grid_size else "grid=default(50)" + fig.suptitle( + f"Scenario diagnostics | {args.steps} ticks | seed={args.seed} | {grid_label}", + fontsize=14, fontweight="bold", y=0.995, + ) + gs = gridspec.GridSpec(6, 2, figure=fig, hspace=0.5, wspace=0.28) + + for idx, (col_name, title, ylabel) in enumerate(TS_PANELS): + ax = fig.add_subplot(gs[idx // 2, idx % 2]) + for name, df in frames.items(): + if col_name in df.columns: + ax.plot(df["tick"], df[col_name], label=name, + color=COLORS.get(name), linewidth=1.3, alpha=0.85) + ax.set_title(title, fontsize=10, fontweight="bold") + ax.set_xlabel("Tick", fontsize=8) + ax.set_ylabel(ylabel, fontsize=8) + ax.tick_params(labelsize=7) + ax.legend(fontsize=7, framealpha=0.6) + ax.grid(True, linewidth=0.4, alpha=0.5) + + # Run-level bar panels for the headline behavioural metrics. + bar_metrics = [ + ("switch_rate", "Switch rate (≥2 sellers)"), + ("revealed_suboptimal_rate", "Revealed-suboptimal rate"), + ] + for j, (key, title) in enumerate(bar_metrics): + ax = fig.add_subplot(gs[5, j]) + values = [summaries[n].get(key, float("nan")) for n in names] + bars = ax.bar(names, values, color=[COLORS.get(n) for n in names], + alpha=0.85, width=0.55) + for bar, val in zip(bars, values): + if not np.isnan(val): + ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), + f"{val:.1%}", ha="center", va="bottom", + fontsize=8, fontweight="bold") + ax.set_title(title, fontsize=10, fontweight="bold") + ax.set_ylabel("Fraction of lives", fontsize=8) + ax.tick_params(labelsize=7) + ax.grid(True, axis="y", linewidth=0.4, alpha=0.5) + + suffix = f"_grid{args.grid_size}" if args.grid_size else "" + suffix += f"_prior{args.prior_price_mean}" if args.prior_price_mean else "" + images_dir = Path(__file__).resolve().parent.parent / "images" + images_dir.mkdir(exist_ok=True) + out = images_dir / f"scenario_diagnostics{suffix}.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"\nSaved figure to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/diagnose_sellers.py b/scripts/diagnose_sellers.py new file mode 100644 index 0000000..774675d --- /dev/null +++ b/scripts/diagnose_sellers.py @@ -0,0 +1,137 @@ +"""Per-seller price & oil trajectories + buyer exploration diagnostics. + +Three questions: + 1. How does each seller's posted price move over time (individually, and + mean +/- std across sellers)? Should per-seller price be a metric? + 2. Is seller oil supply binding? (r_o regrowth / o_max capacity too low?) + 3. Why does a price gap not cause switching? Measure how many sellers each + buyer ever learns about, vs the cold-start prior they compare against. + +Steps the model manually so it can snapshot every living seller each tick. + +Usage: + uv run python scripts/diagnose_sellers.py [--scenario low_lockin] [--steps 250] +""" + +from __future__ import annotations + +import argparse +import statistics +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.scenarios import scenario_overrides + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", default="low_lockin") + parser.add_argument("--steps", type=int, default=250) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + cfg = ModelConfig(seed=args.seed, steps=args.steps, + **scenario_overrides(args.scenario)) + model = SpatialMarketModel(cfg) + + # Snapshot per-seller price/oil each tick, keyed by unique_id. + rows = [] # (tick, sid, price, oil) + for tick in range(1, args.steps + 1): + model.step() + for s in model.alive_sellers: + rows.append((tick, s.unique_id, s.posted_price, s.oil)) + + df = pd.DataFrame(rows, columns=["tick", "sid", "price", "oil"]) + + # Sellers alive for the entire run (the original cohort, no death/respawn). + full_life = [sid for sid, g in df.groupby("sid") if len(g) == args.steps] + + price_w = df.pivot(index="tick", columns="sid", values="price") + oil_w = df.pivot(index="tick", columns="sid", values="oil") + + # --- price stats (cross-seller, per tick) --- + pmean = price_w.mean(axis=1) + pstd = price_w.std(axis=1) + print(f"\n=== {args.scenario} | {args.steps} ticks | seed={args.seed} ===") + print(f"sellers ever present: {df['sid'].nunique()} " + f"(full-life cohort: {len(full_life)})") + print("\nPOSTED PRICE (last 50 ticks, cross-seller):") + tail = price_w.tail(50) + print(f" mean = {tail.mean().mean():.3f}") + print(f" std = {tail.stack().std():.3f}") + print(f" min = {tail.min().min():.3f} max = {tail.max().max():.3f}") + print(f" prior_price_mean (buyer cold-start belief) = {cfg.prior_price_mean}") + vals = tail.to_numpy() + finite = np.isfinite(vals) + frac_below_prior = (vals[finite] < cfg.prior_price_mean).mean() + print(f" fraction of (seller,tick) below prior 2.0 = {frac_below_prior:.2%}") + + # --- oil supply pressure --- + print("\nSELLER OIL (binding-supply check):") + print(f" o_max = {cfg.o_max}, r_o = {cfg.r_o}/tick, " + f"start = {cfg.seller_initial_oil}") + print(f" mean oil across run = {oil_w.stack().mean():.2f}") + print(f" min oil ever = {oil_w.min().min():.2f}") + print(f" median per-seller min oil = " + f"{statistics.median(oil_w.min().values):.2f}") + near_empty = (oil_w.stack() < 1.0).mean() + near_full = (oil_w.stack() > 0.9 * cfg.o_max).mean() + print(f" fraction (seller,tick) near-empty (<1) = {near_empty:.2%}") + print(f" fraction (seller,tick) near-full (>0.9*o_max) = {near_full:.2%}") + + # --- buyer exploration breadth (why switching is dead) --- + known = [len(b.beliefs) for b in model.alive_buyers] + print("\nBUYER EXPLORATION (alive buyers at final tick):") + print(f" sellers in market = {len(model.alive_sellers)}, " + f"visual_range = {cfg.visual_range}") + print(f" mean sellers ever visited/known = {statistics.mean(known):.2f}") + print(f" median = {statistics.median(known)} " + f"max = {max(known)} " + f"%-knowing<=1 = {np.mean([k <= 1 for k in known]):.1%}") + + # --- figure: per-seller price + oil --- + fig, axes = plt.subplots(2, 1, figsize=(11, 9)) + + ax = axes[0] + for sid in full_life: + ax.plot(price_w.index, price_w[sid], color="#999", linewidth=0.6, alpha=0.5) + ax.plot(pmean.index, pmean, color="#d6604d", linewidth=2, label="mean price") + ax.fill_between(pmean.index, pmean - pstd, pmean + pstd, color="#d6604d", + alpha=0.15, label="+/- 1 std") + ax.axhline(cfg.prior_price_mean, color="#2166ac", linestyle="--", linewidth=1.5, + label=f"buyer prior belief ({cfg.prior_price_mean})") + ax.set_title(f"Per-seller posted price over time -- {args.scenario}", + fontsize=11, fontweight="bold") + ax.set_xlabel("Tick"); ax.set_ylabel("Posted price") + ax.legend(fontsize=8); ax.grid(True, alpha=0.4) + + ax = axes[1] + for sid in full_life: + ax.plot(oil_w.index, oil_w[sid], color="#999", linewidth=0.6, alpha=0.5) + ax.plot(oil_w.index, oil_w.mean(axis=1), color="#1a9850", linewidth=2, + label="mean oil") + ax.axhline(cfg.o_max, color="k", linestyle=":", linewidth=1, label="o_max") + ax.set_title("Per-seller oil inventory over time", fontsize=11, fontweight="bold") + ax.set_xlabel("Tick"); ax.set_ylabel("Oil stock") + ax.legend(fontsize=8); ax.grid(True, alpha=0.4) + + fig.tight_layout() + images_dir = Path(__file__).resolve().parent.parent / "images" + images_dir.mkdir(exist_ok=True) + out = images_dir / f"seller_diag_{args.scenario}.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"\nSaved figure to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/experiment_parameters.py b/scripts/experiment_parameters.py new file mode 100644 index 0000000..40d987c --- /dev/null +++ b/scripts/experiment_parameters.py @@ -0,0 +1,135 @@ +""" +Parameter experiments for the Spatial Market Lock-In ABM. + +Drives the tested run-summary ruler in ``spatial_market_lockin.metrics`` +(``summarize_run``) instead of recomputing diagnostics inline, so the numbers +here match what the model itself reports and what the gate test asserts. + +HOW TO USE +---------- + uv run python scripts/experiment_parameters.py + +Edit the experiments / sweep in the __main__ block below. + +WHAT WE ARE LOOKING FOR RIGHT NOW (the "can-switch" baseline) +------------------------------------------------------------- +Before Bayesian beliefs exist, we want a STABLE substrate regime where +buyers can physically reach and transact with more than one seller -- i.e. +``switch_rate`` is clearly non-zero under the current (no-belief) purchase +rule. That establishes that switching is possible; beliefs + risk/loss +aversion are added later to pull switching down toward a realistic lock-in +regime. Target band for this step: + + * never_transacted : < ~0.10 (buyers actually reach a seller) + * avg_lifespan : long enough for several purchases + * switch_rate : clearly > 0 (even 0.2-0.5 is fine here) + * price_cv : > 0 and bounded (a *reason* to switch exists) + +A run at full default scale (50x50, 200 buyers, 200 ticks) takes ~10-20s. +Use smaller steps/buyers while exploring, then re-confirm at full scale. +""" + +from __future__ import annotations + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import scenario_names, scenario_overrides + +# Columns pulled from summarize_run, in display order. +_COLUMNS = ( + ("switch_rate", "switch"), + ("never_transacted", "never_tx"), + ("revealed_lock_in", "locked=1"), + ("avg_lifespan", "lifespan"), + ("avg_transaction_price", "txn_price"), + ("price_cv", "price_cv"), + ("price_spread", "spread"), + ("living_sellers", "sellers"), +) + + +def run_one(label: str, *, seed: int = 7, steps: int = 200, **overrides) -> dict: + """Run one simulation and return its summary dict, tagged with ``label``.""" + + config = ModelConfig(seed=seed, steps=steps, **overrides) + model = SpatialMarketModel(config) + model.run() + summary = summarize_run(model) + summary["label"] = label + return summary + + +def _format_row(summary: dict) -> str: + cells = [] + for key, _ in _COLUMNS: + value = summary.get(key, float("nan")) + if key == "living_sellers": + cells.append(f"{int(value):>{7}}") + else: + cells.append(f"{value:>9.3f}") + return " ".join(cells) + f" {summary['label']}" + + +def _header() -> str: + head = " ".join(f"{short:>9}" if key != "living_sellers" else f"{short:>7}" + for key, short in _COLUMNS) + return head + " label" + + +def report(summaries: list[dict], *, sort_by: str | None = "switch_rate") -> None: + """Print a table of run summaries, optionally sorted by a column desc.""" + + if sort_by is not None: + summaries = sorted( + summaries, + key=lambda s: (s.get(sort_by) if s.get(sort_by) == s.get(sort_by) else -1.0), + reverse=True, + ) + print(_header()) + print("-" * len(_header())) + for summary in summaries: + print(_format_row(summary)) + + +# --- FINDING (sweep, 2026-06) ----------------------------------------------- +# Switching is NOT gated by reachability/lifespan. Even near-immortal buyers +# (lifespan ~1500) with 20 sellers and wide sight switch <2% of lives: with +# ~5 sellers visible and >=2 affordable to reach 96% of the time, buyers still +# glue to one seller. Lock-in here is BEHAVIOURAL (the projected-welfare +# movement rule), not physical -- the two README lock-in measures diverge +# hugely (reachability ~0.03 vs revealed ~0.99). +# +# The lever that actually opens switching is PRICING: +# * low a1 keeps oil affordable so buyers transact and survive (high a1 +# prices oil out of reach -> buyers die before trading -> no switching); +# * low/zero a2 keeps seller prices DISPERSED, giving a reason to switch +# (competitor anchoring converges prices -> sellers look alike -> no +# reason to move). +# The README/anna default (a1=1, a2=0.5) is near the WORST regime for +# switching, which is why anna saw ~1 switcher. +# +# Robust can-switch baseline (5 seeds): switch ~0.30 (a2=0.0) / ~0.10 (a2=0.5). +# The baselines themselves live in spatial_market_lockin.scenarios so the CLI +# and this script share one definition. + +if __name__ == "__main__": + results: list[dict] = [] + + # All named scenarios, for side-by-side comparison. + for name in scenario_names(): + results.append(run_one(name, steps=120, **scenario_overrides(name))) + + # Pricing is the real switching lever -- sweep it on the low_lockin + # substrate (everything except the a1/a2 weights it sets). + substrate = { + k: v + for k, v in scenario_overrides("low_lockin").items() + if k not in ("a1", "a2") + } + for a1 in (0.3, 1.0, 3.0): + for a2 in (0.0, 0.5): + results.append( + run_one(f"a1={a1} a2={a2}", steps=120, a1=a1, a2=a2, **substrate) + ) + + report(results, sort_by="switch_rate") diff --git a/scripts/overlay_correlation.py b/scripts/overlay_correlation.py new file mode 100644 index 0000000..b147aae --- /dev/null +++ b/scripts/overlay_correlation.py @@ -0,0 +1,113 @@ +"""Overlay key per-tick metrics for one scenario and log their correlations. + +Overlays (min-max normalised onto one axis, so different scales are comparable): +lock-in rate, total environmental money, transactions/tick, price dispersion +(CV), and average transaction price. A second panel shows the pairwise Pearson +correlation matrix of the *raw* series, to surface co-movement (e.g. trading +depleting money). + +Usage: + uv run python scripts/overlay_correlation.py [--scenario moderate_lockin] + [--steps 1500] [--seed 42] [--burn-in 100] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.scenarios import scenario_overrides + +# (DataCollector column, legend label, colour) +SERIES = [ + ("lock_in_rate", "Instantaneous lock-in rate", "#762a83"), + ("total_money", "Total environmental money", "#1a9850"), + ("num_transactions", "Transactions / tick", "#2166ac"), + ("price_cv", "Price dispersion (CV)", "#f1a340"), + ("avg_transaction_price", "Avg transaction price", "#d6604d"), +] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", default="moderate_lockin") + parser.add_argument("--steps", type=int, default=1500) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--burn-in", type=int, default=100, + help="ticks dropped before correlating (oscillation transient)") + args = parser.parse_args() + + model = SpatialMarketModel( + ModelConfig(seed=args.seed, steps=args.steps, **scenario_overrides(args.scenario)) + ) + model.run() + df = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) + df["tick"] = range(len(df)) + + cols = [c for c, _, _ in SERIES] + labels = {c: lab for c, lab, _ in SERIES} + colors = {c: col for c, _, col in SERIES} + + # Correlate on the post-burn-in window (drop the startup transient). + corr = df.loc[df["tick"] >= args.burn_in, cols].corr() + print(f"\nPearson correlations ({args.scenario}, ticks >= {args.burn_in}):\n") + print(corr.round(2).to_string()) + + fig, (ax_ts, ax_cor) = plt.subplots( + 2, 1, figsize=(13, 11), gridspec_kw={"height_ratios": [2.0, 1.4]} + ) + + # --- top: min-max normalised overlay --- + for col, label, color in SERIES: + s = df[col].astype(float) + lo, hi = s.min(), s.max() + norm = (s - lo) / (hi - lo) if hi > lo else s * 0 + ax_ts.plot(df["tick"], norm, color=color, linewidth=1.3, alpha=0.85, + label=f"{label} [{lo:.2g}–{hi:.2g}]") + ax_ts.axvspan(0, args.burn_in, color="grey", alpha=0.08) + ax_ts.set_title( + f"{args.scenario}: key metrics (min-max normalised, shared axis) — " + f"{args.steps} ticks, seed={args.seed}", + fontsize=12, fontweight="bold", + ) + ax_ts.set_xlabel("Tick") + ax_ts.set_ylabel("Normalised value [0–1]") + ax_ts.legend(fontsize=9, loc="upper right", framealpha=0.85, + title="metric [raw min–max]") + ax_ts.grid(True, alpha=0.4) + + # --- bottom: correlation heatmap --- + M = corr.to_numpy() + im = ax_cor.imshow(M, cmap="RdBu_r", vmin=-1, vmax=1) + short = [labels[c].replace(" ", "\n") for c in cols] + ax_cor.set_xticks(range(len(cols)), short, fontsize=8) + ax_cor.set_yticks(range(len(cols)), short, fontsize=8) + for i in range(len(cols)): + for j in range(len(cols)): + ax_cor.text(j, i, f"{M[i, j]:.2f}", ha="center", va="center", + color="white" if abs(M[i, j]) > 0.55 else "black", + fontsize=9, fontweight="bold") + ax_cor.set_title(f"Pearson correlation (ticks ≥ {args.burn_in})", + fontsize=11, fontweight="bold") + fig.colorbar(im, ax=ax_cor, fraction=0.046, pad=0.04) + + fig.tight_layout() + images_dir = Path(__file__).resolve().parent.parent / "images" + images_dir.mkdir(exist_ok=True) + out = images_dir / f"overlay_correlation_{args.scenario}.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"\nSaved figure to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_model.py b/scripts/run_model.py index 45bdb7d..fe9c66d 100644 --- a/scripts/run_model.py +++ b/scripts/run_model.py @@ -1,14 +1,76 @@ -"""Placeholder command for the rebuild.""" +"""Single-run CLI: run the model and export CSV/JSON artifacts.""" from __future__ import annotations +import argparse +from pathlib import Path -def main() -> None: - """Explain the current rebuild state.""" +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.export import export_run +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import ( + DEFAULT_SCENARIO, + scenario_names, + scenario_overrides, +) - raise SystemExit( - "The active implementation is at rebuild Step 1: configuration only. " - "The runnable model will be added in later steps." +# CLI flags that map directly onto ModelConfig fields. Only those the user +# actually passes are forwarded as overrides, so unset flags keep scenario / +# config defaults rather than clobbering them with None. +_CONFIG_OVERRIDES = ("num_buyers", "num_sellers", "grid_size") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", type=int, default=None, help="random seed") + parser.add_argument("--steps", type=int, default=500, help="number of ticks to run") + parser.add_argument( + "--scenario", + choices=scenario_names(), + default=DEFAULT_SCENARIO, + help="named parameter scenario applied before explicit flags", + ) + parser.add_argument("--num-buyers", type=int, default=None) + parser.add_argument("--num-sellers", type=int, default=None) + parser.add_argument("--grid-size", type=int, default=None) + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="where to write outputs (default: outputs/run_seed__steps_)", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + args = build_parser().parse_args(argv) + + # Scenario sets the base overrides; explicit flags win over the scenario. + overrides = scenario_overrides(args.scenario) + overrides.update( + { + name: getattr(args, name) + for name in _CONFIG_OVERRIDES + if getattr(args, name) is not None + } + ) + config = ModelConfig(seed=args.seed, steps=args.steps, **overrides) + + model = SpatialMarketModel(config) + model.run() + + output_dir = args.output_dir or ( + Path("outputs") / f"run_seed_{args.seed}_steps_{args.steps}" + ) + export_run(model, output_dir, extra_metadata={"scenario": args.scenario}) + + summary = summarize_run(model) + print(f"Run complete [{args.scenario}]: {model.tick} ticks -> {output_dir}") + print( + f" switch_rate={summary['switch_rate']:.3f} | " + f"never_transacted={summary['never_transacted']:.3f} | " + f"avg_transaction_price={summary['avg_transaction_price']:.3f} | " + f"price_cv={summary['price_cv']:.3f}" ) diff --git a/scripts/sweep_a2.py b/scripts/sweep_a2.py new file mode 100644 index 0000000..4ccab7a --- /dev/null +++ b/scripts/sweep_a2.py @@ -0,0 +1,108 @@ +"""Sweep a2 (competitor anchoring) on the low_lockin substrate, across seeds. + +Question: does a2 still move switch_rate? a2 converges seller prices, which is +meant to remove the price gap that motivates switching. If switch_rate is flat +across a2, some other mechanism (e.g. the loss-aversion gate) is overriding the +price signal and a2 -- SA priority #1 -- is effectively dead. + +Holds the low_lockin substrate fixed (it sets a2=0); we override a2 only. + +Usage: + uv run python scripts/sweep_a2.py [--steps 150] [--seeds 5] +""" + +from __future__ import annotations + +import argparse +import statistics +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import scenario_overrides + +A2_VALUES = [0.0, 0.25, 0.5, 0.75, 0.9] +REPORT_KEYS = [ + ("switch_rate", "switch"), + ("switch_rate_traders", "switch_tr"), + ("revealed_lock_in", "locked=1"), + ("never_transacted", "never_tx"), + ("price_cv", "price_cv"), + ("avg_lifespan", "lifespan"), +] + +# Substrate without a2 (we sweep that); low_lockin == substrate + a2=0. +SUBSTRATE = {k: v for k, v in scenario_overrides("low_lockin").items() if k != "a2"} + + +def run(a2: float, seed: int, steps: int) -> dict: + model = SpatialMarketModel( + ModelConfig(seed=seed, steps=steps, a2=a2, **SUBSTRATE) + ) + model.run() + return summarize_run(model) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", type=int, default=150) + parser.add_argument("--seeds", type=int, default=5) + args = parser.parse_args() + seeds = list(range(1, args.seeds + 1)) + + print(f"Sweeping a2={A2_VALUES} x seeds={seeds} x {args.steps} ticks " + f"({len(A2_VALUES) * len(seeds)} runs)...\n") + + # per_a2[a2][key] = list of values across seeds + per_a2: dict[float, dict[str, list[float]]] = {} + for a2 in A2_VALUES: + rows = [run(a2, s, args.steps) for s in seeds] + per_a2[a2] = {key: [r[key] for r in rows] for key, _ in REPORT_KEYS} + print(f" a2={a2}: done") + + # --- table (mean across seeds, with std for switch_rate) --- + header = f"{'a2':>6}" + "".join(f"{short:>11}" for _, short in REPORT_KEYS) + print("\n" + "=" * len(header)) + print(header) + print("-" * len(header)) + for a2 in A2_VALUES: + cells = "".join( + f"{statistics.mean(per_a2[a2][key]):>11.4f}" for key, _ in REPORT_KEYS + ) + print(f"{a2:>6}{cells}") + print("=" * len(header)) + sw0 = per_a2[A2_VALUES[0]]["switch_rate"] + print(f"\nswitch_rate at a2=0.0: mean={statistics.mean(sw0):.4f} " + f"std={statistics.pstdev(sw0):.4f} (n={len(seeds)})") + + # --- plot: switch_rate vs a2, seed points + mean line --- + fig, ax = plt.subplots(figsize=(8, 5)) + for a2 in A2_VALUES: + ax.scatter([a2] * len(seeds), per_a2[a2]["switch_rate"], + color="#2166ac", alpha=0.5, s=30, zorder=3) + means = [statistics.mean(per_a2[a2]["switch_rate"]) for a2 in A2_VALUES] + ax.plot(A2_VALUES, means, color="#d6604d", linewidth=2, + marker="o", label="mean across seeds", zorder=4) + ax.set_xlabel("a2 (competitor anchoring weight)") + ax.set_ylabel("switch_rate") + ax.set_title(f"Does a2 move switching? low_lockin substrate, " + f"{args.steps} ticks, {len(seeds)} seeds") + ax.grid(True, alpha=0.4) + ax.legend() + images_dir = Path(__file__).resolve().parent.parent / "images" + images_dir.mkdir(exist_ok=True) + out = images_dir / "sweep_a2.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"Saved figure to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/visualize_metrics.py b/scripts/visualize_metrics.py new file mode 100644 index 0000000..8ff1f25 --- /dev/null +++ b/scripts/visualize_metrics.py @@ -0,0 +1,163 @@ +"""Run two side-by-side simulations and plot all tracked metrics. + +Baseline: moderate_lockin scenario (a2=0.5), lock-in premium off (a4=0). +Treatment: same scenario, lock-in premium on (a4=1.0, K=3). + +Usage: + python scripts/visualize_metrics.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.gridspec as gridspec +import numpy as np +import pandas as pd + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import lifetime_counts, summarize_run +from spatial_market_lockin.scenarios import scenario_overrides + +STEPS = 300 +SEED = 42 + +PARTIAL_LOCKIN = scenario_overrides("moderate_lockin") + +configs = { + "No lock-in premium (a4=0)": ModelConfig( + seed=SEED, steps=STEPS, + **{**PARTIAL_LOCKIN, "a4": 0.0}, + ), + "Lock-in premium (a4=1.0, K=3)": ModelConfig( + seed=SEED, steps=STEPS, + **{**PARTIAL_LOCKIN, "a4": 1.0, "loyalty_threshold_k": 3}, + ), +} + +COLORS = ["#2166ac", "#d6604d"] + + +def run(config: ModelConfig) -> SpatialMarketModel: + model = SpatialMarketModel(config) + model.run() + return model + + +print("Running simulations...") +models: dict[str, SpatialMarketModel] = {} +for label, cfg in configs.items(): + print(f" {label} ...") + models[label] = run(cfg) +print("Done.\n") + +# --- extract time series --- + +def ts(model: SpatialMarketModel) -> pd.DataFrame: + df = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) + df["tick"] = range(len(df)) + return df + +frames = {label: ts(m) for label, m in models.items()} + +# --- summary stats --- + +print("=" * 60) +print(f"{'Metric':<30} {'Baseline':>18} {'Lock-in premium':>18}") +print("-" * 60) +summaries = {label: summarize_run(m) for label, m in models.items()} +keys_to_show = [ + ("switch_rate", "Switch rate"), + ("switch_rate_traders", "Switch rate (traders)"), + ("revealed_lock_in", "Revealed lock-in"), + ("revealed_suboptimal_rate", "Revealed suboptimal"), + ("streak_suboptimal_rate", "Streak suboptimal"), + ("never_transacted", "Never transacted"), + ("avg_lifespan", "Avg buyer lifespan (ticks)"), + ("avg_posted_price", "Avg posted price"), + ("avg_transaction_price", "Avg transaction price"), + ("price_cv", "Price CV"), + ("price_spread", "Price spread (max/min)"), + ("avg_lock_in_rate", "Avg lock-in rate"), + ("completed_lives", "Completed buyer lives"), +] +labels = list(configs.keys()) +for key, display in keys_to_show: + v0 = summaries[labels[0]].get(key, float("nan")) + v1 = summaries[labels[1]].get(key, float("nan")) + print(f" {display:<28} {v0:>18.4f} {v1:>18.4f}") +print("=" * 60) + +# --- plot --- + +fig = plt.figure(figsize=(18, 28)) +fig.suptitle( + f"Spatial Market Lock-In ABM | moderate_lockin scenario | {STEPS} ticks | seed={SEED}", + fontsize=13, fontweight="bold", y=0.99, +) + +gs = gridspec.GridSpec(6, 2, figure=fig, hspace=0.55, wspace=0.35) + +# Time series panels +ts_panels = [ + (0, 0, "avg_posted_price", "Avg posted price", "Price"), + (0, 1, "price_cv", "Price dispersion (CV)", "CV"), + (1, 0, "lock_in_rate", "Instantaneous lock-in rate", "Fraction"), + (1, 1, "suboptimal_lock_in_rate", "Suboptimal lock-in rate", "Fraction"), + (2, 0, "living_buyers", "Living buyers", "Count"), + (2, 1, "living_sellers", "Living sellers", "Count"), + (3, 0, "num_transactions", "Transactions per tick", "Count"), + (3, 1, "avg_transaction_price", "Avg transaction price", "Price"), + (4, 0, "step_buyer_deaths", "Buyer deaths per tick", "Count"), + (4, 1, "cumulative_seller_deaths","Cumulative seller deaths", "Count"), +] + +for row, col, col_name, title, ylabel in ts_panels: + ax = fig.add_subplot(gs[row, col]) + for i, (label, df) in enumerate(frames.items()): + if col_name in df.columns: + ax.plot(df["tick"], df[col_name], label=label, color=COLORS[i], linewidth=1.2, alpha=0.85) + ax.set_title(title, fontsize=10, fontweight="bold") + ax.set_xlabel("Tick", fontsize=8) + ax.set_ylabel(ylabel, fontsize=8) + ax.tick_params(labelsize=7) + ax.legend(fontsize=7, framealpha=0.6) + ax.grid(True, linewidth=0.4, alpha=0.5) + +# Row 5: welfare metric bar charts (lifetime metrics, not per-tick) +welfare_metrics = [ + (5, 0, "revealed_suboptimal_rate", "Revealed-suboptimal rate\n(≤2 sellers, believed cheaper exists)"), + (5, 1, "streak_suboptimal_rate", "Streak-suboptimal rate\n(streak ≥ k, believed cheaper exists)"), +] + +for row, col, key, title in welfare_metrics: + ax = fig.add_subplot(gs[row, col]) + values = [summaries[label].get(key, float("nan")) for label in labels] + bars = ax.bar(labels, values, color=COLORS, alpha=0.85, width=0.5) + for bar, val in zip(bars, values): + if not np.isnan(val): + ax.text( + bar.get_x() + bar.get_width() / 2, + bar.get_height() + 0.005, + f"{val:.1%}", + ha="center", va="bottom", fontsize=8, fontweight="bold", + ) + ax.set_title(title, fontsize=10, fontweight="bold") + ax.set_ylabel("Fraction of buyer lives", fontsize=8) + ax.set_ylim(0, max(v for v in values if not np.isnan(v)) * 1.25 + 0.01) + ax.tick_params(axis="x", labelsize=7) + ax.tick_params(axis="y", labelsize=7) + ax.grid(True, axis="y", linewidth=0.4, alpha=0.5) + +images_dir = Path(__file__).resolve().parent.parent / "images" +images_dir.mkdir(exist_ok=True) +out = images_dir / "metrics_output.png" +fig.savefig(out, dpi=130, bbox_inches="tight") +print(f"\nSaved chart to {out}") +plt.close(fig) diff --git a/src/spatial_market_lockin/agents.py b/src/spatial_market_lockin/agents.py index c8d6ea6..6327c0c 100644 --- a/src/spatial_market_lockin/agents.py +++ b/src/spatial_market_lockin/agents.py @@ -10,16 +10,19 @@ if TYPE_CHECKING: from spatial_market_lockin.model import SpatialMarketModel - class Seller(CellAgent): """Fixed seller with money, oil, and a posted price.""" - def __init__(self, model: SpatialMarketModel) -> None: + def __init__(self, model: SpatialMarketModel, generation: int = 0) -> None: super().__init__(model) + self.generation = generation self.money = model.config.seller_initial_money self.oil = model.config.seller_initial_oil self.posted_price = model.config.p_floor self.alive = True + # Maps buyer unique_id -> consecutive purchase count from this seller. + # Used by the lock-in premium (a4 * Y_s term in pricing). + self.loyalty_counts: dict[int, int] = {} @property def coordinate(self) -> tuple[int, int]: @@ -29,6 +32,12 @@ def coordinate(self) -> tuple[int, int]: raise RuntimeError("seller is not placed on the grid") return self.cell.coordinate + def remove_from_market(self) -> None: + """Mark this seller dead and remove it from active Mesa state.""" + + self.alive = False + self.remove() + class Buyer(Grid2DMovingAgent): """Mobile buyer with money and oil.""" @@ -43,6 +52,17 @@ def __init__(self, model: SpatialMarketModel, generation: int = 0) -> None: self.last_harvested_money = 0.0 self.last_purchased_oil = 0.0 self.last_purchase_cost = 0.0 + # Total completed purchases this life. Pairs with lifetime_sellers to + # separate behavioural lock-in from lack-of-opportunity: a 1-purchase + # life is pinned to one seller structurally, not by choice. + self.purchase_count = 0 + self.lifetime_sellers: set = set() + # The seller this buyer last transacted with; used to detect switches + # so loyalty_counts can be reset on the old seller (pricing §a4 term). + self.last_seller: "Seller | None" = None + # Per-seller EMA beliefs, keyed by seller id (float: believed price). + # Absent = never visited; prior_price_mean is used as the cold start. + self.beliefs: dict[int, float] = {} @property def coordinate(self) -> tuple[int, int]: @@ -52,6 +72,21 @@ def coordinate(self) -> tuple[int, int]: raise RuntimeError("buyer is not placed on the grid") return self.cell.coordinate + def believed_price(self, seller: "Seller") -> float: + """Return the buyer's EMA believed price for ``seller``. + + Unvisited seller returns prior_price_mean as the cold-start default. + """ + + return self.beliefs.get(seller.unique_id, self.model.config.prior_price_mean) + + def observe_price(self, seller: "Seller", price: float) -> None: + """EMA update: belief = (1-alpha)*old + alpha*observed_price.""" + + alpha = self.model.config.belief_update_weight + old = self.beliefs.get(seller.unique_id, self.model.config.prior_price_mean) + self.beliefs[seller.unique_id] = (1 - alpha) * old + alpha * price + @property def money_alpha(self) -> float: """Return the Cobb-Douglas money exponent.""" diff --git a/src/spatial_market_lockin/beliefs.py b/src/spatial_market_lockin/beliefs.py deleted file mode 100644 index f663781..0000000 --- a/src/spatial_market_lockin/beliefs.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Price-belief placeholder. - -Log-normal seller price beliefs will be rebuilt after basic trade works. -""" diff --git a/src/spatial_market_lockin/config.py b/src/spatial_market_lockin/config.py index 7a09e6f..e95d5da 100644 --- a/src/spatial_market_lockin/config.py +++ b/src/spatial_market_lockin/config.py @@ -38,6 +38,24 @@ class ModelConfig: respawn_sellers: bool = True + # Dynamic seller pricing: p_s(t) = p_floor * (1 + a1*D_s + a2*C_s + a3*L_s + a4*Y_s) + a1: float = 1.0 # captive-demand weight [HEURISTIC] + a2: float = 0.5 # competitor-anchoring weight [HEURISTIC] + a3: float = 1.0 # liquidity-pressure weight [HEURISTIC] + a4: float = 0.0 # loyalty-markup weight; 0 = feature off [HEURISTIC] + loyalty_threshold_k: int = 3 # consecutive purchases required to count as loyal + competitor_anchoring_length_scale: float = 10.0 # ell: kernel decay distance [HEURISTIC] - SHOULD depend on grid size and visual range + solvency_horizon_ticks: float = 20.0 # T_h: ticks of metabolism a seller must be able to cover + + # EMA price beliefs: unvisited sellers start at prior_price_mean; each + # visit updates: belief = (1-alpha)*belief + alpha*observed_price. + prior_price_mean: float = 2.0 # cold-start belief for unvisited sellers [HEURISTIC] + belief_update_weight: float = 0.3 # alpha: EMA step size, must be in (0, 1] [HEURISTIC] + + # Loss aversion (README §4.2): overpayment above the believed reference + # price is weighted lambda-fold in the buy decision (lambda >= 1). + loss_aversion: float = 2.25 # lambda [HEURISTIC] + def validate(self) -> None: """Raise ``ValueError`` when a parameter cannot support a run.""" @@ -86,3 +104,30 @@ def validate(self) -> None: raise ValueError("o_max must be positive") if self.p_floor <= 0: raise ValueError("p_floor must be positive") + + if self.a1 < 0: + raise ValueError("a1 must be non-negative") + if self.a2 < 0: + raise ValueError("a2 must be non-negative") + if self.a2 >= 1.0: + # Competitor anchoring is linear feedback on last-tick prices; + # a2 >= 1 amplifies instead of damps each tick's deviation, so + # prices diverge exponentially. This is a stability bound, not a + # taste parameter. + raise ValueError("a2 must be less than 1.0 for price stability") + if self.a3 < 0: + raise ValueError("a3 must be non-negative") + if self.a4 < 0: + raise ValueError("a4 must be non-negative") + if self.loyalty_threshold_k < 1: + raise ValueError("loyalty_threshold_k must be at least 1") + if self.competitor_anchoring_length_scale <= 0: + raise ValueError("competitor_anchoring_length_scale must be positive") + if self.solvency_horizon_ticks <= 0: + raise ValueError("solvency_horizon_ticks must be positive") + if self.prior_price_mean <= 0: + raise ValueError("prior_price_mean must be positive") + if not 0 < self.belief_update_weight <= 1: + raise ValueError("belief_update_weight must be in (0, 1]") + if self.loss_aversion < 1: + raise ValueError("loss_aversion must be at least 1") diff --git a/src/spatial_market_lockin/export.py b/src/spatial_market_lockin/export.py index db643a0..faf0812 100644 --- a/src/spatial_market_lockin/export.py +++ b/src/spatial_market_lockin/export.py @@ -1,4 +1,100 @@ -"""Export placeholder. +"""Single-run CSV/JSON exports (README §7, roadmap #5). -CSV and JSON exports will be rebuilt after the model produces data. +Writes five artifacts for one finished run into an output directory: + +* ``model_timeseries.csv`` -- the DataCollector model-vars time series. +* ``transactions.csv`` -- the full transaction ledger. +* ``final_buyers.csv`` -- living buyers at the final tick. +* ``final_sellers.csv`` -- living sellers at the final tick. +* ``run_metadata.json`` -- the run config plus the run-summary diagnostics. """ + +from __future__ import annotations + +import json +from dataclasses import asdict, fields +from pathlib import Path +from typing import TYPE_CHECKING + +import pandas as pd + +from .ledger import Transaction +from .metrics import summarize_run + +if TYPE_CHECKING: + from .model import SpatialMarketModel + + +def _transactions_dataframe(model: "SpatialMarketModel") -> pd.DataFrame: + columns = [field.name for field in fields(Transaction)] + rows = [asdict(transaction) for transaction in model.transactions] + return pd.DataFrame(rows, columns=columns) + + +def _final_buyers_dataframe(model: "SpatialMarketModel") -> pd.DataFrame: + columns = ["buyer_id", "generation", "x", "y", "money", "oil", "distinct_sellers"] + rows = [] + for buyer in model.alive_buyers: + x, y = buyer.coordinate + rows.append( + { + "buyer_id": buyer.unique_id, + "generation": buyer.generation, + "x": x, + "y": y, + "money": buyer.money, + "oil": buyer.oil, + "distinct_sellers": len(buyer.lifetime_sellers), + } + ) + return pd.DataFrame(rows, columns=columns) + + +def _final_sellers_dataframe(model: "SpatialMarketModel") -> pd.DataFrame: + columns = ["seller_id", "x", "y", "money", "oil", "posted_price"] + rows = [] + for seller in model.alive_sellers: + x, y = seller.coordinate + rows.append( + { + "seller_id": seller.unique_id, + "x": x, + "y": y, + "money": seller.money, + "oil": seller.oil, + "posted_price": seller.posted_price, + } + ) + return pd.DataFrame(rows, columns=columns) + + +def export_run( + model: "SpatialMarketModel", + output_dir: str | Path, + extra_metadata: dict | None = None, +) -> Path: + """Write all single-run artifacts into ``output_dir`` and return its path. + + ``extra_metadata`` is merged into ``run_metadata.json`` (e.g. the scenario + name) for reproducibility. + """ + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + timeseries = model.datacollector.get_model_vars_dataframe() + timeseries.to_csv(output_dir / "model_timeseries.csv", index_label="step") + + _transactions_dataframe(model).to_csv(output_dir / "transactions.csv", index=False) + _final_buyers_dataframe(model).to_csv(output_dir / "final_buyers.csv", index=False) + _final_sellers_dataframe(model).to_csv(output_dir / "final_sellers.csv", index=False) + + metadata = { + "config": asdict(model.config), + "summary": summarize_run(model), + } + if extra_metadata: + metadata.update(extra_metadata) + (output_dir / "run_metadata.json").write_text(json.dumps(metadata, indent=2)) + + return output_dir diff --git a/src/spatial_market_lockin/ledger.py b/src/spatial_market_lockin/ledger.py new file mode 100644 index 0000000..c069cd0 --- /dev/null +++ b/src/spatial_market_lockin/ledger.py @@ -0,0 +1,27 @@ +"""Transaction ledger (README roadmap #1). + +One immutable record per completed oil purchase. The model appends a +``Transaction`` in ``_buy_oil`` whenever a buyer actually buys oil +(``quantity > 0``); zero-quantity evaluations record nothing. Metrics and +exports read this ledger -- it is the source of truth for average price, +per-seller price, and transaction-derived diagnostics. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Transaction: + """A single completed buyer-seller oil purchase.""" + + tick: int + buyer_id: int + seller_id: int + buyer_generation: int + price: float + quantity: float + cost: float # recorded as price * quantity, not recomputed downstream + x: int # purchase cell (the buyer is on the seller's cell when buying) + y: int diff --git a/src/spatial_market_lockin/lockin.py b/src/spatial_market_lockin/lockin.py new file mode 100644 index 0000000..363f60d --- /dev/null +++ b/src/spatial_market_lockin/lockin.py @@ -0,0 +1,218 @@ +"""Lock-in measurement (README §6). + +All three pieces from the spec live here: + +1. Instantaneous reachability -- at any tick, a buyer is locked in if it + cannot currently afford to travel to more than one seller. +2. Lifetime distinct-seller count -- handled mostly in model.py (each + purchase records the seller on the buyer; on death the final count is + appended to model.lifetime_seller_counts). +3. Suboptimal lock-in -- a buyer is locked in AND it *believes* an + unreachable seller is cheaper than its (single) reachable one. This uses + the price beliefs (§5.3): the comparison is on each buyer's + reference (expected) price, not ground truth, per README ("a cheaper + *believed* seller exists but is unreachable"). It separates welfare- + reducing trapping from harmless attachment to an already-cheap seller. + +Two extensions beyond README §6: + +``is_revealed_suboptimal``: a buyer bought from exactly one seller over its +whole life while *believing* another was cheaper -- the buyer that never +explored at all. + +``is_streak_suboptimal``: a buyer has been with its current seller for +>= loyalty_threshold_k consecutive ticks while believing a cheaper +alternative exists. Catches buyers that explored early but ended up +behaviourally trapped -- the case ``is_revealed_suboptimal`` misses when +lifetime_sellers > 1. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +from .geometry import chebyshev_distance + +if TYPE_CHECKING: + from .agents import Buyer, Seller + from .config import ModelConfig + + +def reachable_sellers( + buyer: "Buyer", + sellers: Sequence["Seller"], + grid_size: int, + torus: bool, + psi_tick: float, + psi_move: float, +) -> list["Seller"]: + """Return every seller the buyer could currently afford to travel to. + + A seller is reachable iff the travel cost (psi_tick + distance*psi_move) + is no more than the buyer's current oil. + """ + + reachable = [] + for seller in sellers: + distance = chebyshev_distance(buyer.coordinate, seller.coordinate, grid_size, torus) + travel_cost = psi_tick + distance * psi_move + if travel_cost <= buyer.oil: + reachable.append(seller) + return reachable + + +def is_locked_in( + buyer: "Buyer", + sellers: Sequence["Seller"], + grid_size: int, + torus: bool, + psi_tick: float, + psi_move: float, +) -> bool: + """A buyer is locked in at this tick if it can reach at most one seller.""" + + reachable = reachable_sellers(buyer, sellers, grid_size, torus, psi_tick, psi_move) + return len(reachable) <= 1 + + +def lock_in_rate( + buyers: Sequence["Buyer"], + sellers: Sequence["Seller"], + config: "ModelConfig", +) -> float: + """Fraction of the given (living) buyers currently locked in. + + Returns NaN when there are no buyers, so it's visibly distinguishable + from a genuine 0.0 lock-in rate rather than silently reading as "no + lock-in at all." + """ + + buyer_list = list(buyers) + seller_list = list(sellers) + if not buyer_list: + return float("nan") + + locked_count = sum( + 1 + for buyer in buyer_list + if is_locked_in( + buyer, + seller_list, + config.grid_size, + config.torus, + config.psi_tick, + config.psi_move, + ) + ) + return locked_count / len(buyer_list) + + +def is_suboptimally_locked_in( + buyer: "Buyer", + sellers: Sequence["Seller"], + grid_size: int, + torus: bool, + psi_tick: float, + psi_move: float, +) -> bool: + """True if the buyer is locked in AND believes a cheaper seller is out of reach. + + The buyer must be locked in (``<= 1`` reachable seller); then it is + suboptimally locked in if some *unreachable* seller has a lower believed + (reference/expected) price than its cheapest reachable seller. A buyer + that can reach no seller at all counts as suboptimal whenever any seller + exists, since every alternative is out of reach. + """ + + reachable = reachable_sellers(buyer, sellers, grid_size, torus, psi_tick, psi_move) + if len(reachable) > 1: + return False # not locked in -- has real choice + + reachable_set = set(reachable) + unreachable = [seller for seller in sellers if seller not in reachable_set] + if not unreachable: + return False + + reachable_best = min( + (buyer.believed_price(seller) for seller in reachable), + default=float("inf"), + ) + cheapest_unreachable = min(buyer.believed_price(seller) for seller in unreachable) + return cheapest_unreachable < reachable_best + + +def suboptimal_lock_in_rate( + buyers: Sequence["Buyer"], + sellers: Sequence["Seller"], + config: "ModelConfig", +) -> float: + """Fraction of living buyers that are suboptimally locked in; NaN if none.""" + + buyer_list = list(buyers) + seller_list = list(sellers) + if not buyer_list: + return float("nan") + + count = sum( + 1 + for buyer in buyer_list + if is_suboptimally_locked_in( + buyer, + seller_list, + config.grid_size, + config.torus, + config.psi_tick, + config.psi_move, + ) + ) + return count / len(buyer_list) + + +def is_streak_suboptimal( + buyer: "Buyer", sellers: Sequence["Seller"], loyalty_threshold_k: int +) -> bool: + """True if the buyer's current streak >= loyalty_threshold_k and it believes + a cheaper seller exists. + + Complements ``is_revealed_suboptimal``: catches buyers who explored early + but ended up behaviourally trapped rather than those who never switched. + """ + + if buyer.last_seller is None: + return False + streak = buyer.last_seller.loyalty_counts.get(buyer.unique_id, 0) + if streak < loyalty_threshold_k: + return False + used_price = buyer.believed_price(buyer.last_seller) + others = [s for s in sellers if s is not buyer.last_seller] + if not others: + return False + return min(buyer.believed_price(s) for s in others) < used_price + + +def is_revealed_suboptimal(buyer: "Buyer", sellers: Sequence["Seller"]) -> bool: + """Behavioural lock-in welfare flag (extension beyond README §6). + + True if the buyer bought from at most two sellers over its life yet + *believes* some other seller is cheaper than the one it used. Covers + both buyers that never switched and those that tried one alternative + before settling. "Believes" uses the buyer's reference (expected) price: + posterior for sellers it visited, prior for those it never did. Reachability + is irrelevant here -- this is the buyer that could have switched but did not. + """ + + used = buyer.lifetime_sellers + if not used or len(used) > 2: + return False + + # Cheapest deal the buyer actually used. Taking the min over the used set + # (rather than an arbitrary ``next(iter(used))``) keeps this deterministic: + # Seller objects hash by identity, so set-iteration order varies between + # process runs and would otherwise flip the comparison for 2-seller lives. + used_price = min(buyer.believed_price(seller) for seller in used) + others = [seller for seller in sellers if seller not in used] + if not others: + return False + + cheapest_other = min(buyer.believed_price(seller) for seller in others) + return cheapest_other < used_price diff --git a/src/spatial_market_lockin/metrics.py b/src/spatial_market_lockin/metrics.py index 1c3fd0c..20fe7e1 100644 --- a/src/spatial_market_lockin/metrics.py +++ b/src/spatial_market_lockin/metrics.py @@ -1,5 +1,258 @@ -"""Metric placeholder. +"""Metrics and run diagnostics (README §7, roadmap #4). -Lock-in, price, survival, and distribution metrics will be rebuilt after the -core simulation loop exists. +Two groups of pure functions: + +* Per-tick metrics feed the DataCollector time series: average posted + price, cross-seller price dispersion (CV), and the average price of the + transactions that completed on the current tick. +* Run-level diagnostics summarize a finished run -- the calibration ruler. + ``switch_rate`` is the headline number: with too little switching, a high + lock-in rate is a measurement artifact (buyers never had the chance to + switch) rather than a genuine result. + +Everything here is read-only. Functions return NaN where a value is +undefined (e.g. price CV with fewer than two sellers) so "no data" is +visibly distinct from a real zero. """ + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Sequence + +from .lockin import is_revealed_suboptimal, is_streak_suboptimal + +if TYPE_CHECKING: + from .agents import Seller + from .ledger import Transaction + from .model import SpatialMarketModel + +NAN = float("nan") + + +# --- per-tick metrics ------------------------------------------------------ + + +def average_posted_price(sellers: Sequence["Seller"]) -> float: + """Mean posted price across the given (living) sellers; NaN if none.""" + + prices = [seller.posted_price for seller in sellers] + if not prices: + return NAN + return sum(prices) / len(prices) + + +def posted_price_cv(sellers: Sequence["Seller"]) -> float: + """Cross-seller price dispersion: population std / mean. + + NaN with fewer than two sellers (dispersion undefined) or a zero mean. + """ + + prices = [seller.posted_price for seller in sellers] + if len(prices) < 2: + return NAN + mean = sum(prices) / len(prices) + if mean == 0: + return NAN + variance = sum((p - mean) ** 2 for p in prices) / len(prices) + return math.sqrt(variance) / mean + + +def mean_transaction_price(transactions: Sequence["Transaction"]) -> float: + """Mean completed-transaction price; NaN when there are no transactions.""" + + prices = [transaction.price for transaction in transactions] + if not prices: + return NAN + return sum(prices) / len(prices) + + +# --- run-level diagnostics (the calibration ruler) ------------------------- + + +def never_transacted_fraction(counts: Sequence[int]) -> float: + """Fraction of buyer lives that never bought from any seller; NaN if empty.""" + + counts = list(counts) + if not counts: + return NAN + return sum(1 for c in counts if c == 0) / len(counts) + + +def revealed_lock_in_fraction(counts: Sequence[int]) -> float: + """Fraction of buyer lives that used exactly one distinct seller; NaN if empty.""" + + counts = list(counts) + if not counts: + return NAN + return sum(1 for c in counts if c == 1) / len(counts) + + +def switch_rate(counts: Sequence[int]) -> float: + """Fraction of buyer lives that used two or more distinct sellers. + + The headline calibration number. NaN if there are no lives to score. + """ + + counts = list(counts) + if not counts: + return NAN + return sum(1 for c in counts if c >= 2) / len(counts) + + +def switch_rate_among_traders(counts: Sequence[int]) -> float: + """Switch rate restricted to lives that traded at least once. + + Excludes never-transacted lives (count 0) from the denominator, so + lock-in is measured over buyers that actually had a choice. NaN if no + life ever traded. + """ + + traders = [c for c in counts if c >= 1] + if not traders: + return NAN + return sum(1 for c in traders if c >= 2) / len(traders) + + +def switch_rate_min_purchases( + seller_counts: Sequence[int], + purchase_counts: Sequence[int], + min_purchases: int, +) -> float: + """Switch rate among lives with at least ``min_purchases`` purchases. + + Conditions switching on *opportunity*: a life that traded fewer than + ``min_purchases`` times had too few transactions to reveal a switching + choice (a 1-purchase life is pinned to one seller structurally), so it is + dropped from the denominator. ``min_purchases=1`` reduces to + ``switch_rate_among_traders``. NaN if no life qualifies. + """ + + eligible = [ + sellers + for sellers, buys in zip(seller_counts, purchase_counts) + if buys >= min_purchases + ] + if not eligible: + return NAN + return sum(1 for d in eligible if d >= 2) / len(eligible) + + +def switching_propensity( + seller_counts: Sequence[int], + purchase_counts: Sequence[int], +) -> float: + """Mean lifespan-neutral switching intensity over lives with >= 2 purchases. + + Per life: ``(distinct_sellers - 1) / (purchases - 1)`` -- the share of + purchase transitions that reached a new seller, in [0, 1] (0 = always the + same seller, 1 = a new seller at every purchase). Distinct sellers <= + purchases, so the ratio never exceeds 1. Averaged over lives that had at + least one switch *opportunity* (>= 2 purchases). NaN if none qualify. + """ + + ratios = [ + (sellers - 1) / (buys - 1) + for sellers, buys in zip(seller_counts, purchase_counts) + if buys >= 2 + ] + if not ratios: + return NAN + return sum(ratios) / len(ratios) + + +def lifetime_counts(model: "SpatialMarketModel", include_alive: bool = True) -> list[int]: + """Distinct-seller counts across buyer lives. + + Combines completed lives (recorded on death) with the in-progress counts + of currently-alive buyers, read non-destructively so a run can continue. + """ + + counts = list(model.lifetime_seller_counts) + if include_alive: + counts.extend(len(buyer.lifetime_sellers) for buyer in model.alive_buyers) + return counts + + +def purchase_counts(model: "SpatialMarketModel", include_alive: bool = True) -> list[int]: + """Purchase counts across buyer lives, index-aligned with ``lifetime_counts``. + + Completed lives come from ``model.lifetime_purchase_counts`` and the alive + extension iterates ``model.alive_buyers`` in the same order as + ``lifetime_counts``, so the two lists pair element-by-element. + """ + + counts = list(model.lifetime_purchase_counts) + if include_alive: + counts.extend(buyer.purchase_count for buyer in model.alive_buyers) + return counts + + +def summarize_run(model: "SpatialMarketModel") -> dict[str, float]: + """One-row summary of a finished run against the target-band diagnostics.""" + + df = model.datacollector.get_model_vars_dataframe() + counts = lifetime_counts(model, include_alive=True) + buys = purchase_counts(model, include_alive=True) + + # Revealed-suboptimal lock-in: completed-life flags plus the in-progress + # flag for currently-alive buyers (read non-destructively). + subopt_flags = list(model.revealed_suboptimal_flags) + subopt_flags.extend( + int(is_revealed_suboptimal(buyer, model.alive_sellers)) + for buyer in model.alive_buyers + ) + revealed_suboptimal_rate = ( + sum(subopt_flags) / len(subopt_flags) if subopt_flags else NAN + ) + + streak_flags = list(model.streak_suboptimal_flags) + streak_flags.extend( + int(is_streak_suboptimal(buyer, model.alive_sellers, model.config.loyalty_threshold_k)) + for buyer in model.alive_buyers + ) + streak_suboptimal_rate = ( + sum(streak_flags) / len(streak_flags) if streak_flags else NAN + ) + + deaths = model.cumulative_buyer_deaths + population = model.config.num_buyers + ticks = model.tick + avg_lifespan = (population * ticks) / deaths if deaths > 0 else float("inf") + + final_prices = [seller.posted_price for seller in model.alive_sellers] + price_spread = ( + max(final_prices) / min(final_prices) + if final_prices and min(final_prices) > 0 + else NAN + ) + + def _mean(column: str) -> float: + return float(df[column].mean()) if column in df else NAN + + return { + "ticks": ticks, + "completed_lives": len(model.lifetime_seller_counts), + "scored_lives": len(counts), + "avg_lifespan": avg_lifespan, + "never_transacted": never_transacted_fraction(counts), + "revealed_lock_in": revealed_lock_in_fraction(counts), + "switch_rate": switch_rate(counts), + "switch_rate_traders": switch_rate_among_traders(counts), + # Purchase-conditioned companions to the unweighted per-life switch_rate: + # remove the "no opportunity" floor (lives with too few trades to switch) + # and a lifespan-neutral per-opportunity intensity. + "switch_rate_min2_purchases": switch_rate_min_purchases(counts, buys, 2), + "switch_rate_min3_purchases": switch_rate_min_purchases(counts, buys, 3), + "switching_propensity": switching_propensity(counts, buys), + "revealed_suboptimal_rate": revealed_suboptimal_rate, + "streak_suboptimal_rate": streak_suboptimal_rate, + "avg_posted_price": _mean("avg_posted_price"), + "avg_transaction_price": _mean("avg_transaction_price"), + "price_cv": _mean("price_cv"), + "price_spread": price_spread, + "avg_lock_in_rate": _mean("lock_in_rate"), + "avg_suboptimal_lock_in_rate": _mean("suboptimal_lock_in_rate"), + "living_buyers": len(model.alive_buyers), + "living_sellers": len(model.alive_sellers), + } diff --git a/src/spatial_market_lockin/model.py b/src/spatial_market_lockin/model.py index ee11e6c..ec74fcb 100644 --- a/src/spatial_market_lockin/model.py +++ b/src/spatial_market_lockin/model.py @@ -11,6 +11,19 @@ from spatial_market_lockin.agents import Buyer, Seller from spatial_market_lockin.config import ModelConfig from spatial_market_lockin.geometry import chebyshev_distance +from spatial_market_lockin.ledger import Transaction +from spatial_market_lockin.lockin import ( + is_revealed_suboptimal, + is_streak_suboptimal, + lock_in_rate, + suboptimal_lock_in_rate, +) +from spatial_market_lockin.metrics import ( + average_posted_price, + mean_transaction_price, + posted_price_cv, +) +from spatial_market_lockin.pricing import update_seller_prices class SpatialMarketModel(Model): @@ -45,6 +58,24 @@ def __init__(self, config: ModelConfig | None = None, **kwargs: Any) -> None: self.step_buyer_respawns = 0 self.cumulative_buyer_deaths = 0 self.cumulative_buyer_respawns = 0 + self.step_seller_deaths = 0 + self.cumulative_seller_deaths = 0 + self.lifetime_seller_counts: list[int] = [] + # Purchases per completed life, index-aligned with lifetime_seller_counts. + # Lets switching be conditioned on opportunity (purchase count) rather + # than measured per-life regardless of how many times the buyer traded. + self.lifetime_purchase_counts: list[int] = [] + # Behavioural welfare flag per completed life (extension beyond §6): + # was the buyer revealed-suboptimally locked in (loyal to one seller + # while believing another was cheaper)? + self.revealed_suboptimal_flags: list[int] = [] + # Streak-based welfare flag: locked in after exploring (streak >= k, + # believes cheaper seller exists). Complements revealed_suboptimal. + self.streak_suboptimal_flags: list[int] = [] + + self.tick = 0 + self.transactions: list[Transaction] = [] + self.step_transactions: list[Transaction] = [] self.datacollector = DataCollector( model_reporters={ @@ -59,7 +90,38 @@ def __init__(self, config: ModelConfig | None = None, **kwargs: Any) -> None: "cumulative_buyer_respawns": lambda model: ( model.cumulative_buyer_respawns ), - } + "lock_in_rate": lambda model: lock_in_rate( + model.alive_buyers, + model.alive_sellers, + model.config, + ), + "suboptimal_lock_in_rate": lambda model: suboptimal_lock_in_rate( + model.alive_buyers, + model.alive_sellers, + model.config, + ), + "avg_posted_price": lambda model: average_posted_price( + model.alive_sellers + ), + "price_cv": lambda model: posted_price_cv(model.alive_sellers), + "avg_transaction_price": lambda model: mean_transaction_price( + model.step_transactions + ), + "num_transactions": lambda model: len(model.step_transactions), + "step_seller_deaths": lambda model: model.step_seller_deaths, + "cumulative_seller_deaths": lambda model: model.cumulative_seller_deaths, + }, + # Per-seller series (price/oil/money) so individual seller + # trajectories and cross-seller dispersion are recoverable, not just + # the aggregate avg_posted_price / price_cv. Retrieve with + # model.datacollector.get_agenttype_vars_dataframe(Seller). + agenttype_reporters={ + Seller: { + "posted_price": "posted_price", + "oil": "oil", + "money": "money", + } + }, ) self.datacollector.collect(self) @@ -102,15 +164,21 @@ def total_money(self) -> float: return float(np.sum(self.money_layer.data)) def step(self) -> None: - """Advance one tick of basic movement, harvesting, and regrowth.""" + """Advance one tick: prices, movement/harvest, metabolism, regrowth.""" + self.tick += 1 self.step_buyer_deaths = 0 self.step_buyer_respawns = 0 + self.step_seller_deaths = 0 + self.step_transactions = [] + + update_seller_prices(self) for buyer in list(self.alive_buyers): self._move_and_harvest(buyer) if buyer.oil <= 0: self._respawn_buyer(buyer) + self._apply_seller_metabolism() self._regrow_seller_oil() self._regrow_money() self.datacollector.collect(self) @@ -175,7 +243,11 @@ def _move_and_harvest(self, buyer: Buyer) -> None: buyer.oil -= self._movement_oil_cost(distance) seller = self.seller_at(destination) if seller is not None: - self._buy_oil(buyer, seller) + # Capture pre-visit belief before the EMA update so loss aversion + # compares against what the buyer expected before arriving. + pre_visit_belief = buyer.believed_price(seller) + buyer.observe_price(seller, seller.posted_price) + self._buy_oil(buyer, seller, pre_visit_belief) def _choose_buyer_destination(self, buyer: Buyer) -> tuple[int, int]: """Choose the visible cell with highest projected welfare.""" @@ -224,13 +296,15 @@ def _projected_buyer_welfare( projected_oil = buyer.oil - self._movement_oil_cost(distance) seller = self.seller_at(coordinate) if seller is not None: + price = buyer.believed_price(seller) quantity = self._optimal_oil_purchase_quantity( buyer=buyer, money=projected_money, oil=projected_oil, - seller=seller, + price=price, + available_oil=seller.oil, ) - projected_money -= quantity * seller.posted_price + projected_money -= quantity * price projected_oil += quantity return buyer.welfare(money=projected_money, oil=projected_oil) @@ -245,6 +319,39 @@ def _regrow_seller_oil(self) -> None: for seller in self.alive_sellers: seller.oil = min(self.config.o_max, seller.oil + self.config.r_o) + def _apply_seller_metabolism(self) -> None: + """Deduct seller_money_metabolism from every living seller's money. + + Sellers that reach zero money die. If respawn_sellers is True, a fresh + seller immediately replaces them at the same grid cell (same position, + new generation, full initial endowments). If False, the cell empties + and the seller count shrinks — use this to study seller exit dynamics. + """ + + for seller in list(self.alive_sellers): + seller.money = max(0.0, seller.money - self.config.seller_money_metabolism) + if seller.money <= 0: + self._handle_seller_death(seller) + + def _handle_seller_death(self, seller: Seller) -> None: + """Remove a bankrupt seller and optionally spawn a replacement.""" + + # Clear loyalty refs so buyers that pointed to this seller don't hold + # a stale last_seller once it's gone from the market. + for buyer in self.alive_buyers: + if buyer.last_seller is seller: + buyer.last_seller = None + + generation = seller.generation + 1 + seller.remove_from_market() + self.step_seller_deaths += 1 + self.cumulative_seller_deaths += 1 + + if self.config.respawn_sellers: + position = self.random.choice(self._all_coordinates()) + replacement = self._new_seller(position=position, generation=generation) + self.sellers.append(replacement) + def seller_at(self, coordinate: tuple[int, int]) -> Seller | None: """Return a living seller at ``coordinate``, if one exists.""" @@ -253,19 +360,35 @@ def seller_at(self, coordinate: tuple[int, int]) -> Seller | None: return agent return None - def _buy_oil(self, buyer: Buyer, seller: Seller) -> None: - """Execute a fixed-price welfare-improving oil purchase.""" + def _buy_oil(self, buyer: Buyer, seller: Seller, reference_price: float) -> None: + """Execute a welfare-improving oil purchase with loss-averse gating. + + The buyer pays the true posted ``price``, but DECIDES quantity using a + loss-shaded price: any amount the posted price exceeds the buyer's + reference (expected) price is weighted by ``loss_aversion`` (README + §4.2). This implicitly enforces the §5.2 follower gate (buy iff the + loss-shaded price is below the buyer's reservation price) and makes a + pricier-than-expected seller less attractive than the known one. + """ + + price = seller.posted_price + if price > reference_price: + decision_price = reference_price + self.config.loss_aversion * ( + price - reference_price + ) + else: + decision_price = price quantity = self._optimal_oil_purchase_quantity( buyer=buyer, money=buyer.money, oil=buyer.oil, - seller=seller, + price=decision_price, + available_oil=seller.oil, ) if quantity <= 0: return - price = seller.posted_price total = quantity * price buyer.money -= total buyer.oil += quantity @@ -273,11 +396,48 @@ def _buy_oil(self, buyer: Buyer, seller: Seller) -> None: seller.oil -= quantity buyer.last_purchased_oil = quantity buyer.last_purchase_cost = total + buyer.purchase_count += 1 + buyer.lifetime_sellers.add(seller) + + x, y = seller.coordinate + transaction = Transaction( + tick=self.tick, + buyer_id=buyer.unique_id, + seller_id=seller.unique_id, + buyer_generation=buyer.generation, + price=price, + quantity=quantity, + cost=total, + x=x, + y=y, + ) + self.transactions.append(transaction) + self.step_transactions.append(transaction) + + # Loyalty tracking: reset streak on old seller when buyer switches, + # then increment streak on current seller. + if buyer.last_seller is not None and buyer.last_seller is not seller: + buyer.last_seller.loyalty_counts.pop(buyer.unique_id, None) + seller.loyalty_counts[buyer.unique_id] = ( + seller.loyalty_counts.get(buyer.unique_id, 0) + 1 + ) + buyer.last_seller = seller def _respawn_buyer(self, buyer: Buyer) -> Buyer: """Replace a dead buyer with a fresh buyer at a random cell.""" + if buyer.last_seller is not None: + buyer.last_seller.loyalty_counts.pop(buyer.unique_id, None) + generation = buyer.generation + 1 + self.lifetime_seller_counts.append(len(buyer.lifetime_sellers)) + self.lifetime_purchase_counts.append(buyer.purchase_count) + self.revealed_suboptimal_flags.append( + int(is_revealed_suboptimal(buyer, self.alive_sellers)) + ) + self.streak_suboptimal_flags.append( + int(is_streak_suboptimal(buyer, self.alive_sellers, self.config.loyalty_threshold_k)) + ) buyer.remove_from_market() self.step_buyer_deaths += 1 self.step_buyer_respawns += 1 @@ -293,19 +453,23 @@ def _optimal_oil_purchase_quantity( buyer: Buyer, money: float, oil: float, - seller: Seller, + price: float, + available_oil: float, ) -> float: - """Return fixed-price oil quantity that maximizes buyer welfare.""" + """Return the oil quantity at ``price`` that maximizes buyer welfare. - price = seller.posted_price - if money <= 0 or oil <= 0 or seller.oil <= 0: + ``price`` is the buyer's believed price when projecting a move, and + the seller's actual posted price when transacting. + """ + + if money <= 0 or oil <= 0 or available_oil <= 0 or price <= 0: return 0.0 alpha = buyer.money_alpha unconstrained = ((1 - alpha) * money / price) - (alpha * oil) if unconstrained <= 0: return 0.0 - return min(unconstrained, money / price, seller.oil) + return min(unconstrained, money / price, available_oil) def _create_sellers(self) -> None: """Create fixed sellers on unique random grid cells.""" @@ -315,9 +479,14 @@ def _create_sellers(self) -> None: self.config.num_sellers, ) for position in positions: - seller = Seller(self) - seller.cell = self.cell_at(position) - self.sellers.append(seller) + self.sellers.append(self._new_seller(position=position)) + + def _new_seller(self, position: tuple[int, int], generation: int = 0) -> Seller: + """Create and place one fresh seller at ``position``.""" + + seller = Seller(self, generation=generation) + seller.cell = self.cell_at(position) + return seller def _create_buyers(self) -> None: """Create buyers on random grid cells.""" diff --git a/src/spatial_market_lockin/pricing.py b/src/spatial_market_lockin/pricing.py new file mode 100644 index 0000000..6e0a8d0 --- /dev/null +++ b/src/spatial_market_lockin/pricing.py @@ -0,0 +1,175 @@ +"""Dynamic seller pricing: captive demand, competitor anchoring, liquidity +pressure, and lock-in premium, combined into each seller's posted price +every tick. + + p_s(t) = p_floor * (1 + a1*D_s(t) + a2*C_s(t) + a3*L_s(t) + a4*Y_s(t)) + +The component functions here are pure: they read seller/buyer state and +config and return numbers, without mutating anything. ``update_seller_prices`` +is the only function that writes back to the model, and it does so by first +snapshotting every seller's current ``posted_price`` (= last tick's price, +since this runs before any reassignment this tick) and only assigning the +new prices once every seller's new price has been computed. This keeps the +"uses last-tick rival prices" simultaneity rule from the spec explicit, +rather than letting an earlier seller's update leak into a later seller's +"previous tick" snapshot within the same tick. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Mapping, Sequence + +from .geometry import chebyshev_distance + +if TYPE_CHECKING: + from .agents import Seller + from .model import SpatialMarketModel + + +def captive_demand_shares( + sellers: Sequence["Seller"], + buyer_coordinates: Sequence[tuple[int, int]], + grid_size: int, + torus: bool, +) -> dict["Seller", float]: + """Return ``{seller: D_s}`` for every seller in one pass over buyers. + + D_s = n_s / n_bar, where n_s is the count of buyers strictly closer to + `s` than to any other seller (strict Voronoi membership) and n_bar is + the average buyers-per-seller (N / M). A buyer exactly equidistant + between two or more sellers is captive to none of them. + """ + + shares = {seller: 0.0 for seller in sellers} + n_buyers = len(buyer_coordinates) + n_sellers = len(sellers) + if n_buyers == 0 or n_sellers == 0: + return shares + + n_bar = n_buyers / n_sellers + seller_coords = [(seller, seller.cell.coordinate) for seller in sellers] + counts = {seller: 0 for seller in sellers} + + for buyer_coord in buyer_coordinates: + distances = [ + (seller, chebyshev_distance(buyer_coord, coord, grid_size, torus)) + for seller, coord in seller_coords + ] + nearest_distance = min(distance for _, distance in distances) + nearest_sellers = [ + seller for seller, distance in distances if distance == nearest_distance + ] + if len(nearest_sellers) == 1: + counts[nearest_sellers[0]] += 1 + # ties: strictly-closest to no single seller, so not captive to anyone + + return {seller: counts[seller] / n_bar for seller in sellers} + + +def competitor_anchoring( + seller: "Seller", + sellers: Sequence["Seller"], + previous_prices: Mapping["Seller", float], + grid_size: int, + torus: bool, + length_scale: float, + p_floor: float, +) -> float: + """C_s: distance-weighted average of OTHER sellers' previous-tick + prices, expressed as a fractional deviation from p_floor. Kernel + weight is ``exp(-distance / length_scale)`` -- nearer rivals anchor + more strongly than distant ones. + """ + + rivals = [other for other in sellers if other is not seller] + if not rivals: + return 0.0 + + own_coord = seller.cell.coordinate + weighted_sum = 0.0 + weight_total = 0.0 + for rival in rivals: + distance = chebyshev_distance(own_coord, rival.cell.coordinate, grid_size, torus) + weight = math.exp(-distance / length_scale) + weighted_sum += weight * previous_prices[rival] + weight_total += weight + + if weight_total <= 0: + return 0.0 + + weighted_average_price = weighted_sum / weight_total + return (weighted_average_price - p_floor) / p_floor + + +def liquidity_pressure(seller: "Seller", money_metabolism: float, solvency_horizon: float) -> float: + """L_s: distress markup when current money won't cover metabolism over + the solvency horizon T_h, clipped to [0, 1]. A seller with zero + metabolism (or an invalid horizon) feels no liquidity pressure. + """ + + if money_metabolism <= 0 or solvency_horizon <= 0: + return 0.0 + + required = money_metabolism * solvency_horizon + raw = (required - seller.money) / required + return min(max(raw, 0.0), 1.0) + + +def lock_in_premium(seller: "Seller", loyalty_threshold_k: int, n_bar: float) -> float: + """Y_s: captive-buyer fraction relative to n_bar. + + Counts buyers that have made >= ``loyalty_threshold_k`` consecutive + purchases from this seller without switching, divided by the average + buyers-per-seller (n_bar = N/M). A high value means the seller has + many locked-in buyers and can exploit them with a higher price. + Returns 0.0 when n_bar is zero or when no buyers have crossed the + lock-in threshold yet. + """ + + if n_bar <= 0: + return 0.0 + loyal_count = sum( + 1 for count in seller.loyalty_counts.values() if count >= loyalty_threshold_k + ) + return loyal_count / n_bar + + +def update_seller_prices(model: "SpatialMarketModel") -> None: + """Recompute and assign every living seller's posted price for this tick.""" + + config = model.config + sellers = list(model.alive_sellers) + if not sellers: + return + + previous_prices = {seller: seller.posted_price for seller in sellers} + buyer_coordinates = [buyer.cell.coordinate for buyer in model.alive_buyers] + n_bar = len(buyer_coordinates) / len(sellers) if sellers else 0.0 + + demand_shares = captive_demand_shares( + sellers, buyer_coordinates, config.grid_size, config.torus + ) + + new_prices: dict["Seller", float] = {} + for seller in sellers: + d_s = demand_shares[seller] + c_s = competitor_anchoring( + seller, + sellers, + previous_prices, + config.grid_size, + config.torus, + config.competitor_anchoring_length_scale, + config.p_floor, + ) + l_s = liquidity_pressure( + seller, config.seller_money_metabolism, config.solvency_horizon_ticks + ) + y_s = lock_in_premium(seller, config.loyalty_threshold_k, n_bar) + new_prices[seller] = config.p_floor * ( + 1 + config.a1 * d_s + config.a2 * c_s + config.a3 * l_s + config.a4 * y_s + ) + + for seller, price in new_prices.items(): + seller.posted_price = price diff --git a/src/spatial_market_lockin/scenarios.py b/src/spatial_market_lockin/scenarios.py new file mode 100644 index 0000000..2bdb494 --- /dev/null +++ b/src/spatial_market_lockin/scenarios.py @@ -0,0 +1,69 @@ +"""Named parameter scenarios for exploring the model. + +Each scenario is a set of ``ModelConfig`` overrides applied on top of the +config defaults. They exist so the switching behaviour can be explored and +compared reproducibly from the CLI (``--scenario``) and the experiment +script, without hand-copying parameter blocks. + +Findings behind these: switching is governed primarily by the buyer belief +prior (exploration), not by reachability or a2. ``degenerate`` is the +near-zero-switching regime; ``low_lockin`` and ``moderate_lockin`` share a +substrate where buyers survive and can see/reach several sellers, and differ +only in ``a2`` (competitor anchoring). With the cold-start prior recalibrated +to 1.7 (see substrate note), both land in the 5-25% switch band; a2 turns out +to move switching only weakly (and in the opposite direction to the textbook +"convergence reduces switching" intuition -- see prior_sweep notes). +""" + +from __future__ import annotations + +# Substrate where buyers live long enough and typically see/reach several +# sellers; on top of this, the pricing weights a1/a2 govern switching. +# +# prior_price_mean is recalibrated to 1.7 (down from the config default 2.0). +# A prior_sweep (2026-06, scripts/) showed the 2.0 cold-start belief sat ABOVE +# every equilibrium posted price (~1.3-1.7), so every unvisited seller looked +# dearer than a buyer's known seller -> buyers never explored and switching +# collapsed to ~0.09 regardless of a2. At 1.7 the prior sits near equilibrium, +# exploration is restored, and switch_rate lands in the target 5-25% band. +_SWITCHING_SUBSTRATE = { + "buyer_initial_oil": 50.0, + "psi_tick": 0.25, + "num_sellers": 20, + "visual_range": 12, + "a1": 0.3, + "a3": 1.0, + "prior_price_mean": 1.7, +} + +SCENARIOS: dict[str, dict] = { + # README/config defaults. Near-zero switching (~0.01) -- the degenerate + # reference regime. Kept as the default so unqualified runs are unchanged. + "degenerate": {}, + # Dispersed prices (a2=0) -> a real reason to switch. switch_rate ~0.13 + # (3-seed, prior=1.7). + "low_lockin": {**_SWITCHING_SUBSTRATE, "a2": 0.0}, + # Competitor anchoring (a2=0.5) sustains higher prices; with the recalibrated + # prior this switches ~0.18 (3-seed) -- slightly ABOVE low_lockin, because + # higher prices widen the gap to the prior and pull exploration up. a2 is a + # weak/inverted switching lever in this model; see prior_sweep notes. + "moderate_lockin": {**_SWITCHING_SUBSTRATE, "a2": 0.5}, +} + +DEFAULT_SCENARIO = "degenerate" + + +def scenario_names() -> list[str]: + """Return the available scenario names.""" + + return list(SCENARIOS) + + +def scenario_overrides(name: str) -> dict: + """Return a fresh copy of the config overrides for ``name``.""" + + if name not in SCENARIOS: + raise KeyError( + f"unknown scenario {name!r}; choose from {scenario_names()}" + ) + return dict(SCENARIOS[name]) diff --git a/src/spatial_market_lockin/viz.py b/src/spatial_market_lockin/viz.py index e6f34d3..0105235 100644 --- a/src/spatial_market_lockin/viz.py +++ b/src/spatial_market_lockin/viz.py @@ -9,7 +9,15 @@ from mesa.visualization.utils import update_counter import solara +from spatial_market_lockin.lockin import is_revealed_suboptimal, is_streak_suboptimal from spatial_market_lockin.model import SpatialMarketModel +from spatial_market_lockin.metrics import ( + lifetime_counts, + never_transacted_fraction, + revealed_lock_in_fraction, + switch_rate, + switch_rate_among_traders, +) alt.data_transformers.disable_max_rows() @@ -22,6 +30,87 @@ def MarketView(model: SpatialMarketModel): solara.FigureAltair(_market_chart(model)) +@solara.component +def MetricsSummary(model: SpatialMarketModel): + """Current-tick snapshot and lifetime lock-in diagnostics side by side.""" + + update_counter.get() + + df = model.datacollector.get_model_vars_dataframe() + counts = lifetime_counts(model, include_alive=True) + + def _last(col: str) -> float: + return float(df[col].iloc[-1]) if len(df) > 0 and col in df else float("nan") + + def _pct(v: float) -> str: + return "—" if v != v else f"{v:.1%}" + + def _num(v: float) -> str: + return "—" if v != v else f"{v:.3f}" + + n_tx = _last("num_transactions") + n_tx_str = "—" if n_tx != n_tx else str(int(n_tx)) + + def _subopt_rate(flags: list[int], alive_fn) -> str: + all_flags = list(flags) + [int(v) for v in (alive_fn(b) for b in model.alive_buyers)] + return _pct(sum(all_flags) / len(all_flags)) if all_flags else "—" + + revealed_subopt_str = _subopt_rate( + model.revealed_suboptimal_flags, + lambda b: is_revealed_suboptimal(b, model.alive_sellers), + ) + streak_subopt_str = _subopt_rate( + model.streak_suboptimal_flags, + lambda b: is_streak_suboptimal(b, model.alive_sellers, model.config.loyalty_threshold_k), + ) + + with solara.Columns([1, 1]): + with solara.Card("Current tick"): + solara.Markdown(f""" +| Metric | Value | +|---|---| +| Tick | {model.tick} | +| Living buyers | {len(model.alive_buyers)} | +| Living sellers | {len(model.alive_sellers)} | +| Transactions this tick | {n_tx_str} | +| Lock-in rate | {_pct(_last('lock_in_rate'))} | +| Suboptimal lock-in | {_pct(_last('suboptimal_lock_in_rate'))} | +| Avg posted price | {_num(_last('avg_posted_price'))} | +| Avg transaction price | {_num(_last('avg_transaction_price'))} | +| Price CV | {_num(_last('price_cv'))} | +""") + with solara.Card("Lifetime diagnostics"): + solara.Markdown(f""" +| Metric | Value | +|---|---| +| Completed buyer lives | {len(model.lifetime_seller_counts)} | +| Never transacted | {_pct(never_transacted_fraction(counts))} | +| Revealed lock-in (= 1 seller) | {_pct(revealed_lock_in_fraction(counts))} | +| Switch rate (all lives) | {_pct(switch_rate(counts))} | +| Switch rate (traders only) | {_pct(switch_rate_among_traders(counts))} | +| Never-switched suboptimal | {revealed_subopt_str} | +| Streak-trapped suboptimal | {streak_subopt_str} | +| Cumulative buyer deaths | {model.cumulative_buyer_deaths} | +| Cumulative seller deaths | {model.cumulative_seller_deaths} | +""") + + +@solara.component +def LockInPlot(model: SpatialMarketModel): + """Lock-in rate and suboptimal lock-in rate over time.""" + + update_counter.get() + solara.FigureAltair(_lock_in_chart(model)) + + +@solara.component +def PricePlot(model: SpatialMarketModel): + """Avg posted price, avg transaction price, and price CV over time.""" + + update_counter.get() + solara.FigureAltair(_price_chart(model)) + + @solara.component def TotalMoneyPlot(model: SpatialMarketModel): """Draw total environmental money over time.""" @@ -120,7 +209,7 @@ def _market_chart(model: SpatialMarketModel) -> alt.Chart: .properties( width=520, height=520, - title=f"Market state | step {model.steps}", + title=f"Market state | tick {model.tick}", ) .configure_view(stroke=None) ) @@ -188,6 +277,7 @@ def _seller_frame(model: SpatialMarketModel) -> pd.DataFrame: { "kind": "seller", "id": seller.unique_id, + "generation": seller.generation, "x": coordinate[0], "y": coordinate[1], "money": seller.money, @@ -197,7 +287,7 @@ def _seller_frame(model: SpatialMarketModel) -> pd.DataFrame: ) return pd.DataFrame( rows, - columns=["kind", "id", "x", "y", "money", "oil", "posted_price"], + columns=["kind", "id", "generation", "x", "y", "money", "oil", "posted_price"], ) @@ -211,6 +301,106 @@ def _agent_frame(model: SpatialMarketModel, attribute: str) -> pd.DataFrame: raise ValueError("attribute must be 'buyers' or 'sellers'") +def _lock_in_chart(model: SpatialMarketModel) -> alt.Chart: + frame = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) + frame["tick"] = range(len(frame)) + + long = pd.melt( + frame[["tick", "lock_in_rate", "suboptimal_lock_in_rate"]], + id_vars="tick", + value_vars=["lock_in_rate", "suboptimal_lock_in_rate"], + var_name="metric", + value_name="rate", + ).dropna(subset=["rate"]) + long["metric"] = long["metric"].map({ + "lock_in_rate": "Lock-in rate", + "suboptimal_lock_in_rate": "Suboptimal lock-in", + }) + + return ( + alt.Chart(long) + .mark_line() + .encode( + x=alt.X("tick:Q", title="Tick"), + y=alt.Y("rate:Q", title="Fraction of buyers", scale=alt.Scale(domain=[0, 1])), + color=alt.Color( + "metric:N", + title=None, + scale=alt.Scale( + domain=["Lock-in rate", "Suboptimal lock-in"], + range=["#d62728", "#ff7f0e"], + ), + ), + tooltip=[ + alt.Tooltip("tick:Q", title="Tick"), + alt.Tooltip("metric:N", title="Metric"), + alt.Tooltip("rate:Q", title="Rate", format=".3f"), + ], + ) + .properties(width=520, height=200, title="Lock-in rates over time") + ) + + +def _price_chart(model: SpatialMarketModel) -> alt.Chart: + frame = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) + frame["tick"] = range(len(frame)) + + price_long = pd.melt( + frame[["tick", "avg_posted_price", "avg_transaction_price"]], + id_vars="tick", + value_vars=["avg_posted_price", "avg_transaction_price"], + var_name="metric", + value_name="price", + ).dropna(subset=["price"]) + price_long["metric"] = price_long["metric"].map({ + "avg_posted_price": "Avg posted price", + "avg_transaction_price": "Avg transaction price", + }) + + price_lines = ( + alt.Chart(price_long) + .mark_line() + .encode( + x=alt.X("tick:Q", title="Tick"), + y=alt.Y("price:Q", title="Price"), + color=alt.Color( + "metric:N", + title=None, + scale=alt.Scale( + domain=["Avg posted price", "Avg transaction price"], + range=["#1f77b4", "#2ca02c"], + ), + ), + tooltip=[ + alt.Tooltip("tick:Q", title="Tick"), + alt.Tooltip("metric:N", title="Metric"), + alt.Tooltip("price:Q", title="Price", format=".3f"), + ], + ) + ) + + cv_frame = frame[["tick", "price_cv"]].dropna(subset=["price_cv"]) + cv_line = ( + alt.Chart(cv_frame) + .mark_line(strokeDash=[5, 3]) + .encode( + x=alt.X("tick:Q"), + y=alt.Y("price_cv:Q", title="Price CV"), + color=alt.value("#9467bd"), + tooltip=[ + alt.Tooltip("tick:Q", title="Tick"), + alt.Tooltip("price_cv:Q", title="Price CV", format=".3f"), + ], + ) + ) + + return ( + alt.layer(price_lines, cv_line) + .resolve_scale(y="independent") + .properties(width=520, height=200, title="Prices and dispersion over time") + ) + + def _total_money_chart(model: SpatialMarketModel) -> alt.Chart: frame = model.datacollector.get_model_vars_dataframe().reset_index(drop=True) frame["tick"] = range(len(frame)) @@ -231,27 +421,59 @@ def _total_money_chart(model: SpatialMarketModel) -> alt.Chart: model_params = { "seed": 42, - "grid_size": Slider("Grid size", 10, 5, 100, 5), - "num_buyers": Slider("Buyers", 5, 0, 50, 1), - "num_sellers": Slider("Sellers", 2, 1, 20, 1), - "visual_range": Slider("Visual range", 5, 1, 20, 1), + # --- spatial --- + "grid_size": Slider("Grid size", 30, 5, 100, 5), + "num_buyers": Slider("Buyers", 40, 0, 200, 5), + "num_sellers": Slider("Sellers", 8, 1, 30, 1), + "visual_range": Slider("Visual range", 8, 1, 20, 1), + # --- buyer survival --- + "buyer_initial_oil": Slider("Buyer initial oil", 30.0, 5.0, 100.0, 5.0), + "psi_tick": Slider("Oil per tick (ψ_tick)", 0.25, 0.05, 2.0, 0.05), + "psi_move": Slider("Oil per step (ψ_move)", 0.2, 0.0, 1.0, 0.05), + # --- pricing weights --- + "a1": Slider("Captive demand (a1)", 0.3, 0.0, 3.0, 0.1), + "a2": Slider("Competitor anchoring (a2)", 0.3, 0.0, 0.9, 0.1), + "a3": Slider("Liquidity pressure (a3)", 1.0, 0.0, 3.0, 0.1), + "a4": Slider("Loyalty markup (a4)", 0.0, 0.0, 3.0, 0.1), + "loyalty_threshold_k": Slider("Loyalty threshold (k)", 3, 1, 10, 1), + # --- environment --- "money_max": Slider("Money max", 4.0, 1.0, 10.0, 0.5), - "money_regrowth_rate": Slider("Money regrowth", 1.0, 0.0, 5.0, 0.25), - "initial_money_probability": Slider("Initial money chance", 0.05, 0.0, 1.0, 0.01), - "money_regrowth_probability": Slider( - "Regrowth chance", - 0.02, - 0.0, - 1.0, - 0.01, - ), + "money_regrowth_rate": Slider("Money regrowth rate", 1.0, 0.0, 5.0, 0.25), } -initial_model = SpatialMarketModel(seed=42, grid_size=10, num_buyers=5, num_sellers=2) +# Parameters chosen for visible dynamics: +# - 30x30 grid, 40 buyers, 8 sellers: enough agents to see spatial clustering +# - psi_tick=0.25 + buyer_initial_oil=30: buyers survive ~120 ticks without +# trading, so deaths don't swamp the screen before any switching is seen +# - a1=0.3: captive-demand markup stays mild so oil remains affordable +# - a2=0.3: some competitor anchoring but prices stay dispersed enough that +# switching gives a real welfare gain (contrast with degenerate a2=0.5 +# which converges prices and kills switching incentives) +initial_model = SpatialMarketModel( + seed=42, + grid_size=30, + num_buyers=40, + num_sellers=8, + visual_range=8, + buyer_initial_oil=30.0, + psi_tick=0.25, + psi_move=0.2, + a1=0.3, + a2=0.3, + a3=1.0, + a4=0.0, +) page = SolaraViz( initial_model, - components=[(MarketView, 0), (TotalMoneyPlot, 0), (AgentDiagnostics, 0)], + components=[ + (MarketView, 0), + (MetricsSummary, 0), + (LockInPlot, 0), + (PricePlot, 0), + (TotalMoneyPlot, 0), + (AgentDiagnostics, 0), + ], model_params=model_params, name="Spatial Market Lock-In ABM", ) diff --git a/tests/test_aversion.py b/tests/test_aversion.py new file mode 100644 index 0000000..76743a7 --- /dev/null +++ b/tests/test_aversion.py @@ -0,0 +1,98 @@ +"""Tests for loss-aversion gates (README §4.2, §5.2).""" + +from __future__ import annotations + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel + + +def _purchase_model(**overrides) -> SpatialMarketModel: + """1-buyer/1-seller model with the buyer sitting on the seller, no money + to harvest, so a single purchase resolves on the first step.""" + + base = dict( + seed=42, + grid_size=5, + num_buyers=1, + num_sellers=1, + initial_money_probability=0.0, + money_regrowth_probability=0.0, + buyer_initial_money=10.0, + buyer_initial_oil=4.0, + seller_initial_oil=50.0, + p_floor=1.0, + psi_tick=1.0, + psi_move=0.0, + r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, + prior_price_mean=2.0, + ) + base.update(overrides) + model = SpatialMarketModel(ModelConfig(**base)) + buyer = model.buyers[0] + seller = model.sellers[0] + buyer.move_to(model.cell_at(seller.coordinate)) + model.money_layer.data[:, :] = 0.0 + return model + + +# --- loss aversion --------------------------------------------------------- + + +def test_loss_aversion_reduces_purchase_above_reference() -> None: + """A pricier-than-expected seller is bought from less under loss aversion.""" + + # posted price 4.0 is above the prior reference (2.0) -> overpayment is + # weighted by lambda, so a loss-averse buyer buys strictly less. + loss_averse = _purchase_model(p_floor=4.0, loss_aversion=3.0) + neutral = _purchase_model(p_floor=4.0, loss_aversion=1.0) + + loss_averse.step() + neutral.step() + + assert loss_averse.buyers[0].last_purchased_oil < neutral.buyers[0].last_purchased_oil + + +def test_loss_aversion_can_block_purchase_entirely() -> None: + # Strong loss aversion pushes the loss-shaded price above the buyer's + # reservation price, so no purchase happens at all. + model = _purchase_model(p_floor=4.0, loss_aversion=10.0) + + model.step() + + assert model.buyers[0].last_purchased_oil == 0.0 + assert model.transactions == [] + + +def test_no_loss_when_price_at_or_below_reference() -> None: + # posted price equals the reference (2.0): loss aversion is a no-op, so + # lambda does not change the purchase. + high_lambda = _purchase_model(p_floor=2.0, loss_aversion=4.0) + low_lambda = _purchase_model(p_floor=2.0, loss_aversion=1.0) + + high_lambda.step() + low_lambda.step() + + assert high_lambda.buyers[0].last_purchased_oil == pytest.approx( + low_lambda.buyers[0].last_purchased_oil + ) + + +def test_buyer_pays_true_price_not_loss_shaded_price() -> None: + # The loss shading only affects the DECISION; cash paid is quantity * real price. + model = _purchase_model(p_floor=3.0, loss_aversion=2.0) + + model.step() + + record = model.transactions[0] + assert record.price == pytest.approx(3.0) + assert record.cost == pytest.approx(record.price * record.quantity) + + +def test_loss_aversion_below_one_is_rejected() -> None: + with pytest.raises(ValueError): + ModelConfig(loss_aversion=0.5).validate() diff --git a/tests/test_beliefs.py b/tests/test_beliefs.py new file mode 100644 index 0000000..ae5ff67 --- /dev/null +++ b/tests/test_beliefs.py @@ -0,0 +1,75 @@ +"""Tests for EMA price-belief updates.""" + +from __future__ import annotations + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel + + +def _model(**overrides) -> SpatialMarketModel: + base = dict(seed=42, grid_size=5, num_buyers=1, num_sellers=1) + base.update(overrides) + return SpatialMarketModel(ModelConfig(**base)) + + +def test_unvisited_seller_belief_is_prior_mean() -> None: + model = _model(prior_price_mean=3.0) + buyer = model.buyers[0] + seller = model.sellers[0] + assert buyer.believed_price(seller) == pytest.approx(3.0) + + +def test_single_observation_applies_ema_formula() -> None: + model = _model(prior_price_mean=2.0, belief_update_weight=0.5) + buyer = model.buyers[0] + seller = model.sellers[0] + buyer.observe_price(seller, 6.0) + # (1-0.5)*2.0 + 0.5*6.0 = 4.0 + assert buyer.believed_price(seller) == pytest.approx(4.0) + + +def test_alpha_one_replaces_belief_immediately() -> None: + model = _model(prior_price_mean=2.0, belief_update_weight=1.0) + buyer = model.buyers[0] + seller = model.sellers[0] + buyer.observe_price(seller, 7.0) + assert buyer.believed_price(seller) == pytest.approx(7.0) + + +def test_repeated_observations_converge_to_price() -> None: + model = _model(prior_price_mean=2.0, belief_update_weight=0.3) + buyer = model.buyers[0] + seller = model.sellers[0] + for _ in range(100): + buyer.observe_price(seller, 5.0) + assert buyer.believed_price(seller) == pytest.approx(5.0, rel=1e-3) + + +def test_beliefs_are_independent_per_seller() -> None: + model = SpatialMarketModel( + ModelConfig(seed=42, grid_size=10, num_buyers=1, num_sellers=2, belief_update_weight=1.0) + ) + buyer = model.buyers[0] + s1, s2 = model.sellers + buyer.observe_price(s1, 3.0) + buyer.observe_price(s2, 8.0) + assert buyer.believed_price(s1) == pytest.approx(3.0) + assert buyer.believed_price(s2) == pytest.approx(8.0) + + +def test_default_config_has_belief_params() -> None: + config = ModelConfig() + assert config.prior_price_mean == pytest.approx(2.0) + assert config.belief_update_weight == pytest.approx(0.3) + config.validate() + + +def test_belief_update_weight_zero_rejected() -> None: + with pytest.raises(ValueError): + ModelConfig(belief_update_weight=0.0).validate() + + +def test_belief_update_weight_above_one_rejected() -> None: + with pytest.raises(ValueError): + ModelConfig(belief_update_weight=1.1).validate() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..db7e6fb --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,114 @@ +"""Tests for single-run exports and the run CLI (roadmap #5).""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pandas as pd + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.export import export_run + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _small_run() -> SpatialMarketModel: + model = SpatialMarketModel( + ModelConfig(seed=42, steps=15, grid_size=10, num_buyers=20, num_sellers=4) + ) + model.run() + return model + + +def test_export_writes_all_files(tmp_path: Path) -> None: + model = _small_run() + + out = export_run(model, tmp_path / "run") + + for name in ( + "model_timeseries.csv", + "transactions.csv", + "final_buyers.csv", + "final_sellers.csv", + "run_metadata.json", + ): + assert (out / name).exists() + + +def test_transactions_csv_matches_ledger(tmp_path: Path) -> None: + model = _small_run() + + out = export_run(model, tmp_path) + df = pd.read_csv(out / "transactions.csv") + + assert len(df) == len(model.transactions) + assert list(df.columns) == [ + "tick", + "buyer_id", + "seller_id", + "buyer_generation", + "price", + "quantity", + "cost", + "x", + "y", + ] + + +def test_final_sellers_csv_matches_living_sellers(tmp_path: Path) -> None: + model = _small_run() + + out = export_run(model, tmp_path) + df = pd.read_csv(out / "final_sellers.csv") + + assert len(df) == len(model.alive_sellers) + + +def test_metadata_json_has_config_and_summary(tmp_path: Path) -> None: + model = _small_run() + + out = export_run(model, tmp_path) + metadata = json.loads((out / "run_metadata.json").read_text()) + + assert metadata["config"]["seed"] == 42 + assert metadata["config"]["steps"] == 15 + assert "switch_rate" in metadata["summary"] + assert "never_transacted" in metadata["summary"] + + +def test_export_creates_nested_output_dir(tmp_path: Path) -> None: + model = _small_run() + + out = export_run(model, tmp_path / "a" / "b" / "run") + + assert out.is_dir() + assert (out / "transactions.csv").exists() + + +def _load_run_model_main(): + spec = importlib.util.spec_from_file_location( + "run_model_cli", REPO_ROOT / "scripts" / "run_model.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.main + + +def test_cli_runs_and_writes_outputs(tmp_path: Path) -> None: + main = _load_run_model_main() + + main( + [ + "--seed", "1", + "--steps", "5", + "--grid-size", "8", + "--num-buyers", "10", + "--num-sellers", "3", + "--output-dir", str(tmp_path / "cli"), + ] + ) + + assert (tmp_path / "cli" / "model_timeseries.csv").exists() + assert (tmp_path / "cli" / "run_metadata.json").exists() diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..15d15bd --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,118 @@ +"""Tests for the transaction ledger (roadmap #1).""" + +from __future__ import annotations + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.ledger import Transaction + + +def _single_purchase_model(**overrides) -> SpatialMarketModel: + """A 1-buyer/1-seller model with a deterministic, known purchase. + + Mirrors the welfare-optimum purchase fixture in test_model: the buyer + sits on the seller's cell with no money to harvest, so exactly one + purchase of 7/3 oil at price 2.0 (cost 14/3) happens on the first step. + """ + + config = dict( + seed=42, + grid_size=5, + num_buyers=1, + num_sellers=1, + initial_money_probability=0.0, + money_regrowth_probability=0.0, + buyer_initial_money=10.0, + buyer_initial_oil=4.0, + seller_initial_money=20.0, + seller_initial_oil=50.0, + p_floor=2.0, + psi_tick=1.0, + psi_move=0.0, + r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, + ) + config.update(overrides) + model = SpatialMarketModel(ModelConfig(**config)) + buyer = model.buyers[0] + seller = model.sellers[0] + buyer.move_to(model.cell_at(seller.coordinate)) + model.money_layer.data[:, :] = 0.0 + return model + + +def test_ledger_starts_empty() -> None: + model = _single_purchase_model() + + assert model.transactions == [] + assert model.step_transactions == [] + assert model.tick == 0 + + +def test_successful_purchase_records_one_transaction() -> None: + model = _single_purchase_model() + buyer = model.buyers[0] + seller = model.sellers[0] + + model.step() + + assert len(model.transactions) == 1 + record = model.transactions[0] + assert isinstance(record, Transaction) + assert record.tick == 1 + assert record.buyer_id == buyer.unique_id + assert record.seller_id == seller.unique_id + assert record.buyer_generation == buyer.generation + assert record.price == pytest.approx(2.0) + assert record.quantity == pytest.approx(7 / 3) + assert record.cost == pytest.approx(14 / 3) + assert (record.x, record.y) == seller.coordinate + + +def test_recorded_cost_is_price_times_quantity() -> None: + model = _single_purchase_model() + + model.step() + + record = model.transactions[0] + assert record.cost == pytest.approx(record.price * record.quantity) + + +def test_no_transaction_recorded_when_quantity_is_zero() -> None: + # buyer_initial_oil=100 makes the marginal value of oil below price, so + # the welfare-optimal quantity is zero and nothing is bought. + model = _single_purchase_model(buyer_initial_oil=100.0) + + model.step() + + assert model.buyers[0].last_purchased_oil == 0.0 + assert model.transactions == [] + assert model.step_transactions == [] + + +def test_step_transactions_reset_each_tick() -> None: + model = _single_purchase_model() + + model.step() + first_step_count = len(model.step_transactions) + cumulative_after_first = len(model.transactions) + + model.step() + + # step_transactions only ever reflects the current tick... + assert all(t.tick == model.tick for t in model.step_transactions) + # ...while the full ledger accumulates across ticks. + assert len(model.transactions) >= cumulative_after_first + assert first_step_count == cumulative_after_first + + +def test_tick_increments_once_per_step() -> None: + model = _single_purchase_model() + + for expected_tick in range(1, 4): + model.step() + assert model.tick == expected_tick diff --git a/tests/test_lockin.py b/tests/test_lockin.py new file mode 100644 index 0000000..e0852d9 --- /dev/null +++ b/tests/test_lockin.py @@ -0,0 +1,117 @@ +"""Tests for lock-in measurement (README §6).""" + +from __future__ import annotations + +import math + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.lockin import is_locked_in, lock_in_rate, reachable_sellers + + +def _build_model(num_sellers, num_buyers=0, **overrides): + config = ModelConfig( + seed=1, grid_size=20, num_buyers=num_buyers, num_sellers=num_sellers, + torus=False, **overrides, + ) + return SpatialMarketModel(config) + + +def _place(agents, positions, model) -> None: + for agent, position in zip(agents, positions): + agent.cell = model.cell_at(position) + + +def test_reachable_sellers_includes_only_affordable_ones() -> None: + model = _build_model(num_sellers=2, num_buyers=1, psi_tick=1.0, psi_move=1.0) + seller_near, seller_far = model.sellers + _place(model.sellers, [(0, 0), (10, 0)], model) + buyer = model.buyers[0] + buyer.cell = model.cell_at((0, 1)) + buyer.oil = 3.0 # enough for near seller (cost ~1+1=2), not far seller (cost ~1+10=11) + + reachable = reachable_sellers( + buyer, model.sellers, model.config.grid_size, model.config.torus, + model.config.psi_tick, model.config.psi_move, + ) + + assert seller_near in reachable + assert seller_far not in reachable + + +def test_is_locked_in_true_with_zero_or_one_reachable() -> None: + model = _build_model(num_sellers=1, num_buyers=1, psi_tick=1.0, psi_move=0.0) + _place(model.sellers, [(0, 0)], model) + buyer = model.buyers[0] + buyer.cell = model.cell_at((0, 0)) + buyer.oil = 100.0 # only one seller exists at all -> locked in regardless + + assert is_locked_in( + buyer, model.sellers, model.config.grid_size, model.config.torus, + model.config.psi_tick, model.config.psi_move, + ) + + +def test_is_locked_in_false_with_two_reachable() -> None: + model = _build_model(num_sellers=2, num_buyers=1, psi_tick=1.0, psi_move=1.0) + _place(model.sellers, [(0, 0), (2, 0)], model) + buyer = model.buyers[0] + buyer.cell = model.cell_at((1, 0)) + buyer.oil = 50.0 # plenty for both nearby sellers + + assert not is_locked_in( + buyer, model.sellers, model.config.grid_size, model.config.torus, + model.config.psi_tick, model.config.psi_move, + ) + + +def test_lock_in_rate_is_nan_with_no_buyers() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + assert math.isnan(lock_in_rate(model.alive_buyers, model.alive_sellers, model.config)) + + +def test_lock_in_rate_is_one_when_only_one_seller_exists() -> None: + model = _build_model(num_sellers=1, num_buyers=5) + rate = lock_in_rate(model.alive_buyers, model.alive_sellers, model.config) + assert rate == 1.0 + + +def test_lock_in_rate_is_fraction_between_zero_and_one() -> None: + model = _build_model(num_sellers=8, num_buyers=50, psi_tick=1.0, buyer_initial_oil=10.0) + rate = lock_in_rate(model.alive_buyers, model.alive_sellers, model.config) + assert 0.0 <= rate <= 1.0 + + +def test_purchase_records_seller_on_buyer_lifetime_set() -> None: + model = _build_model( + num_sellers=1, num_buyers=1, initial_money_probability=0.0, + money_regrowth_probability=0.0, buyer_initial_money=10.0, + buyer_initial_oil=4.0, p_floor=2.0, psi_tick=1.0, psi_move=0.0, + r_o=0.0, a1=0.0, a2=0.0, a3=0.0, seller_money_metabolism=0.0, + ) + buyer = model.buyers[0] + seller = model.sellers[0] + buyer.move_to(model.cell_at(seller.coordinate)) + model.money_layer.data[:, :] = 0.0 + + assert len(buyer.lifetime_sellers) == 0 + model.step() + assert seller in buyer.lifetime_sellers + assert len(buyer.lifetime_sellers) == 1 + + +def test_lifetime_seller_counts_recorded_on_death() -> None: + model = _build_model( + num_sellers=1, num_buyers=1, buyer_initial_oil=1.0, psi_tick=1.0, psi_move=0.0, + ) + assert model.lifetime_seller_counts == [] + model.step() # buyer's oil (1.0) is consumed exactly by psi_tick -> dies this tick + assert len(model.lifetime_seller_counts) == 1 + + +def test_datacollector_includes_lock_in_rate_column() -> None: + model = _build_model(num_sellers=4, num_buyers=20) + model.step() + df = model.datacollector.get_model_vars_dataframe() + assert "lock_in_rate" in df.columns diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..3e945b2 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,174 @@ +"""Tests for metrics and run diagnostics (roadmap #4).""" + +from __future__ import annotations + +import math +from types import SimpleNamespace + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import ( + average_posted_price, + lifetime_counts, + mean_transaction_price, + never_transacted_fraction, + posted_price_cv, + revealed_lock_in_fraction, + summarize_run, + switch_rate, +) + + +def _seller(price: float) -> SimpleNamespace: + return SimpleNamespace(posted_price=price) + + +def _txn(price: float) -> SimpleNamespace: + return SimpleNamespace(price=price) + + +# --- per-tick metrics ------------------------------------------------------ + + +def test_average_posted_price() -> None: + assert average_posted_price([_seller(1.0), _seller(3.0)]) == pytest.approx(2.0) + + +def test_average_posted_price_empty_is_nan() -> None: + assert math.isnan(average_posted_price([])) + + +def test_posted_price_cv_known_value() -> None: + # prices [1, 3]: mean 2, population std 1, CV = 0.5 + assert posted_price_cv([_seller(1.0), _seller(3.0)]) == pytest.approx(0.5) + + +def test_posted_price_cv_equal_prices_is_zero() -> None: + assert posted_price_cv([_seller(2.0), _seller(2.0)]) == pytest.approx(0.0) + + +def test_posted_price_cv_single_seller_is_nan() -> None: + assert math.isnan(posted_price_cv([_seller(2.0)])) + + +def test_mean_transaction_price() -> None: + assert mean_transaction_price([_txn(2.0), _txn(4.0)]) == pytest.approx(3.0) + + +def test_mean_transaction_price_empty_is_nan() -> None: + assert math.isnan(mean_transaction_price([])) + + +# --- run-level diagnostics ------------------------------------------------- + + +def test_never_transacted_fraction() -> None: + assert never_transacted_fraction([0, 0, 1, 2]) == pytest.approx(0.5) + + +def test_revealed_lock_in_fraction() -> None: + assert revealed_lock_in_fraction([0, 1, 1, 2]) == pytest.approx(0.5) + + +def test_switch_rate() -> None: + assert switch_rate([0, 1, 1, 2, 2, 2]) == pytest.approx(0.5) + + +def test_diagnostics_empty_are_nan() -> None: + assert math.isnan(never_transacted_fraction([])) + assert math.isnan(revealed_lock_in_fraction([])) + assert math.isnan(switch_rate([])) + + +def test_switch_rate_min_purchases_conditions_on_opportunity() -> None: + from spatial_market_lockin.metrics import switch_rate_min_purchases + + # sellers / purchases per life. The two 1-purchase lives are structurally + # pinned to one seller and must be excluded once min_purchases >= 2. + sellers = [1, 1, 1, 2, 3] + buys = [1, 1, 5, 4, 6] + # min=1: all 5 lives, 2 switched -> 0.4 + assert switch_rate_min_purchases(sellers, buys, 1) == pytest.approx(0.4) + # min=2: drops the two 1-purchase lives -> 3 lives, 2 switched -> 2/3 + assert switch_rate_min_purchases(sellers, buys, 2) == pytest.approx(2 / 3) + # nobody qualifies -> NaN + assert math.isnan(switch_rate_min_purchases([1, 1], [1, 1], 5)) + + +def test_switching_propensity_is_lifespan_neutral_share() -> None: + from spatial_market_lockin.metrics import switching_propensity + + # one-purchase lives have no opportunity and are excluded; + # (d-1)/(p-1): (1,5)->0/4=0 ; (3,3)->2/2=1 ; (2,5)->1/4=0.25 -> mean 0.4167 + assert switching_propensity([1, 3, 2], [5, 3, 5]) == pytest.approx( + (0.0 + 1.0 + 0.25) / 3 + ) + assert math.isnan(switching_propensity([1, 1], [1, 1])) + + +def test_lifetime_counts_combines_completed_and_alive() -> None: + model = SpatialMarketModel(seed=1, grid_size=5, num_buyers=2, num_sellers=1) + model.lifetime_seller_counts = [3, 1] + # give the two alive buyers known in-progress distinct-seller sets + model.buyers[0].lifetime_sellers = {object()} + model.buyers[1].lifetime_sellers = set() + + completed_only = lifetime_counts(model, include_alive=False) + combined = lifetime_counts(model, include_alive=True) + + assert sorted(completed_only) == [1, 3] + assert sorted(combined) == [0, 1, 1, 3] + + +# --- integration ----------------------------------------------------------- + + +def test_summarize_run_has_expected_keys() -> None: + model = SpatialMarketModel(seed=3, grid_size=10, num_buyers=20, num_sellers=4, steps=20) + model.run() + + summary = summarize_run(model) + + for key in ( + "switch_rate", + "switch_rate_min2_purchases", + "switch_rate_min3_purchases", + "switching_propensity", + "never_transacted", + "revealed_lock_in", + "avg_posted_price", + "price_cv", + "avg_transaction_price", + "avg_lock_in_rate", + "living_sellers", + ): + assert key in summary + + +def test_datacollector_exposes_new_reporters() -> None: + model = SpatialMarketModel(seed=3, grid_size=10, num_buyers=20, num_sellers=4, steps=10) + model.run() + + df = model.datacollector.get_model_vars_dataframe() + for column in ("avg_posted_price", "price_cv", "avg_transaction_price", "num_transactions"): + assert column in df.columns + + +def test_gate_degenerate_regime_is_visible() -> None: + """The ruler must SEE anna's pathology at README defaults before we fix it. + + At the published defaults (low initial oil, visual range small relative to + seller spacing) most buyers die before ever reaching a seller, so the + switch rate is ~0 and the never-transacted fraction is high. This test + documents the degenerate baseline; later calibration must move switch_rate + well above this. + """ + + model = SpatialMarketModel(ModelConfig(seed=42, steps=40)) # README defaults + model.run() + + summary = summarize_run(model) + + assert summary["never_transacted"] > 0.5 + assert summary["switch_rate"] < 0.05 diff --git a/tests/test_model.py b/tests/test_model.py index 2998177..99082eb 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -324,6 +324,10 @@ def test_buyer_buys_oil_on_seller_cell() -> None: psi_tick=1.0, psi_move=0.0, r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, ) buyer = model.buyers[0] seller = model.sellers[0] @@ -356,6 +360,10 @@ def test_oil_purchase_is_capped_by_seller_inventory() -> None: psi_tick=1.0, psi_move=0.0, r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, ) buyer = model.buyers[0] seller = model.sellers[0] @@ -386,6 +394,10 @@ def test_oil_purchase_can_stop_at_welfare_optimum_before_inventory_cap() -> None psi_tick=1.0, psi_move=0.0, r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, ) buyer = model.buyers[0] seller = model.sellers[0] @@ -417,6 +429,10 @@ def test_no_oil_purchase_when_price_exceeds_marginal_value() -> None: psi_tick=1.0, psi_move=0.0, r_o=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + seller_money_metabolism=0.0, ) buyer = model.buyers[0] seller = model.sellers[0] diff --git a/tests/test_model_end_to_end.py b/tests/test_model_end_to_end.py new file mode 100644 index 0000000..96fbc7c --- /dev/null +++ b/tests/test_model_end_to_end.py @@ -0,0 +1,195 @@ +"""Comprehensive end-to-end contract test for the current (simplified) model. + +Unlike the focused unit tests, this drives a full multi-tick run and asserts +the *whole-model* invariants that the rest of the project (exports, sensitivity +analysis, the dashboard) relies on. It is the guard against silent drift between +the implementation and what the analysis assumes -- e.g. it would have caught +the non-deterministic ``revealed_suboptimal_rate`` bug. + +Scope, deliberately: + * determinism -- same seed reproduces byte-identical results + * summary contract -- every documented run-summary key is present & bounded + * timeseries schema -- every DataCollector column is present & bounded + * population -- instant buyer respawn keeps the population constant + * ledger -- every transaction is internally consistent + * bookkeeping -- death counters and lifetime records agree +""" + +from __future__ import annotations + +import math + +import pandas as pd +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.metrics import summarize_run +from spatial_market_lockin.scenarios import scenario_overrides + +# A compact but non-degenerate regime: buyers actually trade, switch, and the +# lock-in metrics are exercised. moderate_lockin guarantees real trading; the +# grid/step overrides keep the run fast enough for CI. +_STEPS = 80 +_OVERRIDES = {**scenario_overrides("moderate_lockin"), "grid_size": 25, "num_buyers": 60} + +# Run-summary keys the model promises (README §7). The analysis reads these by +# name, so a rename must break a test, not silently produce NaNs downstream. +_SUMMARY_KEYS = { + "ticks", "completed_lives", "scored_lives", "avg_lifespan", + "never_transacted", "revealed_lock_in", "switch_rate", "switch_rate_traders", + "switch_rate_min2_purchases", "switch_rate_min3_purchases", "switching_propensity", + "revealed_suboptimal_rate", "streak_suboptimal_rate", + "avg_posted_price", "avg_transaction_price", "price_cv", "price_spread", + "avg_lock_in_rate", "avg_suboptimal_lock_in_rate", + "living_buyers", "living_sellers", +} + +# DataCollector per-tick columns (README §7). +_TIMESERIES_COLUMNS = { + "total_money", "living_buyers", "living_sellers", + "step_buyer_deaths", "step_buyer_respawns", + "cumulative_buyer_deaths", "cumulative_buyer_respawns", + "lock_in_rate", "suboptimal_lock_in_rate", + "avg_posted_price", "price_cv", "avg_transaction_price", "num_transactions", + "step_seller_deaths", "cumulative_seller_deaths", +} + +# Summary metrics that are fractions: NaN (undefined) or within [0, 1]. +_RATE_KEYS = ( + "never_transacted", "revealed_lock_in", "switch_rate", "switch_rate_traders", + "revealed_suboptimal_rate", "streak_suboptimal_rate", + "avg_lock_in_rate", "avg_suboptimal_lock_in_rate", +) + + +def _run(seed: int) -> SpatialMarketModel: + model = SpatialMarketModel(ModelConfig(seed=seed, steps=_STEPS, **_OVERRIDES)) + model.run() + return model + + +@pytest.fixture(scope="module") +def model() -> SpatialMarketModel: + return _run(7) + + +def test_run_advances_exactly_steps_ticks(model: SpatialMarketModel) -> None: + assert model.tick == _STEPS + + +def test_run_actually_exercises_trading(model: SpatialMarketModel) -> None: + # If nothing trades, every "lock-in" assertion below is vacuous -- so make + # the precondition explicit. moderate_lockin must produce transactions. + assert len(model.transactions) > 0 + + +def test_same_seed_is_byte_identical() -> None: + """Determinism guard. Pins the fixed ``revealed_suboptimal_rate`` bug: + identity-hashed set iteration must not leak into any reported number.""" + a, b = _run(11), _run(11) + + assert summarize_run(a) == summarize_run(b) + pd.testing.assert_frame_equal( + a.datacollector.get_model_vars_dataframe(), + b.datacollector.get_model_vars_dataframe(), + ) + + +def test_different_seed_changes_outcome() -> None: + # Sanity: the seed is actually wired through, so determinism above is not + # just "the model ignores its seed". + assert summarize_run(_run(11)) != summarize_run(_run(12)) + + +def test_per_seller_series_is_recorded(model: SpatialMarketModel) -> None: + """Per-seller price/oil/money trajectories are collected via agenttype + reporters, so cross-seller dispersion is recoverable, not just aggregates.""" + from spatial_market_lockin.agents import Seller + + df = model.datacollector.get_agenttype_vars_dataframe(Seller) + assert {"posted_price", "oil", "money"} <= set(df.columns) + # Indexed by (Step, AgentID); prices are positive, oil within capacity. + assert (df["posted_price"] > 0).all() + assert (df["oil"] >= 0).all() + assert (df["oil"] <= model.config.o_max + 1e-9).all() + # At least the initial seller cohort is present across ticks. + steps = df.index.get_level_values(0).nunique() + assert steps == _STEPS + 1 # construction snapshot + one per tick + + +def test_summary_has_full_documented_contract(model: SpatialMarketModel) -> None: + summary = summarize_run(model) + assert _SUMMARY_KEYS <= set(summary) + + for key in _RATE_KEYS: + value = summary[key] + assert math.isnan(value) or 0.0 <= value <= 1.0, f"{key}={value}" + + assert summary["ticks"] == _STEPS + assert summary["price_cv"] >= 0.0 or math.isnan(summary["price_cv"]) + + +def test_timeseries_schema_and_bounds(model: SpatialMarketModel) -> None: + df = model.datacollector.get_model_vars_dataframe() + assert _TIMESERIES_COLUMNS <= set(df.columns) + # One snapshot at construction + one per tick. + assert len(df) == _STEPS + 1 + + for col in ("lock_in_rate", "suboptimal_lock_in_rate"): + finite = df[col].dropna() + assert ((finite >= 0.0) & (finite <= 1.0)).all() + + # Counts never negative; cumulative counters are monotonic non-decreasing. + assert (df["num_transactions"] >= 0).all() + for col in ("cumulative_buyer_deaths", "cumulative_seller_deaths"): + assert df[col].is_monotonic_increasing + + +def test_buyer_population_is_conserved_by_instant_respawn( + model: SpatialMarketModel, +) -> None: + # Buyers respawn the instant they die, so the living count is pinned and + # every death is matched one-for-one by a respawn, each tick. + df = model.datacollector.get_model_vars_dataframe() + assert (df["living_buyers"] == _OVERRIDES["num_buyers"]).all() + assert (df["step_buyer_deaths"] == df["step_buyer_respawns"]).all() + assert len(model.alive_buyers) == _OVERRIDES["num_buyers"] + + +def test_death_bookkeeping_agrees(model: SpatialMarketModel) -> None: + # Exactly one lifetime record is appended per completed life. + df = model.datacollector.get_model_vars_dataframe() + assert len(model.lifetime_seller_counts) == int(df["step_buyer_deaths"].sum()) + assert all(count >= 0 for count in model.lifetime_seller_counts) + # Purchase counts are recorded in lockstep with seller counts, and a life + # can never have more distinct sellers than purchases. + assert len(model.lifetime_purchase_counts) == len(model.lifetime_seller_counts) + assert all( + sellers <= buys + for sellers, buys in zip( + model.lifetime_seller_counts, model.lifetime_purchase_counts + ) + ) + + +def test_transaction_ledger_is_internally_consistent( + model: SpatialMarketModel, +) -> None: + living_buyer_ids = {b.unique_id for b in model.buyers} + for txn in model.transactions: + assert txn.quantity > 0.0 + assert txn.price > 0.0 + # Buyers settle at the posted price (loss aversion shrinks quantity, it + # does not change what is actually paid -- README §5.2). + assert txn.cost == pytest.approx(txn.price * txn.quantity) + assert 1 <= txn.tick <= _STEPS + # Purchase cell is on the grid. (We do NOT assert a seller still sits + # there: with respawn_sellers, the seller may have since died and been + # replaced at a random cell.) + assert 0 <= txn.x < model.config.grid_size + assert 0 <= txn.y < model.config.grid_size + # The ledger is permanent: it retains rows for buyers that have since + # died and been replaced, so buyer_id need not be currently alive. + assert isinstance(txn.buyer_id, int) + # At least some ledger rows belong to still-living buyers in this short run. + assert any(txn.buyer_id in living_buyer_ids for txn in model.transactions) diff --git a/tests/test_perception.py b/tests/test_perception.py new file mode 100644 index 0000000..bb63b2d --- /dev/null +++ b/tests/test_perception.py @@ -0,0 +1,90 @@ +"""Tests for belief-driven buyer perception (README §5.3).""" + +from __future__ import annotations + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel + + +def _model(**overrides) -> SpatialMarketModel: + base = dict(seed=42, grid_size=10, num_buyers=1, num_sellers=1) + base.update(overrides) + return SpatialMarketModel(ModelConfig(**base)) + + +def test_unvisited_seller_uses_prior_mean() -> None: + model = _model(prior_price_mean=2.0) + buyer = model.buyers[0] + seller = model.sellers[0] + + assert seller.unique_id not in buyer.beliefs + assert buyer.believed_price(seller) == pytest.approx(model.config.prior_price_mean) + + +def test_observing_records_a_belief_and_moves_toward_posted_price() -> None: + model = _model(prior_price_mean=2.0) + buyer = model.buyers[0] + seller = model.sellers[0] + seller.posted_price = 5.0 + + buyer.observe_price(seller, seller.posted_price) + + assert seller.unique_id in buyer.beliefs + believed = buyer.believed_price(seller) + # one observation pulls the belief from the prior mean (2.0) toward 5.0 + assert 2.0 < believed < 5.0 + + +def test_repeated_visits_converge_belief_to_posted_price() -> None: + model = _model(prior_price_mean=2.0) + buyer = model.buyers[0] + seller = model.sellers[0] + seller.posted_price = 5.0 + + for _ in range(100): + buyer.observe_price(seller, seller.posted_price) + + assert buyer.believed_price(seller) == pytest.approx(5.0, rel=1e-2) + + +def test_movement_reacts_to_belief_not_ground_truth() -> None: + """A buyer should be drawn to the seller it BELIEVES is cheaper, even when + the ground-truth posted prices say otherwise.""" + + model = _model( + grid_size=7, + num_buyers=0, + num_sellers=2, + initial_money_probability=0.0, + money_regrowth_probability=0.0, + buyer_initial_money=10.0, + buyer_initial_oil=5.0, + psi_move=0.0, + a1=0.0, + a2=0.0, + a3=0.0, + ) + from spatial_market_lockin.agents import Buyer + + seller_cheap_truth, seller_expensive_truth = model.sellers + # Ground truth: left seller is actually cheap, right seller actually dear. + model.cell_at((1, 3)).agents # ensure cells exist + seller_cheap_truth.cell = model.cell_at((1, 3)) + seller_expensive_truth.cell = model.cell_at((5, 3)) + seller_cheap_truth.posted_price = 1.0 + seller_expensive_truth.posted_price = 9.0 + + buyer = Buyer(model) + buyer.cell = model.cell_at((3, 3)) # equidistant (Chebyshev 2) from both + model.buyers.append(buyer) + model.money_layer.data[:, :] = 0.0 + + # Belief inversion: buyer believes the truly-expensive seller is cheap. + buyer.beliefs[seller_cheap_truth.unique_id] = 9.0 + buyer.beliefs[seller_expensive_truth.unique_id] = 1.0 + + destination = model._choose_buyer_destination(buyer) + + # It should head for the seller it BELIEVES is cheaper (the truly-dear one). + assert destination == seller_expensive_truth.coordinate diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..89b2e56 --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,321 @@ +"""Tests for the dynamic seller pricing game (README §5.1).""" + +from __future__ import annotations + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.pricing import ( + captive_demand_shares, + competitor_anchoring, + liquidity_pressure, + lock_in_premium, + update_seller_prices, +) + + +def _build_model(num_sellers, num_buyers=0, **overrides): + """Build a model and return it; sellers/buyers start at random cells + and are repositioned by the caller for deterministic geometry.""" + config = ModelConfig( + seed=1, + grid_size=20, + num_buyers=num_buyers, + num_sellers=num_sellers, + torus=False, + **overrides, + ) + return SpatialMarketModel(config) + + +def _place(agents, positions, model) -> None: + for agent, position in zip(agents, positions): + agent.cell = model.cell_at(position) + + +def test_captive_demand_share_sums_to_total_buyers_over_n_bar() -> None: + model = _build_model(num_sellers=2, num_buyers=4) + seller_a, seller_b = model.sellers + _place(model.sellers, [(0, 0), (10, 10)], model) + # 3 buyers closer to A, 1 closer to B, none tied. + _place(model.buyers, [(0, 1), (1, 0), (1, 1), (10, 11)], model) + buyer_coords = [b.coordinate for b in model.buyers] + + shares = captive_demand_shares( + model.sellers, buyer_coords, model.config.grid_size, model.config.torus + ) + + n_bar = 4 / 2 # 4 buyers, 2 sellers + assert shares[seller_a] == pytest.approx(3 / n_bar) + assert shares[seller_b] == pytest.approx(1 / n_bar) + + +def test_captive_demand_share_excludes_tied_buyers() -> None: + model = _build_model(num_sellers=2, num_buyers=1) + seller_a, seller_b = model.sellers + _place(model.sellers, [(0, 0), (4, 0)], model) + # (2, 0) is equidistant from both -- captive to neither. + _place(model.buyers, [(2, 0)], model) + buyer_coords = [b.coordinate for b in model.buyers] + + shares = captive_demand_shares( + model.sellers, buyer_coords, model.config.grid_size, model.config.torus + ) + + assert shares[seller_a] == 0.0 + assert shares[seller_b] == 0.0 + + +def test_captive_demand_share_is_zero_with_no_buyers() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + shares = captive_demand_shares(model.sellers, [], model.config.grid_size, model.config.torus) + assert shares[model.sellers[0]] == 0.0 + + +def test_competitor_anchoring_weights_nearer_rivals_more_heavily() -> None: + model = _build_model(num_sellers=3, num_buyers=0) + near_self, near_rival, far_rival = model.sellers + _place(model.sellers, [(0, 0), (1, 0), (10, 0)], model) + previous_prices = {near_self: 1.0, near_rival: 2.0, far_rival: 10.0} + + c_s = competitor_anchoring( + near_self, + model.sellers, + previous_prices, + model.config.grid_size, + model.config.torus, + length_scale=5.0, + p_floor=1.0, + ) + + # Weighted average must sit strictly between the two rival prices, + # and closer to the nearer rival's price (2.0) than the far one's (10.0). + implied_weighted_avg = c_s * 1.0 + 1.0 + assert 2.0 < implied_weighted_avg < 10.0 + assert implied_weighted_avg < 6.0 # closer to 2.0 than the midpoint + + +def test_competitor_anchoring_is_zero_with_no_rivals() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + seller = model.sellers[0] + c_s = competitor_anchoring( + seller, model.sellers, {seller: 1.0}, model.config.grid_size, + model.config.torus, length_scale=5.0, p_floor=1.0, + ) + assert c_s == 0.0 + + +def test_liquidity_pressure_is_zero_when_well_funded() -> None: + model = _build_model(num_sellers=1, num_buyers=0, seller_initial_money=1000.0) + seller = model.sellers[0] + l_s = liquidity_pressure(seller, money_metabolism=1.0, solvency_horizon=20.0) + assert l_s == 0.0 + + +def test_liquidity_pressure_is_one_when_broke() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + seller = model.sellers[0] + seller.money = 0.0 + l_s = liquidity_pressure(seller, money_metabolism=1.0, solvency_horizon=20.0) + assert l_s == 1.0 + + +def test_liquidity_pressure_clips_between_zero_and_one() -> None: + model = _build_model(num_sellers=1, num_buyers=0, seller_initial_money=5.0) + seller = model.sellers[0] + # required = 1.0 * 20.0 = 20.0; (20 - 5) / 20 = 0.75 + l_s = liquidity_pressure(seller, money_metabolism=1.0, solvency_horizon=20.0) + assert l_s == pytest.approx(0.75) + + +def test_liquidity_pressure_zero_with_zero_metabolism() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + seller = model.sellers[0] + seller.money = 0.0 + assert liquidity_pressure(seller, money_metabolism=0.0, solvency_horizon=20.0) == 0.0 + + +def test_monopolist_with_no_rivals_prices_above_floor() -> None: + """A single seller has D_s=1 (full captive demand), so with default + positive a1, price should rise strictly above p_floor.""" + model = _build_model(num_sellers=1, num_buyers=3, seller_initial_money=1000.0) + seller = model.sellers[0] + _place(model.sellers, [(0, 0)], model) + _place(model.buyers, [(0, 1), (1, 0), (0, 2)], model) + + update_seller_prices(model) + + assert seller.posted_price > model.config.p_floor + + +def test_update_seller_prices_uses_last_tick_prices_simultaneously() -> None: + """Two sellers anchoring to each other must both see the OTHER's price + from before this tick's update, not a half-updated value.""" + a2 = 0.5 # must stay < 1.0 (price-stability guard); simultaneity holds regardless + model = _build_model( + num_sellers=2, num_buyers=0, a1=0.0, a3=0.0, a2=a2, + competitor_anchoring_length_scale=5.0, + ) + seller_a, seller_b = model.sellers + _place(model.sellers, [(0, 0), (1, 0)], model) + seller_a.posted_price = 1.0 + seller_b.posted_price = 5.0 + + update_seller_prices(model) + + p_floor = model.config.p_floor + expected_a = p_floor * (1 + a2 * ((5.0 - p_floor) / p_floor)) + expected_b = p_floor * (1 + a2 * ((1.0 - p_floor) / p_floor)) + assert seller_a.posted_price == pytest.approx(expected_a) + assert seller_b.posted_price == pytest.approx(expected_b) + + +def test_update_seller_prices_does_nothing_with_no_sellers() -> None: + model = _build_model(num_sellers=1, num_buyers=0) + model.sellers = [] # simulate no living sellers + update_seller_prices(model) # must not raise + + +def test_full_model_step_updates_seller_price_away_from_flat() -> None: + """Integration check: running a real model tick with default pricing + weights should move price away from the old flat-p_floor behavior.""" + model = _build_model(num_sellers=1, num_buyers=20) + seller = model.sellers[0] + price_before = seller.posted_price + model.step() + assert seller.posted_price != pytest.approx(price_before) + + +def test_full_model_step_applies_seller_metabolism() -> None: + model = _build_model(num_sellers=1, num_buyers=0, seller_money_metabolism=2.0) + seller = model.sellers[0] + money_before = seller.money + model.step() + assert seller.money == pytest.approx(max(0.0, money_before - 2.0)) + + +# --- loyalty markup (a4 * Y_s) tests ---------------------------------------- + + +def test_lock_in_premium_zero_when_no_loyal_buyers() -> None: + model = _build_model(num_sellers=1, num_buyers=4) + seller = model.sellers[0] + # Counts below threshold K=3 -- no buyer qualifies. + seller.loyalty_counts = {1: 1, 2: 2} + y_s = lock_in_premium(seller, loyalty_threshold_k=3, n_bar=4.0) + assert y_s == 0.0 + + +def test_lock_in_premium_counts_only_buyers_at_or_above_threshold() -> None: + model = _build_model(num_sellers=1, num_buyers=4) + seller = model.sellers[0] + # Buyers 1 and 3 qualify (counts >= 3), buyer 2 does not. + seller.loyalty_counts = {1: 5, 2: 2, 3: 3} + n_bar = 4.0 + y_s = lock_in_premium(seller, loyalty_threshold_k=3, n_bar=n_bar) + assert y_s == pytest.approx(2 / n_bar) + + +def test_lock_in_premium_zero_when_n_bar_is_zero() -> None: + model = _build_model(num_sellers=1, num_buyers=4) + seller = model.sellers[0] + seller.loyalty_counts = {1: 10} + assert lock_in_premium(seller, loyalty_threshold_k=1, n_bar=0.0) == 0.0 + + +def test_lock_in_premium_raises_price_above_base_formula() -> None: + """With a4 > 0 and loyal buyers, posted price must exceed what a4=0 would give.""" + model_base = _build_model(num_sellers=1, num_buyers=4, a4=0.0, a1=0.0, a2=0.0, a3=0.0) + model_loyal = _build_model(num_sellers=1, num_buyers=4, a4=1.0, a1=0.0, a2=0.0, a3=0.0, + loyalty_threshold_k=1) + + _place(model_base.sellers, [(5, 5)], model_base) + _place(model_loyal.sellers, [(5, 5)], model_loyal) + _place(model_base.buyers, [(6, 5), (5, 6), (4, 5), (5, 4)], model_base) + _place(model_loyal.buyers, [(6, 5), (5, 6), (4, 5), (5, 4)], model_loyal) + + # Simulate all 4 buyers having bought from the seller at least once. + for buyer in model_loyal.buyers: + model_loyal.sellers[0].loyalty_counts[buyer.unique_id] = 1 + + update_seller_prices(model_base) + update_seller_prices(model_loyal) + + assert model_loyal.sellers[0].posted_price > model_base.sellers[0].posted_price + + +def test_loyalty_count_resets_when_buyer_switches() -> None: + """After a buyer transacts with seller A then seller B, A's loyalty count + for that buyer should be 0 (removed), and B's count should be 1.""" + model = _build_model( + num_sellers=2, num_buyers=1, + a1=0.0, a2=0.0, a3=0.0, a4=0.0, + seller_initial_money=10000.0, + seller_initial_oil=10000.0, + buyer_initial_oil=1.0, + buyer_initial_money=10000.0, + psi_tick=0.01, + psi_move=0.0, + ) + seller_a, seller_b = model.sellers + buyer = model.buyers[0] + + # Place buyer on seller_a and transact manually via _buy_oil. + buyer.cell = seller_a.cell + model._buy_oil(buyer, seller_a, seller_a.posted_price) + assert seller_a.loyalty_counts.get(buyer.unique_id, 0) == 1 + assert buyer.last_seller is seller_a + + # After the first purchase the buyer reaches Cobb-Douglas equilibrium; + # reset oil to 1.0 so it wants to buy again on the second visit. + buyer.oil = 1.0 + # Move buyer to seller_b and transact -- should reset seller_a streak. + buyer.cell = seller_b.cell + model._buy_oil(buyer, seller_b, seller_b.posted_price) + assert seller_a.loyalty_counts.get(buyer.unique_id, 0) == 0 + assert seller_b.loyalty_counts.get(buyer.unique_id, 0) == 1 + assert buyer.last_seller is seller_b + + +def test_loyalty_count_accumulates_on_repeated_purchases() -> None: + model = _build_model( + num_sellers=1, num_buyers=1, + a1=0.0, a2=0.0, a3=0.0, a4=0.0, + seller_initial_money=10000.0, + seller_initial_oil=10000.0, + buyer_initial_oil=1.0, + buyer_initial_money=10000.0, + psi_tick=0.01, + psi_move=0.0, + ) + seller = model.sellers[0] + buyer = model.buyers[0] + buyer.cell = seller.cell + + for expected_count in range(1, 6): + buyer.oil = 1.0 # reset so Cobb-Douglas equilibrium doesn't suppress the purchase + model._buy_oil(buyer, seller, seller.posted_price) + assert seller.loyalty_counts[buyer.unique_id] == expected_count + + +def test_loyalty_count_cleared_on_buyer_death() -> None: + model = _build_model( + num_sellers=1, num_buyers=1, + a1=0.0, a2=0.0, a3=0.0, a4=0.0, + seller_initial_money=10000.0, + seller_initial_oil=10000.0, + buyer_initial_oil=1.0, + buyer_initial_money=10000.0, + psi_tick=0.01, + psi_move=0.0, + ) + seller = model.sellers[0] + buyer = model.buyers[0] + buyer.cell = seller.cell + + model._buy_oil(buyer, seller, seller.posted_price) + assert seller.loyalty_counts.get(buyer.unique_id, 0) == 1 + + buyer.oil = 0.0 + model._respawn_buyer(buyer) + assert seller.loyalty_counts.get(buyer.unique_id, 0) == 0 diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..114f754 --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,94 @@ +"""Tests for named parameter scenarios and CLI scenario selection.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.scenarios import ( + DEFAULT_SCENARIO, + SCENARIOS, + scenario_names, + scenario_overrides, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_expected_scenarios_exist() -> None: + assert set(scenario_names()) >= {"degenerate", "low_lockin", "moderate_lockin"} + assert DEFAULT_SCENARIO == "degenerate" + + +def test_default_scenario_is_empty_overrides() -> None: + # The default must not change config defaults, so unqualified runs are unchanged. + assert scenario_overrides("degenerate") == {} + + +def test_scenario_overrides_returns_a_copy() -> None: + overrides = scenario_overrides("low_lockin") + overrides["a1"] = 999.0 + assert SCENARIOS["low_lockin"]["a1"] != 999.0 + + +def test_low_lockin_and_moderate_lockin_differ_only_in_a2() -> None: + can = scenario_overrides("low_lockin") + partial = scenario_overrides("moderate_lockin") + assert can["a2"] != partial["a2"] + assert {k: v for k, v in can.items() if k != "a2"} == { + k: v for k, v in partial.items() if k != "a2" + } + + +def test_unknown_scenario_raises() -> None: + import pytest + + with pytest.raises(KeyError): + scenario_overrides("does_not_exist") + + +def test_every_scenario_builds_a_valid_config() -> None: + for name in scenario_names(): + config = ModelConfig(seed=1, steps=1, **scenario_overrides(name)) + config.validate() # must not raise + SpatialMarketModel(config) # must construct + + +def _load_run_model_main(): + spec = importlib.util.spec_from_file_location( + "run_model_cli", REPO_ROOT / "scripts" / "run_model.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.main + + +def test_cli_applies_scenario_and_records_it(tmp_path: Path) -> None: + main = _load_run_model_main() + out = tmp_path / "scn" + + main(["--seed", "1", "--steps", "3", "--scenario", "low_lockin", "--output-dir", str(out)]) + + metadata = json.loads((out / "run_metadata.json").read_text()) + assert metadata["scenario"] == "low_lockin" + # the low_lockin scenario sets a1=0.3, a2=0.0 + assert metadata["config"]["a1"] == 0.3 + assert metadata["config"]["a2"] == 0.0 + + +def test_cli_explicit_flag_overrides_scenario(tmp_path: Path) -> None: + main = _load_run_model_main() + out = tmp_path / "ovr" + + # low_lockin sets num_sellers=20; an explicit flag must win. + main( + [ + "--seed", "1", "--steps", "3", "--scenario", "low_lockin", + "--num-sellers", "5", "--output-dir", str(out), + ] + ) + + metadata = json.loads((out / "run_metadata.json").read_text()) + assert metadata["config"]["num_sellers"] == 5 diff --git a/tests/test_suboptimal_lockin.py b/tests/test_suboptimal_lockin.py new file mode 100644 index 0000000..678fe50 --- /dev/null +++ b/tests/test_suboptimal_lockin.py @@ -0,0 +1,197 @@ +"""Tests for the suboptimal-lock-in flag (2d, README §6).""" + +from __future__ import annotations + +import math + +import pytest + +from spatial_market_lockin import ModelConfig, SpatialMarketModel +from spatial_market_lockin.lockin import ( + is_revealed_suboptimal, + is_suboptimally_locked_in, + suboptimal_lock_in_rate, +) +from spatial_market_lockin.metrics import summarize_run, switch_rate_among_traders + + +def _two_seller_model() -> SpatialMarketModel: + model = SpatialMarketModel( + ModelConfig(seed=1, grid_size=11, num_buyers=0, num_sellers=2, psi_move=1.0) + ) + return model + + +def _args(model): + c = model.config + return dict( + grid_size=c.grid_size, + torus=c.torus, + psi_tick=c.psi_tick, + psi_move=c.psi_move, + ) + + + +def test_not_flagged_when_two_sellers_reachable() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + near, far = model.sellers + near.cell = model.cell_at((5, 4)) + far.cell = model.cell_at((5, 6)) + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) # distance 1 to both + buyer.oil = 100.0 # can reach both + + assert not is_suboptimally_locked_in(buyer, model.sellers, **_args(model)) + + +def test_flagged_when_locked_and_cheaper_seller_unreachable() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + near, far = model.sellers + near.cell = model.cell_at((5, 5)) # distance 0 -> reachable + far.cell = model.cell_at((0, 0)) # far away -> unreachable + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + buyer.oil = 1.5 # affords the on-cell seller only + + # Believes the unreachable far seller is cheaper than the reachable near one. + buyer.beliefs[near.unique_id] = 9.0 + buyer.beliefs[far.unique_id] = 1.0 + + assert is_suboptimally_locked_in(buyer, model.sellers, **_args(model)) + + +def test_not_flagged_when_reachable_seller_is_the_cheapest_believed() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + near, far = model.sellers + near.cell = model.cell_at((5, 5)) + far.cell = model.cell_at((0, 0)) + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + buyer.oil = 1.5 # locked to the near seller + + # The reachable seller is believed cheaper -> harmless attachment, not suboptimal. + buyer.beliefs[near.unique_id] = 1.0 + buyer.beliefs[far.unique_id] = 9.0 + + assert not is_suboptimally_locked_in(buyer, model.sellers, **_args(model)) + + +def test_rate_is_nan_with_no_buyers() -> None: + model = _two_seller_model() + assert math.isnan(suboptimal_lock_in_rate([], model.sellers, model.config)) + + +def test_reporter_present_and_bounded_in_run() -> None: + model = SpatialMarketModel( + ModelConfig(seed=3, grid_size=12, num_buyers=20, num_sellers=4, steps=15) + ) + model.run() + df = model.datacollector.get_model_vars_dataframe() + assert "suboptimal_lock_in_rate" in df.columns + finite = df["suboptimal_lock_in_rate"].dropna() + assert ((finite >= 0.0) & (finite <= 1.0)).all() + + +# --- conditional switch rate ---------------------------------------------- + + +def test_switch_rate_among_traders_excludes_never_transacted() -> None: + # counts: two never traded (0), two used one seller, two switched. + counts = [0, 0, 1, 1, 2, 2] + # among the 4 traders, 2 switched -> 0.5 + assert switch_rate_among_traders(counts) == pytest.approx(0.5) + + +def test_switch_rate_among_traders_nan_when_no_traders() -> None: + assert math.isnan(switch_rate_among_traders([0, 0, 0])) + + +# --- revealed-suboptimal lock-in (behavioural welfare flag) ---------------- + + +def test_revealed_suboptimal_when_loyal_to_pricier_believed_seller() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + used, other = model.sellers + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + # Bought only from `used`; believes it is dear and `other` is cheap. + buyer.lifetime_sellers = {used} + buyer.beliefs[used.unique_id] = 9.0 + buyer.beliefs[other.unique_id] = 1.0 + + assert is_revealed_suboptimal(buyer, model.sellers) + + +def test_not_revealed_suboptimal_when_loyal_seller_is_cheapest_believed() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + used, other = model.sellers + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + buyer.lifetime_sellers = {used} + buyer.beliefs[used.unique_id] = 1.0 + buyer.beliefs[other.unique_id] = 9.0 + + assert not is_revealed_suboptimal(buyer, model.sellers) + + +def test_not_revealed_suboptimal_when_buyer_used_two_sellers() -> None: + from spatial_market_lockin.agents import Buyer + + model = _two_seller_model() + used, other = model.sellers + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + buyer.lifetime_sellers = {used, other} # switched -> not revealed-locked-in + + assert not is_revealed_suboptimal(buyer, model.sellers) + + +def test_revealed_suboptimal_two_used_sellers_is_deterministic() -> None: + """Regression: with two used sellers and a third unused one, the flag must + not depend on set-iteration order (Seller objects hash by identity, so that + order varies between process runs). The reference is the cheapest *used* + seller; the comparison is against sellers the buyer never used. + """ + + from spatial_market_lockin.agents import Buyer + + model = SpatialMarketModel( + ModelConfig(seed=1, grid_size=11, num_buyers=0, num_sellers=3, psi_move=1.0) + ) + used_a, used_b, unused = model.sellers + buyer = Buyer(model) + buyer.cell = model.cell_at((5, 5)) + # Used two sellers (believed 3.0 and 5.0); an unused seller is believed + # cheaper (1.0) than the best used deal -> revealed-suboptimal, regardless + # of which used seller iteration happens to visit first. + buyer.lifetime_sellers = {used_a, used_b} + buyer.beliefs[used_a.unique_id] = 3.0 + buyer.beliefs[used_b.unique_id] = 5.0 + buyer.beliefs[unused.unique_id] = 1.0 + assert is_revealed_suboptimal(buyer, model.sellers) + + # Unused seller dearer than the cheapest used deal -> not flagged. + buyer.beliefs[unused.unique_id] = 4.0 + assert not is_revealed_suboptimal(buyer, model.sellers) + + +def test_summary_reports_revealed_suboptimal_rate() -> None: + model = SpatialMarketModel( + ModelConfig(seed=3, grid_size=12, num_buyers=20, num_sellers=4, steps=20) + ) + model.run() + summary = summarize_run(model) + assert "revealed_suboptimal_rate" in summary + rate = summary["revealed_suboptimal_rate"] + assert math.isnan(rate) or 0.0 <= rate <= 1.0 diff --git a/tests/test_viz.py b/tests/test_viz.py index 25a4de0..97e9b96 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -36,7 +36,7 @@ def test_large_grid_chart_can_be_serialized() -> None: spec = _market_chart(model).to_dict() - assert spec["title"] == "Market state | step 0" + assert spec["title"] == "Market state | tick 0" def test_agent_frames_include_buyers_and_sellers() -> None: