Skip to content

codegithubka/Spatial-Market-Lock-In-ABM

Repository files navigation

Spatial Market Lock-In ABM

An agent-based model of supplier lock-in in a spatial market. Mobile buyers roam a 2-D lattice harvesting money and spending it on oil (their fuel), bought from fixed sellers. The model studies a single question from the bottom up:

Under what conditions do buyers become locked in to one seller, what drives that lock-in, and at what collective cost — without any of it being programmed in?

Built on a Sugarscape resource substrate (Mesa 3), with posted-price Stackelberg sellers, Cobb-Douglas buyer welfare, EMA price beliefs, and loss aversion. Lock-in emerges from spatial movement costs and bounded-rational behaviour rather than being imposed by a switching-cost parameter.

Python 3.11+ · Mesa 3 · SALib · managed with uv


Contents


Quick start

The project is managed with uv.

# install (core + dev tools)
uv sync --extra dev

# run a single simulation
uv run python scripts/run_model.py --seed 42 --steps 1000 --scenario moderate_lockin

# launch the interactive Solara dashboard
uv run solara run src/spatial_market_lockin/viz.py

# run the test suite
uv run pytest

A single run writes to outputs/run_seed_<seed>_steps_<steps>/ (override with --output-dir):

File Contents
model_timeseries.csv per-tick model metrics (prices, counts, lock-in rate, transactions)
transactions.csv full transaction ledger — one row per oil purchase
final_buyers.csv / final_sellers.csv agent state at the final tick
run_metadata.json run config plus run-level summary diagnostics

scripts/run_model.py accepts --seed, --steps, --scenario, --num-buyers, --num-sellers, --grid-size, and --output-dir.


Project structure

src/spatial_market_lockin/   # the model package
  model.py                   # SpatialMarketModel — the six-phase tick loop
  agents.py                  # Buyer and Seller agents
  config.py                  # ModelConfig dataclass (all parameters + defaults)
  scenarios.py               # named parameter scenarios (degenerate / low / moderate lock-in)
  pricing.py                 # seller pricing rule (captive / anchor / liquidity / lock-in)
  lockin.py                  # the five lock-in measures
  metrics.py                 # per-tick series + run-level summary (summarize_run)
  geometry.py                # Chebyshev distance / reachability helpers
  ledger.py                  # transaction ledger
  export.py                  # CSV / JSON single-run export
  viz.py                     # SolaraViz dashboard

scripts/                     # single runs, diagnostics, and figure generation
sa/                          # sensitivity-analysis pipeline (OFAT + Sobol, SALib)
slurm/                       # SLURM batch scripts for running the SA on a cluster
notebooks/sa_analysis.ipynb  # baseline + SA visualisation (decoupled from the model)
tests/                       # 157 focused tests
outputs/ , images/           # committed baseline + SA results and figures

Interactive visualization

A live SolaraViz dashboard renders the model in the browser — the spatial grid, current-tick and lifetime metric tables, and live price / lock-in / money time series, with parameter sliders and play/step controls.

uv run solara run src/spatial_market_lockin/viz.py

Then open http://localhost:8765. Reading the grid:

  • Green cells — environmental money (darker = richer); buyers harvest it.
  • Orange squares — fixed sellers. A circle sitting on a square is a buyer transacting there.
  • Circles — mobile buyers, coloured by oil (fuel) on a red→blue scale: red = nearly out of fuel and about to die, blue = healthy. The spread of red across the board is the survival pressure that drives lock-in.

Hover any agent or cell for its full state. Adjust the sliders (grid size, agent counts, oil burn, pricing weights a1a4, loyalty threshold) and the model restarts with the new parameters.

If uv is not found (e.g. it is not on your shell PATH), run the dashboard straight from the project virtual environment instead — no uv required:

.venv/bin/solara run src/spatial_market_lockin/viz.py

How the model works

Space and resources

  • Grid. Mesa 3 OrthogonalMooreGrid over a G × G lattice, toroidal by default. Distance is Chebyshev (Moore neighbourhood).
  • Money is an environmental field (the "sugar" analogue): each cell holds g_c ≥ 0 money, capped at money_max, regrowing stochastically each tick. A buyer landing on a cell harvests all of it (take-all, cell zeroed).
  • Oil is private seller inventory, replenished r_o per tick up to O_max. It is the buyer's fuel: burned every tick (psi_tick) and per unit of movement (psi_move × distance). The oil spent reaching a rival seller is the switching cost — distance sets how costly it is to leave the current one.

Both sides can die of exhaustion: a buyer at oil = 0 and a seller at money = 0. Dead buyers respawn at a random cell with no beliefs; dead sellers respawn (if respawn_sellers) with no loyalty history.

Agents

Buyer (mobile) Seller (fixed)
Holds money, oil money, oil inventory, posted price
Knows per-seller EMA price beliefs (prior m₀ for unvisited); cells within Chebyshev range v previous-tick rival prices; nearby buyers; per-buyer purchase streaks
Dies when oil = 0 money = 0
Acts by moving to the cell of highest projected welfare, then trading reposting a price each tick

One tick — six phases (fixed order)

  1. Seller pricing. Every living seller reprices from one snapshot of last tick — no seller reacts to a rival mid-tick.
  2. Buyer turns (randomised order). Each buyer scores every cell in range by projected welfare, moves to the best, and harvests/burns oil.
  3. Trade. A buyer on a seller's cell buys the welfare-maximising quantity at the posted price (under loss aversion), then updates its EMA belief.
  4. Seller metabolism / death / respawn.
  5. Regrowth of the money field and seller oil.
  6. Snapshot — the DataCollector records per-tick metrics.

Buyer welfare and movement

A buyer values money w_m and oil w_o by Cobb-Douglas welfare:

W(w_m, w_o) = w_m^αW · w_o^(1−αW),    αW = buyer_money_weight / (buyer_money_weight + psi_tick)

A higher per-tick oil burn raises the marginal value of oil. Each tick the buyer projects, for every visible cell: money harvested, oil burned to reach it, and the best purchase available there (at the believed price) — then moves to the top-scoring cell. Movement, harvesting, and partner choice are one joint welfare comparison. A buyer is reachability-locked when at most one seller satisfies psi_tick + psi_move · d ≤ w_o.

Seller pricing

Each living seller posts a markup over a unit floor, computed from last tick:

p_s(t) = p_floor · (1 + a1·D_s + a2·C_s + a3·L_s + a4·Y_s)
Term Meaning
D_s — captive demand n_s / (N/M), where n_s = buyers strictly closer to s than to any rival (geographic market power).
C_s — competitor anchoring distance-weighted deviation of rivals' prices, kernel exp(−d/ℓ). a2 > 0 compresses prices; stability needs a2 < 1.
L_s — liquidity pressure clip((m·T_h − M_s)/(m·T_h), 0, 1) — rises toward 1 near bankruptcy.
Y_s — lock-in premium (captive buyers of s) / (N/M), a buyer counting as captive once its streak ≥ loyalty_threshold_k. a4 is the channel through which a seller exploits capture.

Beliefs and loss aversion

Prices are hidden until first visit. A buyer holds one EMA belief per visited seller and the cold-start prior m₀ for the rest:

belief ← (1 − α)·belief + α·observed_price,    α = belief_update_weight ∈ (0, 1]

Small α = slow, sticky beliefs (a seller that raised prices still looks cheap until revisited). The prior m₀ is the channel that governs exploration — an unvisited seller is judged entirely by it.

Loss aversion gates the purchase decision, not the welfare function. When the posted price exceeds belief, the overage is amplified λ-fold in the demand comparison, so the buyer buys less — but still pays the true posted price. It is a pure demand-suppression channel: a seller pricing above a buyer's remembered estimate loses volume immediately.

decision_price = belief + λ·(posted − belief)   if posted > belief   (else: posted),    λ ≥ 1

The five lock-in measures

From most structural to most behavioural:

  1. Reachability lock-in (per tick) — |reachable sellers| ≤ 1. Physical trapping.
  2. Lifetime distinct-seller count (per life) — distinct sellers ever bought from, recorded at death. Drives switch_rate and revealed_lock_in.
  3. Suboptimal lock-in (per tick) — reachability-locked and believes some unreachable seller is cheaper. Separates harmful trapping from harmless loyalty.
  4. Revealed-suboptimal (per life) — used ≤ 2 sellers while believing a cheaper one existed: could have switched but didn't. The headline welfare-loss measure.
  5. Streak-suboptimal (per life) — current streak ≥ k while believing a cheaper seller exists. Catches buyers that explored early then got trapped — the case measure 4 misses.

Scenarios

Pass with --scenario. All three are overrides on top of the config defaults (see scenarios.py).

Scenario What it is Switch rate
degenerate raw config defaults; buyers exhaust oil before reaching sellers ~1%
low_lockin calibrated substrate, a2 = 0 — dispersed prices, real reason to switch ~13%
moderate_lockin calibrated substrate, a2 = 0.5 — anchoring compresses prices ~18%

The calibrated substrate (M = 20 sellers, visual range v = 12, psi_tick = 0.25, cold-start prior m₀ = 1.7) lands switching in a 5–25% band — enough mobility to identify the exploration parameters and enough lock-in for the exploitation parameters to act on. moderate_lockin is the analysis baseline.


Metrics

Per-tick series (model_timeseries.csv): avg_posted_price, price_cv (cross-seller dispersion), avg_transaction_price, num_transactions, lock_in_rate, suboptimal_lock_in_rate, total_money, living-agent counts, and per-tick / cumulative deaths.

Run-level summary (run_metadata.json, via summarize_run), computed over completed buyer lives:

Response Meaning
switch_rate fraction of lives using ≥ 2 sellers — the calibration dial
switch_rate_min2_purchases / min3 switch rate among lives with ≥ 2 / ≥ 3 purchases
switching_propensity mean (distinct−1)/(purchases−1) — lifespan-neutral mobility
revealed_lock_in fraction using exactly one seller
revealed_suboptimal_rate headline welfare-loss: ≤ 2 sellers while a cheaper one was believed
streak_suboptimal_rate explored-then-trapped welfare loss
never_transacted fraction of lives with zero purchases
avg_transaction_price / avg_posted_price / price_cv time-averaged price metrics

What emerges at baseline (moderate_lockin, a4 = 0, 10-seed mean, 1000 ticks): prices self-organise to ~1.55, ~9% below the prior; ~69% of lives use a single seller yet only ~16% are revealed-suboptimal (loyalty is common but benign without exploitation); transactions, money, and lock-in oscillate in a sustained ~150–180-tick boom–bust cycle. Activating a4 turns benign loyalty into welfare-reducing exploitation.


Sensitivity analysis

The SA varies five behavioural parameters on the moderate_lockin substrate and scores them against the lock-in / switching / price responses. Definitions live in sa/problem.py.

Parameter Range Role
a4 — lock-in premium weight [0, 2] exploitation intensity; dominates price
prior_price_mean (m₀) [1.2, 2.2] dominates switching / exploration
loyalty_threshold_k [1, 10] (int) how fast exploitation kicks in
loss_aversion (λ) [1, 4] demand suppression above belief
belief_update_weight (α) [0.05, 1] belief adaptation speed

Two complementary methods:

# OFAT screen — each parameter swept independently (15 values × 10 seeds)
uv run python sa/run_ofat.py --distinct 15 --replicates 10 --steps 1000

# Global Sobol — Saltelli sample over the full hypercube (resumable)
uv run python sa/run_sobol.py --n 512 --replicates 10 --steps 1000 --out outputs/sa/sobol_raw.csv

# compute first / total / second-order indices from the raw runs
uv run python sa/analyze_sobol.py --raw outputs/sa/sobol_raw.csv --out outputs/sa

run_sobol.py appends incrementally and resumes — re-running the same command continues a partial sweep. The full Sobol design (N=512, 10 seeds) is 61,440 runs; it ships with SLURM scripts in slurm/ for cluster execution. Figures are produced by notebooks/sa_analysis.ipynb; committed results live in outputs/sa/ and images/sa/.

Headline findings. prior_price_mean dominates buyer mobility (S_T ≈ 0.84 for switch rate); a4 dominates price (S_T ≈ 0.87). Welfare-reducing lock-in is driven by their interaction — the a4 × prior second-order term is the only one cleanly separated from zero. λ and α are minor throughout.


Parameters

Config defaults (the degenerate regime), from config.py. Scenarios override a subset (see Scenarios).

Symbol Config key Default Notes
G grid_size 50 grid side length
N num_buyers 200 buyer count
M num_sellers 8 seller count (substrate: 20)
v visual_range 5 buyer Chebyshev sight radius (substrate: 12)
psi_tick psi_tick 1.0 oil burned per tick (substrate: 0.25)
psi_move psi_move 0.2 oil burned per unit distance
buyer_money_weight 0.5 Cobb-Douglas money preference
money_max 4.0 cell money cap
money_regrowth_rate 1.0 money added to selected cells per tick
money_regrowth_probability 0.02 per-cell regrowth probability
r_o r_o 2.0 oil replenished per seller per tick
O_max o_max 50.0 seller oil capacity
m seller_money_metabolism 1.0 seller money burned per tick
λ loss_aversion 2.25 loss aversion (≥ 1)
α belief_update_weight 0.3 EMA step size, in (0, 1]
a1 a1 1.0 captive-demand weight (substrate: 0.3)
a2 a2 0.5 competitor-anchoring weight (< 1)
a3 a3 1.0 liquidity-pressure weight
a4 a4 0.0 lock-in premium weight
k loyalty_threshold_k 3 consecutive purchases for loyalty
competitor_anchoring_length_scale 10.0 anchoring distance decay
T_h solvency_horizon_ticks 20.0 seller solvency horizon
m₀ prior_price_mean 2.0 cold-start belief (substrate: 1.7)
p_floor p_floor 1.0 seller cost floor
respawn_sellers True replace dead sellers at a random cell

Testing

uv run pytest            # 157 tests

The suite covers config, movement, pricing, beliefs, loss aversion, the five lock-in measures, the ledger, metrics, exports, and a whole-model end-to-end contract test (determinism, summary-key contract, DataCollector schema/bounds, population conservation, death bookkeeping). Runs are deterministic given a seed.


Citation

Anagnostopoulos, K., De Martin, A., Metasch, L., Putignano, E. Supplier Lock-In in Spatial Markets: An Agent-Based Model of Exploration and Exploitation. Agent-Based Modelling, University of Amsterdam.

A full ODD+ specification of the model is given in the accompanying report.

License

Released under the MIT License.


Reading convention. Many concrete parameter values are heuristics chosen for tractability, not derived from theory. Nothing in the implementation adds a behavioural channel beyond what the model description specifies.

About

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

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages