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.
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 pytestNamed 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.pyRuns baseline vs lock-in premium side-by-side and saves scripts/metrics_output.png.
- Mesa 3
OrthogonalMooreGridwith 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_sellerstoggle). - 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.
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).
- Engine: Mesa 3 (Python),
OrthogonalMooreGridover aG × Glattice. - 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.
Money is an environmental field (the "sugar" analogue).
- Cell
choldsg_c >= 0money, capped atmoney_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_probabilityand gainsmoney_regrowth_rate, capped bymoney_max.[HEURISTIC] - A buyer landing on
charvests all ofg_cimmediately (take-all, cell zeroed).[HEURISTIC]
Oil is seller inventory.
- Seller
sholdsO_s <= O_max, replenished byr_oper tick. - Sellers earn money from oil sales and burn
seller_money_metabolismper tick. A seller whose money reaches zero dies; ifrespawn_sellers=Truea fresh seller is immediately placed at a random cell with a new identity (newunique_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.
| 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_meanused 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.
| 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.
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
ccontains 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.
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.
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.
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:
a2governs price convergence — higha2makes all sellers charge similar prices, removing the economic incentive to switch.a4+loyalty_threshold_kgovern exploitation — sellers needkconsecutive purchases before the premium appears; once it does it grows as more buyers accumulate streaks.a4anda2interact: ifa2is high and prices converge, the lock-in premium creates the only remaining price dispersion.
- Leader: seller commits to
p_s(t). - Follower gate: buyer computes
decision_price(loss-adjusted, §4.2) and optimal quantityq*at that price. Ifq* ≤ 0no transaction occurs. - Settlement: buyer pays
q* × p_s(actual posted price, not decision price), receivesq*oil. Seller receives money, delivers oil. - Records: transaction appended to the permanent ledger; buyer's
lifetime_sellersupdated; loyalty streak incremented (or reset if switching).
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.
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.
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 |
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 |
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.
| 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 |
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.
| 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 |