Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bfc1413
Add dynamic seller pricing game and lock-in measurement (README §5.1,…
annademartin Jun 17, 2026
2837a71
what changed
annademartin Jun 17, 2026
198b2c2
transaction dataclass and test added
codegithubka Jun 18, 2026
e264b56
metric collection functions added and called in model.py
codegithubka Jun 18, 2026
b78cd87
exports handling
codegithubka Jun 18, 2026
e164310
pricing dynamics baselines added that permit switchting to different …
codegithubka Jun 18, 2026
2eaa0e1
beliefs updated
codegithubka Jun 18, 2026
a32aad6
progress file
codegithubka Jun 18, 2026
13028fe
beliefs for buyer perception
codegithubka Jun 18, 2026
6b41b51
Risk Loss aversion gates implemented
codegithubka Jun 18, 2026
a13c388
progress report for today; please fill in any other changes to this v…
codegithubka Jun 18, 2026
5367606
Lock in measures
codegithubka Jun 18, 2026
b9bdea8
Add seller death/respawn and loyalty markup pricing (a4*Y_s)
Edoardo123410 Jun 18, 2026
67ef14c
Update README. All roadmap items should be now implemented. Should wo…
Edoardo123410 Jun 18, 2026
f80da6e
Add metrics dashboard components and update viz defaults
Edoardo123410 Jun 18, 2026
b92fc2b
Replace Bayesian beliefs and risk aversion with EMA
Edoardo123410 Jun 19, 2026
a61829e
Replace Bayesian beliefs and risk aversion with EMA (source + tests)
Edoardo123410 Jun 19, 2026
6a3b424
Remove safety_horizon_ticks: reachability uses no oil buffer
Edoardo123410 Jun 19, 2026
a7ce411
added new metrics, checked code
Edoardo123410 Jun 19, 2026
ee0f9d3
viz update
Edoardo123410 Jun 19, 2026
85992a0
runs comparison with a4=0, a4=1
Edoardo123410 Jun 19, 2026
2cb4850
model behavior and calibration tests
codegithubka Jun 20, 2026
361dd8f
changes in plotting, baseline expected price prior, metrics addition,…
codegithubka Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.venv/
.claude/
.uv-cache/
.matplotlib-cache/
__pycache__/
Expand All @@ -17,5 +18,4 @@ results/
*.db
archive/
AGENTS.md


.vscode/
436 changes: 270 additions & 166 deletions README.md

Large diffs are not rendered by default.

Binary file added images/overlay_correlation_moderate_lockin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/scenario_diagnostics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/seller_diag_moderate_lockin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
281 changes: 281 additions & 0 deletions progress_reports/progress_report.md
Original file line number Diff line number Diff line change
@@ -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.
91 changes: 91 additions & 0 deletions scripts/baseline_confirmation.py
Original file line number Diff line number Diff line change
@@ -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()
Loading