Skip to content
 
 

Repository files navigation

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, 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. Nothing here adds a behavioural channel beyond what the spec calls for.


Running the model

The implementation uses uv, Mesa 3, and SolaraViz.

uv sync --extra dev
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

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_<seed>_steps_<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:

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.

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.

2 — Buyer turns. Each alive buyer acts in sequence:

  • 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).

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).

4 — Seller oil regrowth. Each living seller's oil stock grows by r_o, capped at O_max.

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 × 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, 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, 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.


3. Agents

3.1 Buyers (N)

State Symbol Notes
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 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 and competitor-anchoring terms). Each seller acts only on its own price — there is no coordination or communication between sellers.

4. Buyer welfare: Cobb-Douglas

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^α × w_o^(1−α)
α = buyer_money_weight / (buyer_money_weight + psi_tick)

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

The buyer moves to the highest-scoring cell. Ties broken by shorter distance, then lower coordinate.

4.1 Optimal purchase quantity

At a seller with believed price p, the Cobb-Douglas unconstrained optimum is:

q* = ((1 − α) × w_m / p) − (α × w_o)

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.

4.2 Loss aversion

Loss aversion gates the purchase decision, not the welfare function itself.

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

5.1 Seller pricing game

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 + a2×C_s + a3×L_s + a4×Y_s)

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.

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)

  • Leader: seller commits to p_s(t).
  • 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 EMA price beliefs

Price is hidden until first visit; each subsequent visit updates the buyer's belief via an exponential moving average:

Initial belief (unvisited):  prior_price_mean
Update on each visit:        belief = (1 − α) × old_belief + α × observed_price

α = 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

Four complementary measures, from most structural to most behavioural:

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.

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.

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.

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.


7. Metrics

Per-tick time series (DataCollector)

Recorded once per tick in model_timeseries.csv. Each row is one tick.

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

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

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.


8. Sensitivity Analysis

All sweepable parameters

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

About

An agent-based model of supplier lock-in in a spatial market

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages