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
- Quick start
- Project structure
- Interactive visualization
- How the model works
- Scenarios
- Metrics
- Sensitivity analysis
- Parameters
- Testing
- Citation
- License
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 pytestA 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.
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
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.pyThen 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 a1–a4, loyalty threshold) and the model
restarts with the new parameters.
If
uvis not found (e.g. it is not on your shellPATH), run the dashboard straight from the project virtual environment instead — nouvrequired:.venv/bin/solara run src/spatial_market_lockin/viz.py
- Grid. Mesa 3
OrthogonalMooreGridover aG × Glattice, toroidal by default. Distance is Chebyshev (Moore neighbourhood). - Money is an environmental field (the "sugar" analogue): each cell holds
g_c ≥ 0money, capped atmoney_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_oper tick up toO_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.
| 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 |
- Seller pricing. Every living seller reprices from one snapshot of last tick — no seller reacts to a rival mid-tick.
- Buyer turns (randomised order). Each buyer scores every cell in range by projected welfare, moves to the best, and harvests/burns oil.
- 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.
- Seller metabolism / death / respawn.
- Regrowth of the money field and seller oil.
- Snapshot — the DataCollector records per-tick metrics.
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.
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. |
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
From most structural to most behavioural:
- Reachability lock-in (per tick) —
|reachable sellers| ≤ 1. Physical trapping. - Lifetime distinct-seller count (per life) — distinct sellers ever bought
from, recorded at death. Drives
switch_rateandrevealed_lock_in. - Suboptimal lock-in (per tick) — reachability-locked and believes some unreachable seller is cheaper. Separates harmful trapping from harmless loyalty.
- Revealed-suboptimal (per life) — used ≤ 2 sellers while believing a cheaper one existed: could have switched but didn't. The headline welfare-loss measure.
- Streak-suboptimal (per life) — current streak ≥
kwhile believing a cheaper seller exists. Catches buyers that explored early then got trapped — the case measure 4 misses.
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.
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.
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/sarun_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.
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 |
uv run pytest # 157 testsThe 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.
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.
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.