Compare commits

..

142 Commits

Author SHA1 Message Date
4667a1678f chore minor paper edits 2026-02-25 09:16:00 +01:00
a4b7b5b4b2 improements of the methodology for now almost ready tosubmit 2026-02-19 18:28:40 +01:00
1a9901f118 refactored training approaches 2026-02-19 18:23:08 +01:00
5912062dc0 new trainer image 2026-02-19 13:03:25 +01:00
843564eeb0 TPU startup scripts 2026-02-19 13:03:03 +01:00
9acc998cc9 fixing models for gcp 2026-02-17 16:54:55 +01:00
802f31b4a1 adding naive jax and libraries and make adjustments 2026-02-17 14:48:18 +01:00
66c4a0cd1d chore: fix chips used 2026-02-17 14:46:43 +01:00
244af9ac09 citing compute 2026-02-17 14:46:34 +01:00
76c31a2abd citing marc and 2026-02-17 09:40:20 +01:00
64ee7e6d9b forcing light mode 2026-02-16 11:30:18 +01:00
1e04a928aa migrated new banner 2026-02-15 17:31:31 +01:00
9b133cddfd introduce penalized sessions to episodes 2026-02-15 17:15:25 +01:00
ded7290935 hidef banner rendering 2026-02-15 17:12:12 +01:00
8e4dd59f90 banner rendering 2026-02-15 17:10:16 +01:00
024f6d4132 banner addition 2026-02-15 17:10:13 +01:00
2b47c3499a chore: fixing discretization of actions 2026-02-15 15:45:46 +01:00
ef1d1f6557 fixing assumption definition 2026-02-14 21:54:42 +01:00
d7657db287 reintroducing our note :) 2026-02-14 21:49:40 +01:00
e8229ac313 updating methodology with better refelction 2026-02-14 15:20:38 +01:00
bc6c481d03 minor refactors to codebase to implement DRO 2026-02-14 14:53:30 +01:00
895eea5674 imporving methodology and adding onto it 2026-02-14 14:28:18 +01:00
fba2a9739e updating paper details 2026-02-14 13:13:00 +01:00
d1aa13360f cleaning refactors 2026-02-13 21:03:02 +01:00
f6f9729424 improving expression of ideas from dump 2026-02-10 18:12:49 +01:00
e22286371f feat: proportiona lrevenu 2026-02-06 11:54:23 +01:00
e44feb7da0 updaing coi definition 2026-02-05 12:47:13 +01:00
ebd2378859 yapping 2026-02-05 12:28:26 +01:00
c4d82b2ecc rescaling the graph 2026-02-02 16:55:06 +01:00
a9e2e7cbf3 improving on the methodlology 2026-02-02 16:52:50 +01:00
e0b074161b fix: typo 2026-02-02 12:08:24 +01:00
08c0afb55a chore: add chart of supra competive pricing 2026-02-02 12:03:30 +01:00
c4fd1352c9 naoice COI implementation 2026-02-02 11:18:37 +01:00
4abef97bf7 chore: adding simulation logging with wandb 2026-01-31 16:21:10 +01:00
33cb0d7e95 feature: refactored demand splitting and implementation 2026-01-31 12:56:48 +01:00
e8ef850089 feat: introduced simple COI proxy 2026-01-31 12:06:48 +01:00
e7cb48e9cd chore: updating paper 2026-01-31 10:47:12 +01:00
Daniel Alves Rösel
dba8f3fafa Merge pull request #44 from velocitatem/agent-behavior-loader-developemen
Agent behavior loader developement + rl loop definition and e2e tests.
2026-01-31 10:21:54 +01:00
Daniel Alves Rösel
9843c5deab Merge pull request #51 from velocitatem/feat-strong-learning-implementation-with-data-contamination
Feat strong learning implementation with data contamination
2026-01-31 10:15:09 +01:00
13959e4b28 chore: bug fixes 2026-01-31 10:13:07 +01:00
Daniel Alves Rösel
2f481bd94b Merge branch 'agent-behavior-loader-developemen' into feat-strong-learning-implementation-with-data-contamination 2026-01-31 10:08:59 +01:00
72877439ca feat: contaminator and training 2026-01-31 09:48:20 +01:00
0f5f8affab chore: make lib backwards compatible 2026-01-31 09:48:20 +01:00
ee70f02a1f chore: export repeated methods into lib 2026-01-31 09:48:20 +01:00
22a2c255bd chore: remove boilerplate 2026-01-31 09:48:20 +01:00
ccc19f3493 acapting some architectures 2026-01-31 09:48:20 +01:00
00e3eff2fa migrating weak learning 2026-01-31 09:48:20 +01:00
440371dba4 feat: initial feature engineering of trajectories 2026-01-31 09:48:20 +01:00
b05b510f70 strong dataset gathering 2026-01-31 09:48:20 +01:00
04907df393 feat: weak train scaffold 2026-01-31 09:48:20 +01:00
b2f0746c01 chore: extra commenting 2026-01-31 09:48:20 +01:00
7b2d80ac4c feat: wip contaminator 2026-01-31 09:48:20 +01:00
0ce12fbc3b chore: ignores 2026-01-31 09:48:17 +01:00
e9cf5f0736 refactor models computations 2026-01-31 09:46:44 +01:00
82b54428b7 chore: refactor the loader class 2026-01-31 09:46:44 +01:00
87a35fad2c feat: joint loader 2026-01-31 09:46:44 +01:00
af23d2f736 feat: introduction of agentinc MDPs and KL divergence of > 2 2026-01-31 09:46:44 +01:00
9cb2b0fc44 feat: forgot airflow helper staging 2026-01-31 09:46:44 +01:00
7c330a19c6 feat: added a runner script for agent orchestration 2026-01-31 09:46:44 +01:00
Daniel Alves Rösel
eb95060380 Pre run web refactors (#43)
* chore: refactor date utilities

* feat: improve images of hotel rooms

* fix: adding date utils
2026-01-31 09:46:44 +01:00
61dd621532 chore: styling and title updates 2026-01-31 09:46:44 +01:00
4c368d48f2 chore: fixing visual bugs in cart 2026-01-31 09:46:44 +01:00
3c141a4b6c chore: better test consistency before agnet 2026-01-31 09:46:44 +01:00
e89cb263d4 planning 2026-01-31 09:46:44 +01:00
62a4008c29 feat: integration of pipeline hooks into testing 2026-01-31 09:46:44 +01:00
8b429b7a8e chore: refactor to better map end to end 2026-01-31 09:46:44 +01:00
f9bf3de71e pdf rendering 2026-01-31 09:46:44 +01:00
131323ef56 featuer: dot exporter 2026-01-31 09:46:44 +01:00
ec4cf074e6 feature: MDP behavior mappers (unlinked) 2026-01-31 09:46:44 +01:00
6a06a8af4a simple code cleanup 2026-01-31 09:46:44 +01:00
3fa98f375d refactor to align moer with research in the env sims 2026-01-31 09:46:44 +01:00
201c98bcac improved implementation 2026-01-31 09:46:44 +01:00
8a08458478 formlating the reward simply 2026-01-31 09:46:44 +01:00
7d09232e48 high level defintion 2026-01-31 09:46:44 +01:00
20132c084c initial environemnt definitions 2026-01-31 09:46:41 +01:00
26abff5864 chore: fixing tests with seed determinism 2026-01-30 13:57:40 +01:00
4c7d9362af chore: envs for e2e 2026-01-30 13:55:22 +01:00
ea45801845 chore: removing the lab byproduct 2026-01-30 13:22:22 +01:00
Daniel Alves Rösel
574e05d9e0 Merge pull request #50 from velocitatem/new-simulation-environment-development
New simulation environment development
2026-01-30 13:19:53 +01:00
52fe865598 feature: drafting studies directory 2026-01-30 13:18:20 +01:00
28d3f6853e chore: refactor wrapper 2026-01-30 13:17:12 +01:00
10e8397eec chore: bette rplotting 2026-01-29 13:11:52 +01:00
772772b5b9 chore: better wrapping amd more performant 2026-01-29 10:01:53 +01:00
6e06081d60 porting to better 2026-01-28 16:09:28 +01:00
83d9bb2552 chore: properly developing 2026-01-28 14:04:57 +01:00
fa2aca8b13 chore: rough migration of environment configuration 2026-01-26 14:12:41 +01:00
cd6c3d6006 chore: migrating thesis case definition 2026-01-26 13:19:55 +01:00
Daniel Alves Rösel
b5f19e04b7 Paper lit review (#45)
* chore: updating apa citation and fixing citation in-text and parent

* fixing in lit review

* adjusting citations and improving schema

* chore: fixed formating and adjusting other components

* refined abstract

* one page fitting

* constrainative proposals

* fix: syntax of transtion probs

* refined lit review and soruces

* research Objectives

* adding logo graphics

* chore: fixing citation completeness

* updating with newly built algoerith

* lit review document setup
2026-01-26 13:04:32 +01:00
98a9a3738c fix: coi better defined and aligned and sac improved 2026-01-25 10:36:37 +01:00
1224841a82 preliminary improved runs 2026-01-24 23:51:57 +01:00
4033e73ba1 feat: consistent failure case 2026-01-24 15:16:41 +01:00
bae51daa1c chore: refactor session mapping 2026-01-24 14:21:35 +01:00
c5eae17924 simple baselines and training setup to be refactored 2026-01-24 13:20:42 +01:00
28669ea4c3 win: refomulated and re-inspired from library 2026-01-23 17:16:32 +01:00
b0a1647956 docs 2026-01-23 12:52:58 +01:00
19bb4fd517 chore; ignoreing build of docs 2026-01-23 10:37:48 +01:00
4e2e41d943 shock: defining new lab environment and formulation 2026-01-23 10:37:32 +01:00
a033e77697 intorducing jax for computation 2026-01-22 21:02:10 +01:00
40e0b201e6 chore: init code for jax core 2026-01-22 13:10:15 +01:00
a217d53556 feat: translating features to jax 2026-01-22 13:10:01 +01:00
a6e6cc5d60 feat: baseline setup for RL modeling 2026-01-22 12:52:41 +01:00
fa89347c4e feat: expanding market observation space 2026-01-22 11:48:24 +01:00
2b3d937be6 feat: fixing alignment w premiums and specific extraction of data 2026-01-22 11:46:32 +01:00
20c47fe85f review: planning environment refactoring 2026-01-22 11:40:47 +01:00
b7161573d7 chore: mini docs 2026-01-22 11:40:27 +01:00
c15bb1882e chore: training and data refactors 2026-01-22 11:40:12 +01:00
dee6f573e3 feat: contaminator and training 2026-01-21 19:12:56 +01:00
2ed200f870 chore: make lib backwards compatible 2026-01-21 19:12:35 +01:00
56308ecb10 chore: export repeated methods into lib 2026-01-21 19:12:11 +01:00
7fcd18c3cb chore: remove boilerplate 2026-01-21 19:11:54 +01:00
5f607a58eb acapting some architectures 2026-01-21 18:22:39 +01:00
6aad196234 migrating weak learning 2026-01-21 18:22:31 +01:00
e5060babfa feat: initial feature engineering of trajectories 2026-01-21 14:05:39 +01:00
80863e9b17 strong dataset gathering 2026-01-21 14:05:30 +01:00
a5029f2eab feat: weak train scaffold 2026-01-21 11:27:03 +01:00
c102ac482e chore: extra commenting 2026-01-21 11:11:49 +01:00
08ade8dc89 feat: wip contaminator 2026-01-20 21:00:47 +01:00
95d4f0cee2 chore: ignores 2026-01-13 19:50:36 +01:00
Daniel Alves Rösel
a9d73ccce5 Paper first fillout (#39)
* initial environemnt definitions

* high level defintion

* formlating the reward simply

* improved implementation

* tailored docker compose image for secondary tenaordboard

* preliminary desriptions and babble

* details on formulation and defintion of agent and its loop

* typos one

* more grammar issues

* fluidity improvements and refactors

* more decluttering and dnoising

* finalizing introduction review

* some methodology

* somehow this disappeared

* bit more of this and that

* methodology of how we do architectuer and online DP

* fix: compilation

* expanding on the taxonomy and economic references

* authoer notes

* acks + google GCP

* making space w new format nada lit review

* stronger lit review and more sources

* forgot about tables and graphs

* dedupe citations

* adding cloudflare

* fixing env vars

* updating docs with url

* upating embed

* fixing the url

* paper badge

* formaliztaion of rewards and adding definitions

* noisy formulations

* connecting some more dots here

* adding significant weight in prices

* fixing error

* fixing typos and consistency

* extra math formulations and refferenceot DRO

* fixing diagram of loops

* github mindmap

* fixing erro and thiknig about big picture

* enhancing the website

* goals methodology and gitignore

* some more references and theory links

* talking about some wtp

* feature: added wordcounter

* forcing latex builds and fixining the bib #

* refactor: update Cost of Information equations and notation for clarity

* some more math and refactors

* refactor: unify notation and improve clarity in COI equations

* refactor: generalize master function for demand estimation and pricing strategies

* we dont like math but we have to do it :(

* refactor: enhance Cost of Information framework with additional context and illustration

* refactor: enhance literature review and methodology sections with economic theory insights and system architecture details

* alining format to fit the rubric

* refactoring bibliography

* fix: align

* mdp additionally

* trying different title

* adding balance figure

* agentic givergence, finally

* fix: figure fonts adjusted to match
2026-01-13 17:07:29 +01:00
3072e5f46e refactor models computations 2026-01-13 16:51:00 +01:00
a1e3166322 chore: refactor the loader class 2026-01-13 16:46:17 +01:00
6f361b96a8 feat: joint loader 2026-01-13 16:42:50 +01:00
eea019ab3f feat: introduction of agentinc MDPs and KL divergence of > 2 2026-01-13 15:57:05 +01:00
a36973cb42 feat: forgot airflow helper staging 2026-01-13 15:37:06 +01:00
96180e9af1 feat: added a runner script for agent orchestration 2026-01-13 15:36:20 +01:00
Daniel Alves Rösel
e60c0c64e1 Pre run web refactors (#43)
* chore: refactor date utilities

* feat: improve images of hotel rooms

* fix: adding date utils
2026-01-13 15:35:27 +01:00
90f57cb9b9 chore: styling and title updates 2026-01-13 15:09:52 +01:00
d865357695 chore: fixing visual bugs in cart 2026-01-13 15:05:33 +01:00
961302a21a chore: better test consistency before agnet 2026-01-12 22:33:47 +01:00
0d214a469f planning 2026-01-12 20:59:09 +01:00
acf731efcb feat: integration of pipeline hooks into testing 2026-01-12 13:37:48 +01:00
9a8525a854 chore: refactor to better map end to end 2026-01-12 11:02:48 +01:00
29f51d56d1 pdf rendering 2026-01-12 11:02:48 +01:00
c56c7f6537 featuer: dot exporter 2026-01-12 11:02:48 +01:00
b1882b6049 feature: MDP behavior mappers (unlinked) 2026-01-12 11:02:48 +01:00
57a7e0c571 simple code cleanup 2026-01-12 11:02:48 +01:00
c8c44d0453 refactor to align moer with research in the env sims 2026-01-12 11:02:48 +01:00
f950565264 tailored docker compose image for secondary tenaordboard 2026-01-12 11:02:48 +01:00
aae124f5ea improved implementation 2026-01-12 11:02:48 +01:00
c5caee21b1 formlating the reward simply 2026-01-12 11:02:48 +01:00
fe7dafed0a high level defintion 2026-01-12 11:02:48 +01:00
fa65fe992d initial environemnt definitions 2026-01-12 11:02:48 +01:00
115 changed files with 14196 additions and 887 deletions

31
.gitignore vendored
View File

@@ -5,18 +5,29 @@
**/.virtual_documents/ **/.virtual_documents/
**/session_*.svg **/session_*.svg
**/*graph.svg **/*graph.svg
paper/src/bib/auto **/auto/*.el
*.old
**/package-lock.json
**/*.parquet
**/_build/
# Airflow logs - exclude DAG run logs paper/src/bib/auto
**/_build/
paper/src/auto/*
paper/src/bib/auto
paper/template/*
docs/goals/*.md
PHANTOM.wiki/
experiments/airflow/logs/* experiments/airflow/logs/*
experiments/airflow/logs/scheduler/ experiments/airflow/logs/scheduler/
experiments/airflow/logs/dag_processor_manager/ experiments/airflow/logs/dag_processor_manager/
experiments/collected_data/* experiments/collected_data/
experiments/agents/collected_data/
paper/src/auto/* sim/rl/behavior_loader/*.dot
lib/ sim/rl/behavior_loader/*.png
docs/goals/*.md sim/rl/behavior_loader/*.svg
PHANTOM.wiki/ sim/rl/behavior_loader/*.pdf
tests/e2e/node_modules/** tests/e2e/node_modules/**
**/auto/*.el lab/case/thesis/runs*/
*.old sim/case/thesis_simplified/runs*/
PHANTOM_web/*

View File

@@ -9,11 +9,43 @@ PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest PYTEST := $(VENV)/bin/pytest
SWEEP_ENV_FILE ?= .env.sweep
WANDB_ENTITY ?=
WANDB_PROJECT ?= phantom-pricing
SWEEP_ID ?=
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
AGENT_COUNT ?= 0
REPO_URL ?=
BRANCH ?= main
WORKDIR ?= $(HOME)/PHANTOM-agent
AGENT_LOOP ?= 1
RETRY_SECONDS ?= 20
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
TPU_NAME ?=
TPU_ZONE ?= us-central2-b
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help .PHONY: help
help: help:
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines" @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | stats.lines"
@echo "docker.train.publish"
@echo ""
@echo "Local wandb run:"
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
@echo ""
@echo "Local sweep agent from this repo:"
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
@echo ""
@echo "Bootstrap private repo worker from anywhere:"
@echo " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id"
@echo ""
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
$(BUILDDIR): $(BUILDDIR):
mkdir -p paper/$(BUILDDIR) mkdir -p paper/$(BUILDDIR)
@@ -49,8 +81,10 @@ test.backend: $(VENV)
test.e2e: test.e2e:
@cd tests/e2e && npm install @cd tests/e2e && npm install
@cd tests/e2e && npx playwright install chromium @cd tests/e2e && npx playwright install chromium
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1) @timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1) @timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
@cd tests/e2e && npm test @cd tests/e2e && npm test
.PHONY: test.all .PHONY: test.all
@@ -68,12 +102,46 @@ $(VENV):
install: $(VENV) install: $(VENV)
$(PIP) install -r requirements.txt $(PIP) install -r requirements.txt
.PHONY: train
train: install
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
$(PYTHON) -m engine.train $(LOCAL_TRAIN_ARGS)
.PHONY: train.agent
train.agent: install
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
$(PYTHON) -m engine.train --sweep-agent --sweep-id "$(SWEEP_ID)" \
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
.PHONY: train.bootstrap
train.bootstrap:
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
@$(SWEEP_ENV_LOAD); test -n "$$GITHUB_TOKEN" || (echo "GITHUB_TOKEN required — set it in $(SWEEP_ENV_FILE)" && exit 1)
@test -n "$(REPO_URL)" || (echo "REPO_URL required, e.g. REPO_URL=https://github.com/org/repo.git" && exit 1)
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
@$(SWEEP_ENV_LOAD); \
WANDB_API_KEY="$$WANDB_API_KEY" \
WANDB_ENTITY="$(WANDB_ENTITY)" \
WANDB_PROJECT="$(WANDB_PROJECT)" \
GITHUB_TOKEN="$$GITHUB_TOKEN" \
REPO_URL="$(REPO_URL)" \
BRANCH="$(BRANCH)" \
WORKDIR="$(WORKDIR)" \
SWEEP_ID="$(SWEEP_ID)" \
AGENT_COUNT="$(AGENT_COUNT)" \
AGENT_LOOP="$(AGENT_LOOP)" \
RETRY_SECONDS="$(RETRY_SECONDS)" \
bash scripts/wandb_agent_bootstrap.sh
.PHONY: stats.lines .PHONY: stats.lines
stats.lines: stats.lines:
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \ @find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l \( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
.PHONY wordcount .PHONY: wordcount
wordcount: wordcount:
@echo "Counting words in main text (excluding appendix)..." @echo "Counting words in main text (excluding appendix)..."
@texcount -nosub -total -sum -1 \ @texcount -nosub -total -sum -1 \
@@ -84,6 +152,24 @@ wordcount:
$(SRCDIR)/chapters/05-discussion.tex \ $(SRCDIR)/chapters/05-discussion.tex \
$(SRCDIR)/chapters/06-conclusion.tex $(SRCDIR)/chapters/06-conclusion.tex
.PHONY: docker.train.publish
docker.train.publish:
docker build -f docker/Trainer.dockerfile --target gpu -t $(TRAIN_IMAGE_REF):gpu-latest .
docker push $(TRAIN_IMAGE_REF):gpu-latest
docker build -f docker/Trainer.dockerfile --target tpu -t $(TRAIN_IMAGE_REF):tpu-latest .
docker push $(TRAIN_IMAGE_REF):tpu-latest
.PHONY: train.tpu.pod
train.tpu.pod:
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
gcloud compute tpus tpu-vm scp scripts/tpu_pod_run.sh $(TPU_NAME):/tmp/tpu_pod_run.sh \
--zone=$(TPU_ZONE) --project=phantom-trc --worker=all
@$(SWEEP_ENV_LOAD); \
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
--zone=$(TPU_ZONE) --project=phantom-trc --worker=all \
--command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh"
.PHONY: pdf clean watch run.webapp test count-lines all .PHONY: pdf clean watch run.webapp test count-lines all
pdf: pdf.build pdf: pdf.build

6
TPUS/README.md Normal file
View File

@@ -0,0 +1,6 @@
64 spot Cloud TPU v6e chips in zone europe-west4-a
32 spot Cloud TPU v4 chips in zone us-central2-b
64 spot Cloud TPU v5e chips in zone us-central1-a
64 spot Cloud TPU v6e chips in zone us-east1-d
32 on-demand Cloud TPU v4 chips in zone us-central2-b
64 spot Cloud TPU v5e chips in zone europe-west4-b

View File

@@ -0,0 +1,22 @@
# 32 spot Cloud TPU v4 chips in zone us-central2-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv4s32spotUC2B
export TPU_NAME=tpu-v4-32-uc2b-spot
export ZONE=us-central2-b
export ACCELERATOR_TYPE=v4-32
export RUNTIME_VERSION=v2-alpha-tpuv4
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

13
TPUS/v4_uscentral2b.sh Normal file
View File

@@ -0,0 +1,13 @@
# 32 on-demand Cloud TPU v4 chips in zone us-central2-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUlong
export ZONE=us-central2-b
export ACCELERATOR_TYPE=v4-32
export RUNTIME_VERSION=v2-alpha-tpuv4
#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION}
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION}

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v5e chips in zone europe-west4-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv5e64spotEW4B
export TPU_NAME=tpu-v5e-64-ew4b
export ZONE=europe-west4-b
export ACCELERATOR_TYPE=v5e-64
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v5e chips in zone us-central1-a
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv5e64spotUC1A
export TPU_NAME=tpu-v5e-64-uc1a
export ZONE=us-central1-a
export ACCELERATOR_TYPE=v5e-64
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v6e chips in zone europe-west4-a
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv6e64spotEW4A
export TPU_NAME=tpu-v6e-64-ew4a
export ZONE=europe-west4-a
export ACCELERATOR_TYPE=v6e-64
export RUNTIME_VERSION=v2-alpha-tpuv6e
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v6e chips in zone us-east1-d
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv6e64spotUE1D
export TPU_NAME=tpu-v6e-64-ue1d
export ZONE=us-east1-d
export ACCELERATOR_TYPE=v6e-64
export RUNTIME_VERSION=v2-alpha-tpuv6e
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -47,53 +47,52 @@ def health() -> dict:
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse) @app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)): def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
"""
THIS is the fast lookup service (mechanism).
Priority: session-keyed price > global optimal price > base price
"""
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0] product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
if not product: raise HTTPException(404, f"Product {productId} not found") if not product: raise HTTPException(404, f"Product {productId} not found")
metadata = product['metadata'] metadata = product['metadata']
base_price = metadata.get('base_price', 100.0) base_price = metadata.get('base_price', 100.0)
# fetch pre-computed prices from registry # PRIORITY 1: session-aware price (computed by Airflow worker)
if sessionId:
session_price = registry.get_session_price(sessionId, productId)
if session_price is not None:
return PriceResponse(
productId=productId,
price=session_price,
base_price=base_price,
markup=session_price/base_price,
elasticity=None,
model_version='session-aware'
)
# PRIORITY 2: global pre-computed prices (surge pricing)
prices_df = registry.get_prices('latest') prices_df = registry.get_prices('latest')
elasticity_df = registry.get_elasticity('latest') if prices_df is not None:
product_price_row = prices_df[prices_df['productId'] == productId]
if prices_df is None: if not product_price_row.empty:
# fallback: no pre-computed prices available optimal_price = float(product_price_row['optimal_price'].iloc[0])
return PriceResponse( return PriceResponse(
productId=productId, productId=productId,
price=base_price, price=optimal_price,
base_price=base_price, base_price=base_price,
markup=1.0, markup=optimal_price/base_price,
elasticity=None elasticity=None,
) model_version='surge'
)
# lookup pre-computed price for this product
product_price_row = prices_df[prices_df['productId'] == productId]
if product_price_row.empty:
# product not in pre-computed prices, fallback to base
return PriceResponse(
productId=productId,
price=base_price,
base_price=base_price,
markup=1.0,
elasticity=None
)
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
# get elasticity if available
product_elasticity = None
if elasticity_df is not None:
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
if not product_elasticity_row.empty:
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
# PRIORITY 3: fallback to base price
return PriceResponse( return PriceResponse(
productId=productId, productId=productId,
price=optimal_price, price=base_price,
base_price=base_price, base_price=base_price,
markup=optimal_price/base_price, markup=1.0,
elasticity=product_elasticity elasticity=None,
model_version='base'
) )
@app.get("/models") @app.get("/models")

View File

@@ -198,12 +198,16 @@ def dump_logs(
auto_offset_reset='earliest', auto_offset_reset='earliest',
enable_auto_commit=False, enable_auto_commit=False,
value_deserializer=lambda x: json.loads(x.decode('utf-8')), value_deserializer=lambda x: json.loads(x.decode('utf-8')),
consumer_timeout_ms=5000 consumer_timeout_ms=30000,
fetch_max_wait_ms=10000,
max_poll_records=1000
) )
events = [] events = []
for msg in consumer: for msg in consumer:
events.append(msg.value) events.append(msg.value)
if last_n and len(events) >= last_n * 2:
break
consumer.close() consumer.close()

View File

@@ -112,11 +112,14 @@ services:
depends_on: depends_on:
- postgres - postgres
environment: environment:
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor - AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow - AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY} - AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__LOAD_EXAMPLES=false - AIRFLOW__CORE__LOAD_EXAMPLES=false
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true - AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
- AIRFLOW__CORE__PARALLELISM=16
- AIRFLOW__CORE__DAG_CONCURRENCY=8
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
- _AIRFLOW_DB_MIGRATE=true - _AIRFLOW_DB_MIGRATE=true
- _AIRFLOW_WWW_USER_CREATE=true - _AIRFLOW_WWW_USER_CREATE=true
- _AIRFLOW_WWW_USER_USERNAME=admin - _AIRFLOW_WWW_USER_USERNAME=admin
@@ -136,14 +139,20 @@ services:
- airflow-init - airflow-init
- redis - redis
environment: environment:
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor - AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow - AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY} - AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true - AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
- AIRFLOW__CORE__LOAD_EXAMPLES=false - AIRFLOW__CORE__LOAD_EXAMPLES=false
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true - AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
- AIRFLOW__CORE__PARALLELISM=16
- AIRFLOW__CORE__DAG_CONCURRENCY=8
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true - AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY} - AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
- KAFKA_HOST=kafka - KAFKA_HOST=kafka
- KAFKA_PORT=29092 - KAFKA_PORT=29092
- BACKEND_URL=http://backend:5000 - BACKEND_URL=http://backend:5000
@@ -173,13 +182,20 @@ services:
redis: redis:
condition: service_started condition: service_started
environment: environment:
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor - AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow - AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY} - AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true - AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
- AIRFLOW__CORE__LOAD_EXAMPLES=false - AIRFLOW__CORE__LOAD_EXAMPLES=false
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true - AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
- AIRFLOW__CORE__PARALLELISM=16
- AIRFLOW__CORE__DAG_CONCURRENCY=8
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY} - AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
- KAFKA_HOST=kafka - KAFKA_HOST=kafka
- KAFKA_PORT=29092 - KAFKA_PORT=29092
- BACKEND_URL=http://backend:5000 - BACKEND_URL=http://backend:5000

42
docker/Trainer.dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1.7
FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime AS gpu
WORKDIR /app
COPY docker/trainer.requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
# Optional for JAX-on-GPU workflows.
ARG INSTALL_JAX_GPU=false
RUN if [ "${INSTALL_JAX_GPU}" = "true" ]; then \
pip install --no-cache-dir "jax[cuda12]==0.4.30" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html; \
fi
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
COPY engine /app/engine
ENV PYTHONPATH=/app \
XLA_PYTHON_CLIENT_PREALLOCATE=false
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
FROM python:3.11-slim AS tpu
WORKDIR /app
COPY docker/trainer.requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
RUN pip install --no-cache-dir "jax[tpu]==0.4.30" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
COPY engine /app/engine
ENV PYTHONPATH=/app \
PHANTOM_USE_JAX=1 \
PHANTOM_DEFAULT_AGENT_ARGS="--jax" \
XLA_PYTHON_CLIENT_PREALLOCATE=false
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env sh
set -eu
if [ -z "${SWEEP_ID:-}" ]; then
echo "SWEEP_ID is required"
exit 1
fi
set -- python -m engine.train --sweep-agent --sweep-id "${SWEEP_ID}"
if [ -n "${PHANTOM_DEFAULT_AGENT_ARGS:-}" ]; then
set -- "$@" ${PHANTOM_DEFAULT_AGENT_ARGS}
fi
if [ -n "${TRAIN_ARGS:-}" ]; then
set -- "$@" ${TRAIN_ARGS}
fi
if [ "${AGENT_COUNT:-0}" != "0" ]; then
set -- "$@" --count "${AGENT_COUNT}"
fi
exec "$@"

View File

@@ -0,0 +1,13 @@
numpy>=1.24.0
pandas>=2.0.0
scipy>=1.11.0
gymnasium>=0.29.0
stable-baselines3>=2.2.0
tensorboard>=2.15.0
wandb>=0.17.0
tensorflow-probability==0.24.0
flax==0.10.7
optax==0.2.7
distrax==0.1.5
orbax-checkpoint==0.11.32
chex==0.1.90

97
engine/engine.py Normal file
View File

@@ -0,0 +1,97 @@
from sys import platform
import numpy as np
from .lib.demand import generate_demand_for_actor, estimate_demand
from .lib.behavior import sample_behavior
from logging import INFO, getLogger
logger = getLogger(__name__)
logger.setLevel(INFO)
class MarketEngine:
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
def __init__(
self,
alpha: float,
N: int,
human_params: tuple,
agent_params: tuple,
demand_distribution=np.random.normal,
noise_std: float = 1.0,
action_weights: dict | None = None,
):
# no defaults for D_H, D_A - force explicit experiment design
self.alpha = alpha
self.N = int(N)
self.Nagents = int(N * alpha)
self.Nhumans = int(N * (1 - alpha))
self.human_params = human_params
self.agent_params = agent_params
self.noise_std = noise_std
self.demand_dist = demand_distribution
self.action_weights = action_weights
def act(self, prices):
# generate separate demands d() per actor type
demand_h = generate_demand_for_actor(
prices,
self.human_params,
self.noise_std,
distribution_method=self.demand_dist,
)
demand_a = generate_demand_for_actor(
prices,
self.agent_params,
self.noise_std,
distribution_method=self.demand_dist,
)
# sample behavior trajectories from each demand distribution
human_t = [sample_behavior(demand_h, human=True) for _ in range(self.Nhumans)]
agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)]
# store trajectories for agent probability calculation
self.last_trajectories = human_t + agent_t
return estimate_demand(self.last_trajectories, self.action_weights)
def measure(self):
pass
class PricingEngine:
def __init__(
self,
) -> None:
pass
def act(self, demand):
return np.random.uniform(low=25, high=100, size=10)
class Limbo:
def __init__(self, platform, market) -> None:
self.platform_turn = True
self.platform = platform
self.market = market
self.output = None
def step(self):
if self.platform_turn:
self.output = self.platform.act(self.output)
else:
self.output = self.market.act(self.output)
self.platform_turn = not self.platform_turn
return self.output
def reset(self):
self.platform_turn = True
self.output = None
if __name__ == "__main__":
platform = PricingEngine()
market = MarketEngine(
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
)
limbo = Limbo(platform, market)
for _ in range(10):
limbo.step()

13
engine/jax/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""JAX-compatible training and environment modules for PHANTOM."""
from __future__ import annotations
try:
import jax # noqa: F401
import jax.numpy as jnp # noqa: F401
JAX_AVAILABLE = True
except ImportError:
JAX_AVAILABLE = False
__all__ = ["JAX_AVAILABLE"]

49
engine/jax/checkpoint.py Normal file
View File

@@ -0,0 +1,49 @@
"""Orbax checkpoint helpers for JAX training runs."""
from __future__ import annotations
from pathlib import Path
from typing import Any
try:
import orbax.checkpoint as ocp
HAS_ORBAX = True
except ImportError:
HAS_ORBAX = False
def _require_orbax() -> None:
if not HAS_ORBAX:
raise ImportError(
"orbax-checkpoint is required for checkpoint support. "
"Install engine/jax/requirements.txt first."
)
def create_manager(directory: str | Path, max_to_keep: int = 5):
_require_orbax()
root = Path(directory)
root.mkdir(parents=True, exist_ok=True)
options = ocp.CheckpointManagerOptions(
max_to_keep=max(1, int(max_to_keep)), create=True
)
return ocp.CheckpointManager(root.as_posix(), ocp.PyTreeCheckpointer(), options)
def save(manager, *, step: int, payload: Any) -> bool:
_require_orbax()
return bool(manager.save(int(step), payload))
def latest_step(manager) -> int | None:
_require_orbax()
return manager.latest_step()
def restore(manager, *, target: Any, step: int | None = None) -> Any:
_require_orbax()
step_to_restore = manager.latest_step() if step is None else int(step)
if step_to_restore is None:
return target
return manager.restore(step_to_restore, items=target)

287
engine/jax/env.py Normal file
View File

@@ -0,0 +1,287 @@
"""JAX-native PHANTOM environment with robust contamination step."""
from __future__ import annotations
from typing import NamedTuple
try:
import jax
import jax.numpy as jnp
except ImportError as exc: # pragma: no cover
raise ImportError("engine.jax.env requires JAX") from exc
from .primitives import (
_sample_sessions_jax,
agent_probability_from_kl,
batch_kl,
compute_session_transitions,
load_transition_data,
purchase_flags,
reward_with_coi_penalty,
revenue_from_demand,
weighted_demand,
)
class EnvParams(NamedTuple):
n_products: int
n_sessions: int
max_episode_steps: int
max_session_steps: int
price_low: float
price_high: float
lambda_coi: float
info_value: float
robust_radius: float
margin_floor: float
margin_floor_patience: int
action_scales: jax.Array
alpha_nominal: float
alpha_candidates: jax.Array
human_T: jax.Array
agent_T: jax.Array
terminal_mask: jax.Array
purchase_mask: jax.Array
event_weights: jax.Array
start_idx: int
term_idx: int
class EnvState(NamedTuple):
prices: jax.Array
demand: jax.Array
step_count: jax.Array
low_margin_streak: jax.Array
last_agent_prob: jax.Array
last_alpha_adv: jax.Array
class CandidateEval(NamedTuple):
reward: jax.Array
revenue: jax.Array
demand: jax.Array
agent_prob: jax.Array
leakage: jax.Array
discount: jax.Array
n_purchases: jax.Array
n_agents: jax.Array
def make_env_params(
*,
n_products: int,
alpha: float,
n_sessions: int,
lambda_coi: float,
robust_radius: float,
robust_points: int,
info_value: float,
action_levels: int,
action_scale_low: float,
action_scale_high: float,
price_low: float,
price_high: float,
max_episode_steps: int,
max_session_steps: int = 40,
margin_floor: float = 0.05,
margin_floor_patience: int = 5,
prefer_behavior_data: bool = True,
) -> EnvParams:
transition = load_transition_data(prefer_data=prefer_behavior_data).to_jax()
if robust_radius <= 0.0 or robust_points <= 1:
alpha_candidates = jnp.asarray([float(alpha)], dtype=jnp.float32)
else:
lo = max(0.0, float(alpha) - float(robust_radius))
hi = min(1.0, float(alpha) + float(robust_radius))
alpha_candidates = jnp.linspace(lo, hi, int(robust_points), dtype=jnp.float32)
action_scales = jnp.linspace(
float(action_scale_low),
float(action_scale_high),
int(action_levels),
dtype=jnp.float32,
)
return EnvParams(
n_products=int(n_products),
n_sessions=int(n_sessions),
max_episode_steps=int(max_episode_steps),
max_session_steps=int(max_session_steps),
price_low=float(price_low),
price_high=float(price_high),
lambda_coi=float(lambda_coi),
info_value=float(info_value),
robust_radius=float(robust_radius),
margin_floor=float(margin_floor),
margin_floor_patience=int(margin_floor_patience),
action_scales=action_scales,
alpha_nominal=float(alpha),
alpha_candidates=alpha_candidates,
human_T=jnp.asarray(transition.human_T),
agent_T=jnp.asarray(transition.agent_T),
terminal_mask=jnp.asarray(transition.terminal_mask),
purchase_mask=jnp.asarray(transition.purchase_mask),
event_weights=jnp.asarray(transition.event_weights),
start_idx=int(transition.start_idx),
term_idx=int(transition.term_idx),
)
def _flatten_obs(demand: jax.Array, prices: jax.Array) -> jax.Array:
return jnp.concatenate([demand.astype(jnp.float32), prices.astype(jnp.float32)])
def _decode_action(
prices: jax.Array, action: jax.Array, params: EnvParams
) -> jax.Array:
idx = jnp.clip(action.astype(jnp.int32), 0, params.action_scales.shape[0] - 1)
scale = params.action_scales[idx]
next_prices = prices * scale
return jnp.clip(next_prices, params.price_low, params.price_high)
def _evaluate_candidate(
key: jax.Array,
alpha_candidate: jax.Array,
prices: jax.Array,
params: EnvParams,
) -> CandidateEval:
states, products, actors, lengths = _sample_sessions_jax(
key,
params.human_T,
params.agent_T,
params.terminal_mask,
params.start_idx,
params.term_idx,
alpha_candidate,
params.n_products,
params.n_sessions,
params.max_session_steps,
int(params.human_T.shape[0]),
)
session_trans = compute_session_transitions(
states, lengths, int(params.human_T.shape[0])
)
delta_h, delta_a = batch_kl(session_trans, params.human_T, params.agent_T)
agent_probs = agent_probability_from_kl(delta_h, delta_a)
agent_prob = jnp.mean(agent_probs)
demand = weighted_demand(states, products, params.n_products, params.event_weights)
revenue = revenue_from_demand(prices, demand)
reward, leakage, discount = reward_with_coi_penalty(
revenue,
agent_prob,
params.lambda_coi,
params.info_value,
)
purchases = purchase_flags(states, params.purchase_mask)
return CandidateEval(
reward=reward,
revenue=revenue,
demand=demand,
agent_prob=agent_prob,
leakage=leakage,
discount=discount,
n_purchases=jnp.sum(purchases.astype(jnp.float32)),
n_agents=jnp.sum(actors.astype(jnp.float32)),
)
def reset_env(key: jax.Array, params: EnvParams) -> tuple[jax.Array, EnvState]:
prices = jax.random.uniform(
key,
shape=(params.n_products,),
minval=params.price_low,
maxval=params.price_high,
)
demand = jnp.zeros((params.n_products,), dtype=jnp.float32)
state = EnvState(
prices=prices,
demand=demand,
step_count=jnp.asarray(0, dtype=jnp.int32),
low_margin_streak=jnp.asarray(0, dtype=jnp.int32),
last_agent_prob=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
last_alpha_adv=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
)
return _flatten_obs(demand, prices), state
def step_env(
key: jax.Array,
state: EnvState,
action: jax.Array,
params: EnvParams,
) -> tuple[jax.Array, EnvState, jax.Array, jax.Array, dict[str, jax.Array]]:
prices = _decode_action(state.prices, action, params)
n_candidates = params.alpha_candidates.shape[0]
cand_keys = jax.random.split(key, n_candidates)
evals = jax.vmap(
lambda k, a: _evaluate_candidate(k, a, prices, params),
in_axes=(0, 0),
)(cand_keys, params.alpha_candidates)
idx = jnp.argmin(evals.reward)
demand = evals.demand[idx]
reward = evals.reward[idx]
revenue = evals.revenue[idx]
agent_prob = evals.agent_prob[idx]
leakage = evals.leakage[idx]
discount = evals.discount[idx]
n_purchases = evals.n_purchases[idx]
n_agents = evals.n_agents[idx]
alpha_adv = params.alpha_candidates[idx]
step_count = state.step_count + 1
avg_price = jnp.maximum(jnp.mean(prices), 1e-6)
avg_margin = (avg_price - params.price_low) / avg_price
next_streak = jnp.where(
avg_margin < params.margin_floor, state.low_margin_streak + 1, 0
)
margin_collapsed = next_streak >= params.margin_floor_patience
done = (step_count >= params.max_episode_steps) | margin_collapsed
next_state = EnvState(
prices=prices,
demand=demand,
step_count=step_count,
low_margin_streak=next_streak,
last_agent_prob=agent_prob,
last_alpha_adv=alpha_adv,
)
obs = _flatten_obs(demand, prices)
info = {
"revenue": revenue,
"agent_prob": agent_prob,
"alpha_adv": alpha_adv,
"coi_leakage": leakage,
"coi_discount": discount,
"n_purchases": n_purchases,
"n_agents": n_agents,
"avg_margin": avg_margin,
}
return obs, next_state, reward, done, info
class PHANTOMJAXEnv:
def __init__(self, params: EnvParams):
self.params = params
def reset(self, key: jax.Array, params: EnvParams | None = None):
return reset_env(key, self.params if params is None else params)
def step(
self,
key: jax.Array,
state: EnvState,
action: jax.Array,
params: EnvParams | None = None,
):
return step_env(key, state, action, self.params if params is None else params)
def action_space_n(self, params: EnvParams | None = None) -> int:
p = self.params if params is None else params
return int(p.action_scales.shape[0])
def observation_dim(self, params: EnvParams | None = None) -> int:
p = self.params if params is None else params
return int(p.n_products * 2)

495
engine/jax/primitives.py Normal file
View File

@@ -0,0 +1,495 @@
"""JAX-compatible primitives for PHANTOM session simulation and separability."""
from __future__ import annotations
from dataclasses import dataclass
from functools import partial
from typing import Mapping, Sequence
import numpy as np
try:
import jax
import jax.numpy as jnp
JAX_AVAILABLE = True
except ImportError:
jax = None # type: ignore[assignment]
jnp = np # type: ignore[assignment]
JAX_AVAILABLE = False
STATE_START_KEYS = ("session_start", "start")
TERMINAL_EVENT_TOKENS = (
"session_end",
"end",
"purchase_complete",
"checkout_start",
"checkout",
)
PURCHASE_EVENT_TOKENS = (
"purchase_complete",
"purchase",
"checkout_start",
"checkout",
)
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
ACTION_CATEGORIES = {
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
"dwell": {
"hover_title",
"hover_paragraph",
"hover_link",
"hover_over_title",
"hover_over_paragraph",
"hover_over_link",
"hover_over_button",
},
"nav": {
"page_view",
"view_item",
"view",
"learn_more",
"learn_more_about_item",
"view_item_page",
"session_start",
},
"filter": {
"search",
"filter_date",
"filter_price",
"sort",
"filter_for_date",
"filter_for_price",
"filter_for_amenities",
"sort_change",
},
}
DEFAULT_ACTION_WEIGHTS = {
action: CATEGORY_WEIGHTS[group]
for group, actions in ACTION_CATEGORIES.items()
for action in actions
}
@dataclass(frozen=True)
class TransitionData:
"""Dense transition kernels and per-state metadata."""
human_T: np.ndarray
agent_T: np.ndarray
terminal_mask: np.ndarray
purchase_mask: np.ndarray
event_weights: np.ndarray
event_names: tuple[str, ...]
start_idx: int
term_idx: int
def to_jax(self) -> "TransitionData":
if not JAX_AVAILABLE:
return self
return TransitionData(
human_T=jnp.asarray(self.human_T),
agent_T=jnp.asarray(self.agent_T),
terminal_mask=jnp.asarray(self.terminal_mask),
purchase_mask=jnp.asarray(self.purchase_mask),
event_weights=jnp.asarray(self.event_weights),
event_names=self.event_names,
start_idx=int(self.start_idx),
term_idx=int(self.term_idx),
)
@dataclass(frozen=True)
class SessionBatch:
states: np.ndarray
products: np.ndarray
actors: np.ndarray
lengths: np.ndarray
def _event_weight(name: str) -> float:
if name in DEFAULT_ACTION_WEIGHTS:
return float(DEFAULT_ACTION_WEIGHTS[name])
if name.startswith("hover"):
return float(CATEGORY_WEIGHTS["dwell"])
if name.startswith("filter") or name in {"search", "sort", "sort_change"}:
return float(CATEGORY_WEIGHTS["filter"])
if name.startswith("add") or name in {
"checkout",
"checkout_start",
"purchase",
"remove_item",
"purchase_complete",
}:
return float(CATEGORY_WEIGHTS["cart"])
if any(token in name for token in TERMINAL_EVENT_TOKENS):
return 0.0
return float(CATEGORY_WEIGHTS["nav"])
def _is_terminal(name: str) -> bool:
return any(token in name for token in TERMINAL_EVENT_TOKENS)
def _is_purchase(name: str) -> bool:
return any(token in name for token in PURCHASE_EVENT_TOKENS)
def _collect_events(*transitions: Mapping[str, Mapping[str, float]]) -> tuple[str, ...]:
names: set[str] = set()
for trans in transitions:
for src, dsts in trans.items():
names.add(src)
names.update(dsts.keys())
names.discard("__terminal__")
return tuple(sorted(names))
def _normalize_rows(matrix: np.ndarray, term_idx: int) -> np.ndarray:
row_sums = matrix.sum(axis=1, keepdims=True)
dead_rows = np.isclose(row_sums.squeeze(-1), 0.0)
if np.any(dead_rows):
matrix[dead_rows] = 0.0
matrix[dead_rows, term_idx] = 1.0
row_sums = matrix.sum(axis=1, keepdims=True)
return matrix / np.maximum(row_sums, 1e-8)
def _dense_from_dict(
transitions: Mapping[str, Mapping[str, float]],
event_to_idx: Mapping[str, int],
term_idx: int,
) -> np.ndarray:
n_states = len(event_to_idx)
matrix = np.zeros((n_states, n_states), dtype=np.float32)
for src, dsts in transitions.items():
i = event_to_idx.get(src)
if i is None:
continue
for dst, prob in dsts.items():
j = event_to_idx.get(dst)
if j is None:
continue
matrix[i, j] += float(prob)
return _normalize_rows(matrix, term_idx)
def compile_transition_data(
human_transitions: Mapping[str, Mapping[str, float]],
agent_transitions: Mapping[str, Mapping[str, float]],
) -> TransitionData:
event_names = _collect_events(human_transitions, agent_transitions)
if not event_names:
return fallback_transition_data()
event_names = tuple([*event_names, "__terminal__"])
term_idx = len(event_names) - 1
event_to_idx = {name: i for i, name in enumerate(event_names)}
human_T = _dense_from_dict(human_transitions, event_to_idx, term_idx)
agent_T = _dense_from_dict(agent_transitions, event_to_idx, term_idx)
terminal_mask = np.array([_is_terminal(name) for name in event_names], dtype=bool)
purchase_mask = np.array([_is_purchase(name) for name in event_names], dtype=bool)
event_weights = np.array(
[_event_weight(name) for name in event_names], dtype=np.float32
)
terminal_mask[term_idx] = True
for idx, is_term in enumerate(terminal_mask):
if not is_term:
continue
human_T[idx] = 0.0
agent_T[idx] = 0.0
human_T[idx, idx] = 1.0
agent_T[idx, idx] = 1.0
start_idx = 0
for key in STATE_START_KEYS:
if key in event_to_idx:
start_idx = int(event_to_idx[key])
break
return TransitionData(
human_T=human_T,
agent_T=agent_T,
terminal_mask=terminal_mask,
purchase_mask=purchase_mask,
event_weights=event_weights,
event_names=event_names,
start_idx=start_idx,
term_idx=term_idx,
)
def fallback_transition_data() -> TransitionData:
human = {
"session_start": {
"page_view": 0.80,
"view_item_page": 0.15,
"session_end": 0.05,
},
"page_view": {"view_item_page": 0.55, "search": 0.25, "session_end": 0.20},
"view_item_page": {
"learn_more_about_item": 0.40,
"add_item_to_cart": 0.28,
"session_end": 0.32,
},
"learn_more_about_item": {
"add_item_to_cart": 0.50,
"view_item_page": 0.30,
"session_end": 0.20,
},
"add_item_to_cart": {
"checkout_start": 0.58,
"view_item_page": 0.24,
"session_end": 0.18,
},
"checkout_start": {"purchase_complete": 0.70, "session_end": 0.30},
"purchase_complete": {"session_end": 1.0},
}
agent = {
"session_start": {
"page_view": 0.90,
"view_item_page": 0.08,
"session_end": 0.02,
},
"page_view": {"view_item_page": 0.40, "search": 0.35, "session_end": 0.25},
"view_item_page": {
"learn_more_about_item": 0.55,
"add_item_to_cart": 0.15,
"session_end": 0.30,
},
"learn_more_about_item": {
"view_item_page": 0.45,
"add_item_to_cart": 0.20,
"session_end": 0.35,
},
"add_item_to_cart": {
"checkout_start": 0.42,
"view_item_page": 0.28,
"session_end": 0.30,
},
"checkout_start": {"purchase_complete": 0.52, "session_end": 0.48},
"purchase_complete": {"session_end": 1.0},
}
return compile_transition_data(human, agent)
def load_transition_data(prefer_data: bool = True) -> TransitionData:
if not prefer_data:
return fallback_transition_data()
try:
from ..lib.behavior import get_transition_models
human_trans, agent_trans = get_transition_models()
return compile_transition_data(human_trans, agent_trans)
except Exception:
return fallback_transition_data()
if JAX_AVAILABLE:
@partial(jax.jit, static_argnums=(8, 9, 10))
def _sample_sessions_jax(
key: jax.Array,
human_T: jax.Array,
agent_T: jax.Array,
terminal_mask: jax.Array,
start_idx: int,
term_idx: int,
alpha: float,
n_products: int,
n_sessions: int,
max_steps: int,
n_states: int,
) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
k_actor, k_product, k_step = jax.random.split(key, 3)
start_idx_i32 = jnp.asarray(start_idx, dtype=jnp.int32)
term_idx_i32 = jnp.asarray(term_idx, dtype=jnp.int32)
actor_draw = jax.random.uniform(k_actor, (n_sessions,))
actors = (actor_draw < alpha).astype(jnp.int32)
products = jax.random.randint(
k_product, (n_sessions,), 0, n_products, dtype=jnp.int32
)
active_init = jnp.ones((n_sessions,), dtype=jnp.bool_)
state_init = jnp.full((n_sessions,), start_idx_i32, dtype=jnp.int32)
def _scan_step(carry, _):
states, active, rng = carry
rng, k = jax.random.split(rng)
probs_h = human_T[states]
probs_a = agent_T[states]
probs = jnp.where(actors[:, None] == 0, probs_h, probs_a)
next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1)
next_state = jnp.where(active, next_state, term_idx_i32)
emitted = jnp.where(active, next_state, -1)
is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)]
next_active = active & (~is_terminal)
carry_states = jnp.where(next_active, next_state, term_idx_i32)
return (carry_states, next_active, rng), emitted
_, state_t = jax.lax.scan(
_scan_step, (state_init, active_init, k_step), None, length=max_steps
)
states = state_t.T
lengths = jnp.sum(states >= 0, axis=1, dtype=jnp.int32)
return states, products, actors, lengths
def sample_sessions(
key,
transition_data: TransitionData,
alpha: float,
n_products: int,
n_sessions: int,
max_steps: int,
) -> SessionBatch:
if JAX_AVAILABLE:
td = transition_data.to_jax()
states, products, actors, lengths = _sample_sessions_jax(
key,
td.human_T,
td.agent_T,
td.terminal_mask,
int(td.start_idx),
int(td.term_idx),
float(alpha),
int(n_products),
int(n_sessions),
int(max_steps),
int(td.human_T.shape[0]),
)
return SessionBatch(
states=states, products=products, actors=actors, lengths=lengths
)
rng = np.random.default_rng(int(np.asarray(key).reshape(-1)[0]))
n_states = transition_data.human_T.shape[0]
products = rng.integers(0, n_products, size=n_sessions, dtype=np.int32)
actors = (rng.random(size=n_sessions) < alpha).astype(np.int32)
states = np.full((n_sessions, max_steps), -1, dtype=np.int32)
lengths = np.zeros((n_sessions,), dtype=np.int32)
for i in range(n_sessions):
current = int(transition_data.start_idx)
mat = transition_data.agent_T if actors[i] == 1 else transition_data.human_T
for t in range(max_steps):
nxt = int(rng.choice(n_states, p=mat[current]))
states[i, t] = nxt
if transition_data.terminal_mask[nxt]:
lengths[i] = t + 1
break
current = nxt
if lengths[i] == 0:
lengths[i] = max_steps
return SessionBatch(
states=states, products=products, actors=actors, lengths=lengths
)
if JAX_AVAILABLE:
@partial(jax.jit, static_argnums=(2,))
def compute_session_transitions(states, lengths, n_states: int):
src = states[:, :-1]
dst = states[:, 1:]
time_idx = jnp.arange(src.shape[1])[None, :]
valid = (src >= 0) & (dst >= 0) & (time_idx < (lengths[:, None] - 1))
src_clip = jnp.clip(src, 0, n_states - 1)
dst_clip = jnp.clip(dst, 0, n_states - 1)
src_oh = jax.nn.one_hot(src_clip, n_states)
dst_oh = jax.nn.one_hot(dst_clip, n_states)
counts = jnp.einsum(
"nti,ntj,nt->nij", src_oh, dst_oh, valid.astype(jnp.float32)
)
row_sums = jnp.sum(counts, axis=-1, keepdims=True)
return counts / (row_sums + 1e-10)
else:
def compute_session_transitions(states, lengths, n_states: int):
trans = np.zeros((states.shape[0], n_states, n_states), dtype=np.float32)
for i in range(states.shape[0]):
for t in range(max(int(lengths[i]) - 1, 0)):
s = int(states[i, t])
d = int(states[i, t + 1])
if s >= 0 and d >= 0:
trans[i, s, d] += 1.0
row_sums = trans.sum(axis=-1, keepdims=True)
return trans / (row_sums + 1e-10)
def batch_kl(P, Q_human, Q_agent, eps: float = 1e-10):
p = P + eps
p = p / jnp.sum(p, axis=-1, keepdims=True)
qh = Q_human[None, ...] + eps
qa = Q_agent[None, ...] + eps
delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2))
delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2))
return delta_h, delta_a
if JAX_AVAILABLE:
batch_kl = jax.jit(batch_kl)
def agent_probability_from_kl(delta_h, delta_a, temperature: float = 1.0):
t = jnp.maximum(float(temperature), 1e-6)
exp_h = jnp.exp(-delta_h / t)
exp_a = jnp.exp(-delta_a / t)
return exp_a / (exp_h + exp_a + 1e-10)
def estimate_alpha_from_kl(delta_h, delta_a, beta: float = 2.0):
logits = beta * (delta_h - delta_a)
return 1.0 / (1.0 + jnp.exp(-logits))
def weighted_demand(states, products, n_products: int, event_weights):
valid = states >= 0
state_clip = jnp.clip(states, 0, event_weights.shape[0] - 1)
weights = event_weights[state_clip] * valid
per_session = jnp.sum(weights, axis=1)
demand = jnp.zeros((n_products,), dtype=jnp.float32)
demand = demand.at[products].add(per_session)
total = jnp.sum(demand)
return jnp.where(total > 0.0, (demand / total) * 100.0, demand)
if JAX_AVAILABLE:
weighted_demand = jax.jit(weighted_demand, static_argnums=(2,))
def purchase_flags(states, purchase_mask):
state_clip = jnp.clip(states, 0, purchase_mask.shape[0] - 1)
hits = purchase_mask[state_clip] & (states >= 0)
return jnp.any(hits, axis=1)
if JAX_AVAILABLE:
purchase_flags = jax.jit(purchase_flags)
def revenue_from_demand(prices, demand):
return jnp.dot(prices, demand)
if JAX_AVAILABLE:
revenue_from_demand = jax.jit(revenue_from_demand)
def reward_with_coi_penalty(
revenue, agent_prob: float, lambda_coi: float, info_value: float
):
leakage = agent_prob * info_value
discount = jnp.clip(1.0 - lambda_coi * leakage, 0.0, 1.0)
return revenue * discount, leakage, discount
if JAX_AVAILABLE:
reward_with_coi_penalty = jax.jit(reward_with_coi_penalty)

View File

@@ -0,0 +1,5 @@
flax==0.10.7
optax==0.2.7
distrax==0.1.5
orbax-checkpoint==0.11.32
chex==0.1.90

1304
engine/jax/train.py Normal file

File diff suppressed because it is too large Load Diff

14
engine/lib/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .demand import estimate_demand, estimate_weighted_demand, generate_demand_for_actor
from .behavior import sample_behavior, get_transition_models, trajectory_to_events
from .render import DashboardRenderer, style_axis
from .wrappers import EconomicMetricsWrapper
from .callbacks import MetricsCallback, EvalMetricsCallback, CheckpointArtifactCallback
from .providers import (
ProviderBenchmark,
ProviderResult,
BenchmarkConfig,
RandomBaseline,
SurgeBaseline,
)
from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability
from .discrete import EventQTable

134
engine/lib/behavior.py Normal file
View File

@@ -0,0 +1,134 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[2]))
try:
from sim.rl.behavior_loader.models import (
BehaviorModel,
AgentBehaviorModel,
aggregate_event_transitions,
)
except ImportError:
BehaviorModel = None
AgentBehaviorModel = None
aggregate_event_transitions = None
import pandas as pd
import numpy as np
from .demand import generate_demand_for_actor
base_dir = Path(__file__).parents[2] / "experiments"
human_dir = str(base_dir / "collected_data")
agent_dir = str(base_dir / "agents" / "collected_data")
_cache = {} # lazy cache for models and base pivots
def _get_base_pivot(human: bool):
if (
BehaviorModel is None
or AgentBehaviorModel is None
or aggregate_event_transitions is None
):
raise ImportError("behavior loader dependencies are unavailable")
key = "human" if human else "agent"
if key not in _cache:
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
mdp = model.build_MDP()
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
return _cache[key]
def get_transition_models():
"""load human and agent transition models for agent probability calculation
returns:
tuple: (human_transitions, agent_transitions) as dicts of event->event->prob
"""
if (
BehaviorModel is None
or AgentBehaviorModel is None
or aggregate_event_transitions is None
):
raise ImportError("behavior loader dependencies are unavailable")
human_model = BehaviorModel(human_dir)
agent_model = AgentBehaviorModel(agent_dir)
human_mdp = human_model.build_MDP()
agent_mdp = agent_model.build_MDP()
human_trans = aggregate_event_transitions(human_mdp)
agent_trans = aggregate_event_transitions(agent_mdp)
return human_trans, agent_trans
def trajectory_to_events(trajectory: list) -> list:
"""extract event names from trajectory for KL divergence calculation
trajectories are in format 'eventName_product0', extract just eventName
args:
trajectory: list like ['view_product0', 'add_to_cart_product1', 'checkout_product1']
returns:
list: event names like ['view', 'add_to_cart', 'checkout']
"""
events = []
for state in trajectory:
# state format from sample_behavior: 'eventName_productX'
if "_product" in state:
event = state.rsplit("_product", 1)[0]
else:
event = state
events.append(event)
return events
def adjust_behavior_to_condition(condition, transition_matrix):
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
condition = np.asarray(condition, dtype=float)
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
condition = np.clip(condition, 0.0, None)
s = float(np.sum(condition))
if not np.isfinite(s) or s <= 0:
cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
else:
cond_norm = condition / s
n_products = len(condition)
base_vals = transition_matrix.values
base_cols, base_rows = (
transition_matrix.columns.tolist(),
transition_matrix.index.tolist(),
)
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)]
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
def sample_behavior(condition, human=True, max_len=40):
base_pivot = _get_base_pivot(human)
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
trajectory = [np.random.choice(adjusted_transitions.index)]
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
probs = np.asarray(adjusted_transitions.loc[trajectory[-1]].values, dtype=float)
probs = np.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0)
probs = np.clip(probs, 0.0, None)
s = float(np.sum(probs))
sample = np.random.choice(
adjusted_transitions.columns, p=(probs / s) if s > 0 else None
)
trajectory.append(sample)
return trajectory
if __name__ == "__main__":
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
print(t)
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
print(t)

182
engine/lib/callbacks.py Normal file
View File

@@ -0,0 +1,182 @@
"""Training callbacks for W&B/TensorBoard logging - reads from info dict."""
from pathlib import Path
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
import numpy as np
from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file
try:
import wandb
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
class MetricsCallback(BaseCallback):
"""Training metrics logger - reads info['economics'], logs to W&B."""
def __init__(
self, log_histograms: bool = True, log_freq: int = 100, verbose: int = 0
):
super().__init__(verbose)
self.log_histograms = log_histograms
self.log_freq = log_freq
self._episode_revenues: list[float] = []
def _on_step(self) -> bool:
if not HAS_WANDB or wandb.run is None:
return True
for info in self.locals.get("infos", []):
if "economics" not in info:
continue
econ = info["economics"]
t = self.num_timesteps
payload = {
"economics/revenue": econ["revenue"],
"economics/margin": econ["margin"],
"coi/level": econ["coi_level"],
"economics/regret": econ["regret"],
}
if "coi_mix" in econ:
payload["coi/mix"] = econ["coi_mix"]
if "coi_base" in econ:
payload["coi/base"] = econ["coi_base"]
if "coi_leakage" in econ:
payload["coi/leakage"] = econ["coi_leakage"]
if "coi_penalty" in econ:
payload["coi/penalty"] = econ["coi_penalty"]
wandb.log(payload, step=t)
self._episode_revenues.append(econ["revenue"])
# histograms at log_freq intervals
if self.log_histograms and self.num_timesteps % self.log_freq == 0:
for info in self.locals.get("infos", []):
if "prices" in info:
wandb.log(
{"distributions/prices": wandb.Histogram(info["prices"])},
step=self.num_timesteps,
)
if "demand" in info:
wandb.log(
{"distributions/demand": wandb.Histogram(info["demand"])},
step=self.num_timesteps,
)
return True
def _on_rollout_end(self) -> None:
if not HAS_WANDB or wandb.run is None or not self._episode_revenues:
return
wandb.log(
{
"episode/mean_revenue": np.mean(self._episode_revenues),
"episode/total_revenue": np.sum(self._episode_revenues),
},
step=self.num_timesteps,
)
self._episode_revenues = []
class CheckpointArtifactCallback(BaseCallback):
"""Periodic SB3 checkpoint uploader backed by W&B artifacts."""
def __init__(self, cfg: dict, interval: int = 10_000, verbose: int = 0):
super().__init__(verbose)
self.cfg = dict(cfg)
self.interval = max(1, int(interval))
self.model_dir = Path(str(self.cfg.get("model_dir", "engine/models")))
self.model_dir.mkdir(parents=True, exist_ok=True)
self._next_checkpoint = self.interval
self._last_saved_step = -1
def _artifact_name(self) -> str:
sweep_id = (
getattr(wandb.run, "sweep_id", None)
if HAS_WANDB and wandb.run is not None
else None
)
return checkpoint_artifact_name(self.cfg, backend="sb3", sweep_id=sweep_id)
def _checkpoint_file(self) -> Path:
algo = str(self.cfg.get("algo", "model"))
base = self.model_dir / f"phantom_{algo}_checkpoint"
self.model.save(str(base))
return base.with_suffix(".zip")
def _save_checkpoint(self) -> None:
if not HAS_WANDB or wandb.run is None:
return
step = int(self.num_timesteps)
if step <= self._last_saved_step:
return
checkpoint_path = self._checkpoint_file()
metadata = {
"step": step,
"algo": str(self.cfg.get("algo", "unknown")),
"sweep_id": getattr(wandb.run, "sweep_id", None),
}
saved = log_checkpoint_file(
self._artifact_name(),
file_path=checkpoint_path,
artifact_file_name=checkpoint_path.name,
metadata=metadata,
)
if saved:
self._last_saved_step = step
def _on_step(self) -> bool:
if self.num_timesteps < self._next_checkpoint:
return True
self._save_checkpoint()
while self._next_checkpoint <= self.num_timesteps:
self._next_checkpoint += self.interval
return True
def _on_training_end(self) -> None:
self._save_checkpoint()
class EvalMetricsCallback(EvalCallback):
"""Deterministic evaluation - true performance without exploration noise."""
def __init__(
self, eval_env, eval_freq: int = 1000, n_eval_episodes: int = 5, **kwargs
):
super().__init__(
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
)
self._eval_revenues: list[float] = []
def _on_step(self) -> bool:
result = super()._on_step()
if not HAS_WANDB or wandb.run is None:
return result
# log eval metrics after evaluation runs
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
wandb.log(
{
"eval/mean_reward": self.last_mean_reward,
"eval/mean_revenue": np.mean(self._eval_revenues)
if self._eval_revenues
else 0,
},
step=self.num_timesteps,
)
self._eval_revenues = []
return result
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
# called after each eval episode
info = locals_.get("info", {})
if "economics" in info:
self._eval_revenues.append(info["economics"]["revenue"])

76
engine/lib/coi.py Normal file
View File

@@ -0,0 +1,76 @@
import numpy as np
from typing import Dict
def compute_agent_probability(
trajectory: list, human_transitions: Dict, agent_transitions: Dict
) -> float:
"""estimate agent probability via KL divergence between trajectory transitions and reference models
compares empirical trajectory transition distribution to human/agent prototypes
args:
trajectory: list of state/event strings from session
human_transitions: reference transition dict from human MDP (event->event->prob)
agent_transitions: reference transition dict from agent MDP (event->event->prob)
returns:
agent probability in [0, 1] via softmax over KL divergences
"""
if len(trajectory) < 2:
return 0.0 # insufficient data, assume human
# build empirical transition distribution from trajectory
trans_counts = {}
for s, s_next in zip(trajectory[:-1], trajectory[1:]):
if s not in trans_counts:
trans_counts[s] = {}
trans_counts[s][s_next] = trans_counts[s].get(s_next, 0) + 1
# normalize to probabilities
empirical = {}
for s, nxt in trans_counts.items():
total = sum(nxt.values())
empirical[s] = {s_n: cnt / total for s_n, cnt in nxt.items()}
# compute KL divergence to each prototype
def kl_div(p_dist: Dict, q_dist: Dict) -> float:
eps = 1e-10
# aggregate over all source states in empirical dist
kl = 0.0
for s in p_dist:
if s not in q_dist:
continue # skip states not in reference
p_trans, q_trans = p_dist[s], q_dist[s]
for k in p_trans:
p_val = p_trans[k] + eps
q_val = q_trans.get(k, 0.0) + eps
kl += p_val * np.log(p_val / q_val)
return kl
kl_human = kl_div(empirical, human_transitions)
kl_agent = kl_div(empirical, agent_transitions)
# convert to probability via softmax (lower KL = higher prob)
# agent_prob = exp(-kl_agent) / (exp(-kl_human) + exp(-kl_agent))
exp_h = np.exp(-kl_human)
exp_a = np.exp(-kl_agent)
return float(exp_a / (exp_h + exp_a + 1e-10))
def extract_purchases(trajectories: list) -> Dict[int, int]:
purchases: Dict[int, int] = {}
for traj in trajectories:
if traj and "checkout" in traj[-1] and "_product" in traj[-1]:
prod_id = int(traj[-1].rsplit("_product", 1)[1])
purchases[prod_id] = purchases.get(prod_id, 0) + 1
return purchases
def compute_uplift_coi(
prices: np.ndarray, purchases: Dict[int, int], baseline_prices: np.ndarray
) -> float:
# TODO: consider view-weighted fractional purchase for denser signal
return float(
sum(max(0.0, prices[k] - baseline_prices[k]) * n for k, n in purchases.items())
)

92
engine/lib/demand.py Normal file
View File

@@ -0,0 +1,92 @@
import numpy as np
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
ACTION_CATEGORIES = {
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
"dwell": {"hover_title", "hover_paragraph", "hover_link"},
"nav": {"page_view", "view_item", "view", "learn_more"},
"filter": {"search", "filter_date", "filter_price", "sort"},
}
DEFAULT_ACTION_WEIGHTS = {
a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions
}
def generate_demand_for_actor(
prices: np.ndarray,
params: tuple,
noise_std: float = 1.0,
distribution_method=np.random.normal,
) -> np.ndarray:
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
params: (mean, std) for valuation distribution D_H or D_A"""
val = distribution_method(*params, size=len(prices))
noise = distribution_method(0, noise_std, len(prices))
demand = np.maximum(0, val - prices + noise)
total = np.sum(demand)
return demand / total * 100 if total > 0 else demand
def estimate_demand(trajectories, action_weights=None):
return estimate_weighted_demand(trajectories, action_weights)
def _parse_event_state(state: str):
if "_product" not in state:
return state, None
action, raw_pid = state.rsplit("_product", 1)
return action, int(raw_pid) if raw_pid.isdigit() else None
def _weight_for_action(action: str, action_weights: dict) -> float:
if action in action_weights:
return action_weights[action]
if action.startswith("hover"):
return CATEGORY_WEIGHTS["dwell"]
if action.startswith("filter") or action in {"search", "sort"}:
return CATEGORY_WEIGHTS["filter"]
if action.startswith("add") or action in {"checkout", "purchase", "remove"}:
return CATEGORY_WEIGHTS["cart"]
return CATEGORY_WEIGHTS["nav"]
def estimate_weighted_demand(trajectories, action_weights=None):
action_weights = (
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
)
scores = {}
for traj in trajectories:
for state in traj:
action, product_id = _parse_event_state(state)
if product_id is None:
continue
w = _weight_for_action(action, action_weights)
if w <= 0:
continue
scores[product_id] = scores.get(product_id, 0.0) + w
total = sum(scores.values())
return (
{pid: (score / total) * 100 for pid, score in scores.items()}
if total > 0
else {}
)
# Example usage
if __name__ == "__main__":
np.random.seed(42)
prices = np.array([20.0, 35.0, 50.0, 65.0])
# demo actor-specific demands
human_params, agent_params = (50, 10), (45, 15)
demand_h = generate_demand_for_actor(prices, human_params)
demand_a = generate_demand_for_actor(prices, agent_params)
print("Human Demand:", demand_h)
print("Agent Demand:", demand_a)
from .behavior import sample_behavior
N, alpha = 200, 0.3
n_h, n_a = int(N * (1 - alpha)), int(N * alpha)
human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)]
agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)]
demand_estimate = estimate_demand(human_t + agent_t)
print("Estimated Demand from Behavior:", demand_estimate)

70
engine/lib/discrete.py Normal file
View File

@@ -0,0 +1,70 @@
from collections import defaultdict
import gymnasium as gym
from gymnasium import spaces
import numpy as np
class DiscretePriceActionWrapper(gym.ActionWrapper):
def __init__(
self,
env: gym.Env,
n_levels: int = 9,
min_scale: float = 0.8,
max_scale: float = 1.2,
):
super().__init__(env)
self.scales = np.linspace(min_scale, max_scale, n_levels, dtype=np.float32)
self.action_space = spaces.Discrete(n_levels)
def action(self, action: int):
scale = float(self.scales[int(action)])
cur = np.asarray(self.env.unwrapped._prices, dtype=np.float32)
lo, hi = self.env.unwrapped.price_bounds
return np.clip(cur * scale, lo, hi).astype(np.float32)
class EventQTable:
def __init__(
self,
n_actions: int,
n_products: int,
price_bounds: tuple,
lr: float = 0.1,
gamma: float = 0.99,
n_bins: int = 6,
):
self.n_actions = int(n_actions)
self.n_products = int(n_products)
self.lr = float(lr)
self.gamma = float(gamma)
self.q = defaultdict(lambda: np.zeros(self.n_actions, dtype=np.float32))
lo, hi = price_bounds
self.demand_bins = np.linspace(0.0, 100.0, n_bins + 1)[1:-1]
self.price_bins = np.linspace(lo, hi, n_bins + 1)[1:-1]
def encode(self, obs: np.ndarray) -> tuple:
obs = np.asarray(obs, dtype=np.float32)
d = obs[: self.n_products]
p = obs[self.n_products : 2 * self.n_products]
d_mean = float(np.mean(d)) if d.size else 0.0
d_std = float(np.std(d)) if d.size else 0.0
p_mean = float(np.mean(p)) if p.size else 0.0
return (
int(np.digitize(d_mean, self.demand_bins)),
int(np.digitize(d_std, self.demand_bins)),
int(np.digitize(p_mean, self.price_bins)),
)
def act(self, obs: np.ndarray, eps: float = 0.0) -> tuple[int, tuple]:
s = self.encode(obs)
if np.random.random() < eps:
return int(np.random.randint(self.n_actions)), s
return int(np.argmax(self.q[s])), s
def update(self, s: tuple, a: int, r: float, s2: tuple, done: bool):
target = r + (0.0 if done else self.gamma * float(np.max(self.q[s2])))
self.q[s][a] += self.lr * (target - self.q[s][a])
def predict(self, obs: np.ndarray, deterministic: bool = True):
a, _ = self.act(obs, 0.0 if deterministic else 0.05)
return a, None

182
engine/lib/providers.py Normal file
View File

@@ -0,0 +1,182 @@
"""Provider benchmarking - compare pricing strategies across contamination levels."""
from dataclasses import dataclass, field
from typing import Callable, Any
import numpy as np
import pandas as pd
try:
import wandb
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
class RandomBaseline:
"""uniform random action selection as a lower-bound baseline"""
def __init__(self, n_actions: int):
self.n = n_actions
def __call__(self, obs):
return int(np.random.randint(self.n))
def predict(self, obs, **kw):
return self(obs), None
class SurgeBaseline:
"""heuristic surge pricing: boost price when demand is above threshold, discount when below.
matches the naive pricing rule from thesis Section 3.3.2"""
def __init__(
self, n_actions: int, high_threshold: float = 60.0, low_threshold: float = 30.0
):
self.n = n_actions
self.mid = n_actions // 2 # identity action (scale ~1.0)
self.high_t = high_threshold
self.low_t = low_threshold
def __call__(self, obs):
obs = np.asarray(obs, dtype=np.float32)
n_prod = len(obs) // 2
demand_mean = float(np.mean(obs[:n_prod])) if n_prod > 0 else 0.0
if demand_mean >= self.high_t:
return min(self.mid + 2, self.n - 1) # surge: two levels above identity
if demand_mean <= self.low_t:
return max(self.mid - 2, 0) # discount: two levels below identity
return self.mid # hold
def predict(self, obs, **kw):
return self(obs), None
@dataclass
class ProviderResult:
"""Single benchmark result for one provider at one alpha level."""
name: str
alpha: float
total_revenue: float
mean_revenue: float
coi_level: float
coi_preserved_pct: float # vs alpha=0 baseline
margin_integrity: float
regret: float
episodes: int
@dataclass
class BenchmarkConfig:
"""Configuration for provider benchmark runs."""
n_episodes: int = 100
alpha_range: list[float] = field(default_factory=lambda: [0.0, 0.1, 0.3, 0.5])
baseline_name: str = "fixed"
class ProviderBenchmark:
"""Compare pricing providers to prove margin preservation across contamination levels.
Usage:
def env_factory(alpha):
return EconomicMetricsWrapper(PHANTOM(alpha=alpha))
providers = {
"fixed": lambda obs: np.ones(10) * 50,
"learned": model.predict,
}
benchmark = ProviderBenchmark(env_factory, providers)
results = benchmark.run()
print(benchmark.summary_table())
"""
def __init__(
self,
env_factory: Callable[[float], Any],
providers: dict[str, Callable],
config: BenchmarkConfig | None = None,
):
self.env_factory = env_factory # fn(alpha) -> wrapped env
self.providers = providers # {name: fn(obs) -> action}
self.config = config or BenchmarkConfig()
self.results: list[ProviderResult] = []
def run(self) -> list[ProviderResult]:
"""Run benchmark across all providers and alpha levels."""
baseline_coi: dict[str, float] = {} # {provider: coi at alpha=0}
for alpha in self.config.alpha_range:
env = self.env_factory(alpha)
for name, policy_fn in self.providers.items():
revenues, coi_levels, margins = [], [], []
for _ in range(self.config.n_episodes):
obs, _ = env.reset()
episode_revenue = 0.0
done = False
while not done:
action = policy_fn(obs)
# handle sb3 model.predict returning tuple
if isinstance(action, tuple):
action = action[0]
obs, reward, term, trunc, info = env.step(action)
done = term or trunc
econ = info.get("economics", {})
episode_revenue += econ.get("revenue", 0)
coi_levels.append(econ.get("coi_level", 0))
margins.append(econ.get("margin", 0))
revenues.append(episode_revenue)
mean_coi = np.mean(coi_levels) if coi_levels else 0.0
if alpha == 0.0:
baseline_coi[name] = mean_coi
base = baseline_coi.get(name, mean_coi)
coi_preserved = mean_coi / base if base > 0 else 1.0
result = ProviderResult(
name=name,
alpha=alpha,
total_revenue=float(np.sum(revenues)),
mean_revenue=float(np.mean(revenues)),
coi_level=mean_coi,
coi_preserved_pct=coi_preserved * 100,
margin_integrity=float(np.mean(margins)) if margins else 0.0,
regret=0.0, # compute vs optimal if known
episodes=self.config.n_episodes,
)
self.results.append(result)
# log to wandb if available
if HAS_WANDB and wandb.run is not None:
wandb.log(
{
f"benchmark/{name}/revenue": result.mean_revenue,
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
f"benchmark/{name}/margin": result.margin_integrity,
"benchmark/alpha": alpha,
}
)
return self.results
def to_dataframe(self) -> pd.DataFrame:
"""Convert results to pandas DataFrame."""
return pd.DataFrame([r.__dict__ for r in self.results])
def summary_table(self) -> pd.DataFrame:
"""Pivot table: providers x alpha with revenue/COI metrics."""
df = self.to_dataframe()
return df.pivot_table(
index="name",
columns="alpha",
values=["mean_revenue", "coi_preserved_pct", "margin_integrity"],
aggfunc="mean",
)

126
engine/lib/render.py Normal file
View File

@@ -0,0 +1,126 @@
"""rendering logic for PHANTOM environment dashboard"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
if title: ax.set_title(title, fontsize=11, fontweight='bold', pad=8)
if xlabel: ax.set_xlabel(xlabel, fontsize=9)
if ylabel: ax.set_ylabel(ylabel, fontsize=9)
class DashboardRenderer:
"""stateful renderer for PHANTOM market dynamics visualization"""
def __init__(self):
self.fig = None
self.gs = None
def render(self, env) -> None:
if self.fig is None:
plt.ion()
self.fig = plt.figure(figsize=(14, 10))
self.gs = GridSpec(3, 3, figure=self.fig, hspace=0.35, wspace=0.3,
left=0.07, right=0.95, top=0.92, bottom=0.08)
plt.show(block=False)
self.fig.clear()
self.fig.suptitle(f'PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]',
fontsize=14, fontweight='bold')
demand_mat = np.array(env._demand_history).T
price_mat = np.array(env._price_history).T
elasticity = env._compute_elasticity()
self._render_scatter(env)
self._render_elasticity_bar(env, elasticity)
self._render_session_pie(env)
self._render_price_heatmap(price_mat)
self._render_demand_heatmap(demand_mat)
self._render_correlation(env.n_products, price_mat, demand_mat)
self._render_revenue(env)
self.fig.canvas.draw_idle()
self.fig.canvas.flush_events()
def _render_scatter(self, env):
ax = self.fig.add_subplot(self.gs[0, 0])
prices_flat = np.array(env._price_history).flatten()
demands_flat = np.array(env._demand_history).flatten()
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
ax.scatter(prices_flat, demands_flat, c=product_ids, cmap='plasma', alpha=0.6, s=15, edgecolors='none')
if len(prices_flat) > 1:
z = np.polyfit(prices_flat, demands_flat, 1)
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
ax.plot(p_line, np.polyval(z, p_line), '--', lw=1.5, alpha=0.8)
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
def _render_elasticity_bar(self, env, elasticity):
ax = self.fig.add_subplot(self.gs[0, 1])
ax.barh(range(env.n_products), elasticity, alpha=0.8)
ax.axvline(0, lw=0.8, alpha=0.5)
ax.axvline(-1, lw=1, ls='--', alpha=0.5)
ax.set_yticks(range(env.n_products))
ax.set_yticklabels([f'P{i}' for i in range(env.n_products)], fontsize=7)
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
def _render_session_pie(self, env):
ax = self.fig.add_subplot(self.gs[0, 2])
n_h, n_a = env.market.Nhumans, env.market.Nagents
wedges, _ = ax.pie([n_h, n_a], startangle=90, wedgeprops={'linewidth': 2, 'edgecolor': 'white'})
ax.legend(wedges, [f'H ({n_h})', f'A ({n_a})'], loc='lower center', fontsize=8,
frameon=False, bbox_to_anchor=(0.5, -0.05))
ax.set_title("Session Mix", fontsize=11, fontweight='bold')
def _render_price_heatmap(self, price_mat):
ax = self.fig.add_subplot(self.gs[1, :2])
im = ax.imshow(price_mat, aspect='auto', cmap='viridis', origin='lower')
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
cbar.set_label('$', fontsize=8)
def _render_demand_heatmap(self, demand_mat):
ax = self.fig.add_subplot(self.gs[1, 2])
im = ax.imshow(demand_mat, aspect='auto', cmap='Blues', origin='lower')
style_axis(ax, "Demand Q(product, t)", "Step", None)
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
def _render_correlation(self, n_products, price_mat, demand_mat):
ax = self.fig.add_subplot(self.gs[2, 0])
if price_mat.shape[1] > 2:
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
im = ax.imshow(corr, cmap='RdBu', vmin=-1, vmax=1, aspect='auto')
ax.set_xticks(range(n_products))
ax.set_yticks(range(n_products))
ax.set_xticklabels([f'Q{i}' for i in range(n_products)], fontsize=6)
ax.set_yticklabels([f'P{i}' for i in range(n_products)], fontsize=6)
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
style_axis(ax, "Price-Demand Correlation", None, None)
def _render_revenue(self, env):
ax = self.fig.add_subplot(self.gs[2, 1:])
n_steps = len(env._revenue_history)
demand_std = [np.std(d) for d in env._demand_history]
ax.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
ax.plot(env._revenue_history, linewidth=2, label='Revenue')
ax.set_xlim(0, max(n_steps, 1))
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
ax2 = ax.twinx()
ax2.plot(range(n_steps), demand_std, linewidth=2, ls='-', alpha=0.9, label='sigma(Demand)')
d_min, d_max = min(demand_std), max(demand_std)
margin = (d_max - d_min) * 0.2 if d_max > d_min else 0.5
ax2.set_ylim(max(0, d_min - margin), d_max + margin)
ax2.set_ylabel('Demand sigma', fontsize=9)
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
ax.legend(loc='upper left', fontsize=7, frameon=False)
ax2.legend(loc='upper right', fontsize=7, frameon=False)
def close(self):
if self.fig:
plt.close(self.fig)
self.fig = None

77
engine/lib/wrappers.py Normal file
View File

@@ -0,0 +1,77 @@
"""Economic metrics wrapper - calculates thesis-aligned KPIs and injects into info dict."""
import gymnasium as gym
import numpy as np
class EconomicMetricsWrapper(gym.Wrapper):
"""Calculates thesis-aligned economic metrics per step, injects into info.
Metrics follow thesis definitions:
- COI level: E[P] - p_min (Definition 1)
- Margin: (avg_price - p_min) / avg_price
- Regret: 1 - (revenue / baseline_revenue)
"""
def __init__(
self, env: gym.Env, p_min: float = 10.0, baseline_revenue: float | None = None
):
super().__init__(env)
self.p_min = p_min
self.baseline_revenue = baseline_revenue
self._price_history: list[np.ndarray] = []
self._revenue_history: list[float] = []
def reset(self, **kwargs):
obs, info = self.env.reset(**kwargs)
self._price_history = []
self._revenue_history = []
return obs, info
def step(self, action):
obs, reward, terminated, truncated, info = self.env.step(action)
# extract from unwrapped env
prices = self.env.unwrapped._prices
demand_dict = self.env.unwrapped._demand
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))])
alpha = self.env.unwrapped.alpha
# core calculations
revenue = float(np.sum(prices * demand))
avg_price = float(np.mean(prices))
margin = (avg_price - self.p_min) / max(avg_price, 1e-6)
coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1
self._price_history.append(prices.copy())
self._revenue_history.append(revenue)
# regret vs baseline (golden path)
regret = 0.0
if self.baseline_revenue and self.baseline_revenue > 0:
regret = 1.0 - (revenue / self.baseline_revenue)
# inject structured metrics into info
info["economics"] = {
"revenue": revenue,
"margin": margin,
"coi_level": coi_level,
"regret": regret,
}
for key in ("coi_mix", "coi_base", "coi_leakage", "coi_penalty"):
if key in info:
info["economics"][key] = info[key]
info["prices"] = prices.copy()
info["demand"] = demand.copy()
return obs, reward, terminated, truncated, info
@property
def episode_revenue(self) -> float:
return sum(self._revenue_history)
@property
def episode_mean_price(self) -> float:
if not self._price_history:
return 0.0
return float(np.mean([np.mean(p) for p in self._price_history]))

34
engine/studies/factors.py Normal file
View File

@@ -0,0 +1,34 @@
"""shared factor definitions for experimental designs"""
import numpy as np
from dataclasses import dataclass, field
from typing import Callable, Any
@dataclass
class Factor:
name: str
levels: list
primary: bool = True # full cross vs sampled
# demand functions with compatible signatures
def demand_linear(mu, sigma, size): return np.maximum(0, np.random.normal(mu, sigma, size))
def demand_uniform(mu, sigma, size): return np.random.uniform(mu - sigma, mu + sigma, size)
def demand_exponential(mu, sigma, size): return np.random.exponential(mu, size)
def demand_logistic(mu, sigma, size): return np.random.logistic(mu, sigma, size)
DEMAND_FUNCTIONS = {
"linear": demand_linear,
"uniform": demand_uniform,
"exponential": demand_exponential,
"logistic": demand_logistic,
}
FACTORS = [
Factor("demand_fn", list(DEMAND_FUNCTIONS.keys()), primary=True),
Factor("alpha", [0.1, 0.3, 0.5, 0.7], primary=True),
Factor("n_products", [5, 15, 30, 50], primary=True),
Factor("demand_mu", [30.0, 50.0, 70.0], primary=False),
Factor("demand_sigma", [5.0, 10.0, 20.0], primary=False),
Factor("N", [100, 500, 1000], primary=False),
]
SEEDS_PER_CONFIG = 5

View File

@@ -0,0 +1,89 @@
"""full factorial design - all factor combinations"""
import sys
sys.path.insert(0, "..")
import logging
from itertools import product
import json
import hashlib
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor
from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
def generate_configs():
"""generate all factor combinations with seeds"""
all_levels = [f.levels for f in FACTORS]
names = [f.name for f in FACTORS]
configs = []
for combo in product(*all_levels):
base = {names[i]: combo[i] for i in range(len(names))}
for seed in range(SEEDS_PER_CONFIG):
cfg = {**base, "seed": seed}
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
configs.append(cfg)
return configs
def run_single(cfg: dict) -> dict:
"""execute one experiment config, return metrics"""
from engine.wrapper import PHANTOM
import numpy as np
np.random.seed(cfg["seed"])
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
env = PHANTOM(
n_products=cfg["n_products"],
alpha=cfg["alpha"],
N=cfg["N"],
)
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
obs, _ = env.reset()
total_reward, steps = 0.0, 0
for _ in range(100):
action = env.action_space.sample()
obs, reward, term, trunc, _ = env.step(action)
total_reward += reward
steps += 1
if term: break
env.close()
return {
"id": cfg["id"],
"config": cfg,
"total_reward": total_reward,
"avg_reward": total_reward / steps if steps > 0 else 0.0,
"steps": steps,
}
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
configs = generate_configs()
log.info(f"full factorial: {len(configs)} configs ({len(configs)//SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)")
results = []
with ProcessPoolExecutor(max_workers=max_workers) as ex:
for i, result in enumerate(ex.map(run_single, configs)):
results.append(result)
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
Path(output).write_text("\n".join(json.dumps(r) for r in results))
log.info(f"wrote {len(results)} results to {output}")
return results
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--workers", type=int, default=None)
p.add_argument("--output", default="results_full.jsonl")
p.add_argument("--dry-run", action="store_true", help="only show design size")
args = p.parse_args()
configs = generate_configs()
log.info(f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}")
if not args.dry_run:
run_study(args.workers, args.output)

106
engine/studies/mixed_lh.py Normal file
View File

@@ -0,0 +1,106 @@
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
import sys
sys.path.insert(0, "..")
import logging
from itertools import product
import json
import hashlib
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor
import numpy as np
from scipy.stats.qmc import LatinHypercube
from factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
LH_SAMPLES = 10
def generate_configs(lh_samples: int = LH_SAMPLES):
primary = [f for f in FACTORS if f.primary]
secondary = [f for f in FACTORS if not f.primary]
primary_grid = list(product(*[f.levels for f in primary]))
lhs = LatinHypercube(d=len(secondary), seed=42)
configs = []
for p_combo in primary_grid:
samples = lhs.random(n=lh_samples)
for s in samples:
sec_vals = {
secondary[i].name: secondary[i].levels[int(s[i] * len(secondary[i].levels))]
for i in range(len(secondary))
}
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
base.update(sec_vals)
for seed in range(SEEDS_PER_CONFIG):
cfg = {**base, "seed": seed}
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
configs.append(cfg)
return configs
def run_single(cfg: dict) -> dict:
from engine.wrapper import PHANTOM
import numpy as np
np.random.seed(cfg["seed"])
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
env = PHANTOM(
n_products=cfg["n_products"],
alpha=cfg["alpha"],
N=cfg["N"],
)
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
obs, _ = env.reset()
total_reward, steps = 0.0, 0
for _ in range(100):
action = env.action_space.sample()
obs, reward, term, trunc, _ = env.step(action)
total_reward += reward
steps += 1
if term: break
env.close()
return {
"id": cfg["id"],
"config": cfg,
"total_reward": total_reward,
"avg_reward": total_reward / steps,
"steps": steps,
}
def run_study(max_workers: int = None, output: str = "results_mixed.jsonl", lh_samples: int = LH_SAMPLES):
configs = generate_configs(lh_samples)
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
log.info(f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)")
results = []
with ProcessPoolExecutor(max_workers=max_workers) as ex:
for i, result in enumerate(ex.map(run_single, configs)):
results.append(result)
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
Path(output).write_text("\n".join(json.dumps(r) for r in results))
log.info(f"wrote {len(results)} results to {output}")
return results
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--workers", type=int, default=None)
p.add_argument("--output", default="results_mixed.jsonl")
p.add_argument("--lh-samples", type=int, default=10)
p.add_argument("--dry-run", action="store_true", help="only show design size")
args = p.parse_args()
primary = [f for f in FACTORS if f.primary]
secondary = [f for f in FACTORS if not f.primary]
configs = generate_configs(args.lh_samples)
log.info(f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}")
if not args.dry_run:
run_study(args.workers, args.output, args.lh_samples)

View File

@@ -0,0 +1,84 @@
method: random
metric:
name: sweep/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
total_timesteps:
values: [30000, 50000, 80000]
seed:
values: [13, 42, 77]
n_products:
values: [8, 10, 12]
alpha:
distribution: uniform
min: 0.1
max: 0.6
lambda_coi:
distribution: uniform
min: 0.05
max: 0.6
robust_radius:
distribution: uniform
min: 0.0
max: 0.3
robust_points:
values: [3, 5, 7]
info_value:
distribution: uniform
min: 0.5
max: 2.0
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 1.0e-5
max: 1.0e-3
gamma:
values: [0.97, 0.99, 0.995]
buffer_size:
values: [20000, 50000, 100000]
batch_size:
values: [128, 256, 512]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4, 8]
learning_starts:
values: [500, 1000, 3000]
n_steps:
values: [512, 1024, 2048]
n_epochs:
values: [5, 10, 20]
gae_lambda:
values: [0.9, 0.95, 0.98]
clip_range:
values: [0.1, 0.2, 0.3]
ent_coef:
values: [0.0, 0.005, 0.01]
target_update_interval:
values: [500, 1000, 2000]
exploration_fraction:
values: [0.1, 0.2, 0.3]
exploration_final_eps:
values: [0.01, 0.03, 0.05]
action_levels:
values: [7, 9, 11]
action_scale_low:
values: [0.75, 0.8, 0.85]
action_scale_high:
values: [1.15, 1.2, 1.25]
q_lr:
values: [0.03, 0.05, 0.1, 0.2]
eps_start:
value: 1.0
eps_end:
values: [0.02, 0.05, 0.1]
eps_decay:
values: [0.999, 0.9995, 0.9999]

View File

@@ -0,0 +1,85 @@
method: grid
metric:
name: sweep/score
goal: maximize
run_cap: 4
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
seed:
value: 42
total_timesteps:
value: 12000
eval_episodes:
value: 3
eval_freq:
value: 500
log_freq:
value: 100
revenue_weight:
value: 0.01
n_products:
value: 8
N:
value: 80
alpha:
value: 0.3
lambda_coi:
value: 0.2
robust_radius:
value: 0.0
robust_points:
value: 1
info_value:
value: 1.0
learning_rate:
value: 0.0003
gamma:
value: 0.99
buffer_size:
value: 20000
batch_size:
value: 128
tau:
value: 0.005
train_freq:
value: 1
learning_starts:
value: 500
n_steps:
value: 512
n_epochs:
value: 10
gae_lambda:
value: 0.95
clip_range:
value: 0.2
ent_coef:
value: 0.0
target_update_interval:
value: 500
exploration_fraction:
value: 0.2
exploration_final_eps:
value: 0.05
action_levels:
value: 7
action_scale_low:
value: 0.9
action_scale_high:
value: 1.1
q_lr:
value: 0.1
q_bins:
value: 6
eps_start:
value: 1.0
eps_end:
value: 0.05
eps_decay:
value: 0.9995

View File

@@ -0,0 +1,54 @@
method: bayes
metric:
name: sweep/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
value: sac
total_timesteps:
values: [50000, 80000, 120000]
seed:
values: [13, 42, 77]
alpha:
distribution: uniform
min: 0.15
max: 0.55
n_products:
values: [8, 10, 12]
lambda_coi:
distribution: uniform
min: 0.05
max: 0.5
robust_radius:
distribution: uniform
min: 0.05
max: 0.3
robust_points:
values: [3, 5, 7]
info_value:
distribution: uniform
min: 0.5
max: 2.0
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 3.0e-5
max: 1.0e-3
gamma:
values: [0.98, 0.99, 0.995]
buffer_size:
values: [50000, 100000, 200000]
batch_size:
values: [128, 256, 512]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4, 8]
learning_starts:
values: [1000, 3000, 5000]

View File

@@ -0,0 +1,86 @@
method: random
metric:
name: sweep/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
arch:
values: [tiny, small, medium]
activation:
values: [relu, tanh]
total_timesteps:
values: [8000, 12000, 20000]
seed:
values: [13, 42, 77]
n_products:
values: [6, 8, 10]
alpha:
distribution: uniform
min: 0.1
max: 0.5
lambda_coi:
distribution: uniform
min: 0.05
max: 0.4
robust_radius:
values: [0.0, 0.1, 0.2]
robust_points:
values: [3, 5]
info_value:
values: [0.75, 1.0, 1.5]
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 1.0e-5
max: 5.0e-4
gamma:
values: [0.98, 0.99]
buffer_size:
values: [10000, 30000, 50000]
batch_size:
values: [64, 128, 256]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4]
learning_starts:
values: [500, 1000, 2000]
n_steps:
values: [256, 512, 1024]
n_epochs:
values: [5, 10]
gae_lambda:
values: [0.9, 0.95]
clip_range:
values: [0.1, 0.2]
ent_coef:
values: [0.0, 0.005]
target_update_interval:
values: [500, 1000]
exploration_fraction:
values: [0.1, 0.2]
exploration_final_eps:
values: [0.02, 0.05]
action_levels:
values: [5, 7, 9]
action_scale_low:
values: [0.85, 0.9]
action_scale_high:
values: [1.1, 1.15]
q_lr:
values: [0.05, 0.1, 0.2]
q_bins:
values: [4, 6, 8]
eps_start:
value: 1.0
eps_end:
values: [0.02, 0.05]
eps_decay:
values: [0.999, 0.9995]

521
engine/train.py Normal file
View File

@@ -0,0 +1,521 @@
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import numpy as np
from .wandb_checkpoint import checkpoint_artifact_name, download_latest_checkpoint
try:
import wandb
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
try:
from stable_baselines3 import PPO, A2C, DQN
from stable_baselines3.common.callbacks import EvalCallback
from stable_baselines3.common.monitor import Monitor
HAS_SB3 = True
except ImportError:
HAS_SB3 = False
from .jax import JAX_AVAILABLE
DEFAULT_CFG = {
"project": "phantom-pricing",
"algo": "ppo",
"seed": 42,
"total_timesteps": 50_000,
"eval_episodes": 5,
"eval_freq": 1_000,
"log_freq": 100,
"revenue_weight": 0.01,
"n_products": 10,
"N": 100,
"alpha": 0.3,
"lambda_coi": 0.2,
"robust_radius": 0.15,
"robust_points": 5,
"info_value": 1.0,
"price_low": 10.0,
"price_high": 150.0,
"action_levels": 9,
"action_scale_low": 0.8,
"action_scale_high": 1.2,
"learning_rate": 3e-4,
"gamma": 0.99,
"buffer_size": 50_000,
"batch_size": 256,
"tau": 0.005,
"train_freq": 1,
"learning_starts": 1_000,
"target_update_interval": 1_000,
"exploration_fraction": 0.2,
"exploration_final_eps": 0.05,
"n_steps": 2_048,
"n_epochs": 10,
"gae_lambda": 0.95,
"clip_range": 0.2,
"ent_coef": 0.0,
"q_lr": 0.1,
"eps_start": 1.0,
"eps_end": 0.05,
"eps_decay": 0.9995,
"model_dir": "engine/models",
"arch": "small",
"activation": "relu",
"q_bins": 6,
"max_steps": 100,
"margin_floor": 0.05,
"margin_floor_patience": 5,
"use_jax": False,
"jax_num_envs": 16,
"jax_num_steps": 128,
"jax_num_minibatches": 4,
"jax_update_epochs": 4,
"jax_anneal_lr": True,
"checkpoint_interval": 10_000,
}
def _truthy(value: str | bool | None) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _cfg(raw: dict | None = None) -> dict:
cfg = dict(DEFAULT_CFG)
if raw:
cfg.update({k: v for k, v in raw.items() if v is not None})
cfg["algo"] = str(cfg["algo"]).lower()
cfg["use_jax"] = _truthy(cfg.get("use_jax")) or _truthy(
os.environ.get("PHANTOM_USE_JAX")
)
return cfg
def _wandb_cfg_dict() -> dict:
return (
{k: wandb.config[k] for k in wandb.config.keys()}
if HAS_WANDB and wandb.run
else {}
)
def make_env(cfg: dict):
from gymnasium.wrappers import FlattenObservation
from .wrapper import PHANTOM
from .lib.wrappers import EconomicMetricsWrapper
env = PHANTOM(
n_products=int(cfg["n_products"]),
alpha=float(cfg["alpha"]),
N=int(cfg["N"]),
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
lambda_coi=float(cfg["lambda_coi"]),
robust_radius=float(cfg["robust_radius"]),
robust_points=int(cfg["robust_points"]),
info_value=float(cfg["info_value"]),
action_levels=int(cfg["action_levels"]),
action_scale_low=float(cfg["action_scale_low"]),
action_scale_high=float(cfg["action_scale_high"]),
max_steps=int(cfg.get("max_steps", 100)),
margin_floor=float(cfg.get("margin_floor", 0.05)),
margin_floor_patience=int(cfg.get("margin_floor_patience", 5)),
render_mode=None,
)
env = EconomicMetricsWrapper(env)
env = FlattenObservation(env)
return env
def _net_arch(name) -> list[int]:
presets = {
"tiny": [32, 32],
"small": [64, 64],
"medium": [128, 128],
"large": [256, 256],
}
if isinstance(name, (list, tuple)):
return [int(v) for v in name]
s = str(name).lower().strip()
if s in presets:
return presets[s]
if "x" in s:
try:
vals = [int(v) for v in s.split("x") if v]
return vals if vals else presets["small"]
except ValueError:
return presets["small"]
return presets["small"]
def _activation(name):
try:
import torch.nn as nn
except ImportError:
return None
return {
"relu": nn.ReLU,
"tanh": nn.Tanh,
"elu": nn.ELU,
"leaky_relu": nn.LeakyReLU,
}.get(str(name).lower().strip(), nn.ReLU)
def _policy_kwargs(cfg: dict) -> dict:
kw = {"net_arch": _net_arch(cfg.get("arch", "small"))}
act = _activation(cfg.get("activation", "relu"))
if act is not None:
kw["activation_fn"] = act
return kw
def _action(agent, obs, deterministic: bool = True):
out = agent.predict(obs, deterministic=deterministic)
a = out[0] if isinstance(out, tuple) else out
if isinstance(a, np.ndarray) and a.size == 1:
return int(a.reshape(-1)[0])
return a
def evaluate(agent, env, episodes: int) -> dict:
rewards, revenues = [], []
for _ in range(int(episodes)):
obs, _ = env.reset()
done, ep_r, ep_rev = False, 0.0, 0.0
while not done:
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
done = term or trunc
ep_r += float(reward)
ep_rev += float(
info.get("economics", {}).get("revenue", info.get("revenue", 0.0))
)
rewards.append(ep_r)
revenues.append(ep_rev)
return {
"eval/reward": float(np.mean(rewards)),
"eval/revenue": float(np.mean(revenues)),
"eval/reward_std": float(np.std(rewards)),
"eval/revenue_std": float(np.std(revenues)),
}
def build_model(cfg: dict, env):
algo = cfg["algo"]
policy_kwargs = _policy_kwargs(cfg)
if algo == "sac":
raise ValueError("sac is not supported with the discrete core env")
if algo == "ppo":
return PPO(
"MlpPolicy",
env,
verbose=1,
policy_kwargs=policy_kwargs,
seed=int(cfg["seed"]),
learning_rate=float(cfg["learning_rate"]),
n_steps=int(cfg["n_steps"]),
batch_size=int(cfg["batch_size"]),
n_epochs=int(cfg["n_epochs"]),
gamma=float(cfg["gamma"]),
gae_lambda=float(cfg["gae_lambda"]),
clip_range=float(cfg["clip_range"]),
ent_coef=float(cfg["ent_coef"]),
)
if algo == "a2c":
return A2C(
"MlpPolicy",
env,
verbose=1,
policy_kwargs=policy_kwargs,
seed=int(cfg["seed"]),
learning_rate=float(cfg["learning_rate"]),
n_steps=max(5, int(cfg["n_steps"]) // 32),
gamma=float(cfg["gamma"]),
gae_lambda=float(cfg["gae_lambda"]),
ent_coef=float(cfg["ent_coef"]),
)
if algo == "dqn":
return DQN(
"MlpPolicy",
env,
verbose=1,
policy_kwargs=policy_kwargs,
seed=int(cfg["seed"]),
learning_rate=float(cfg["learning_rate"]),
buffer_size=int(cfg["buffer_size"]),
batch_size=int(cfg["batch_size"]),
gamma=float(cfg["gamma"]),
train_freq=int(cfg["train_freq"]),
learning_starts=int(cfg["learning_starts"]),
target_update_interval=int(cfg["target_update_interval"]),
exploration_fraction=float(cfg["exploration_fraction"]),
exploration_final_eps=float(cfg["exploration_final_eps"]),
)
raise ValueError(f"unsupported algo '{algo}'")
def _sb3_model_cls(algo: str):
if algo == "ppo":
return PPO
if algo == "a2c":
return A2C
if algo == "dqn":
return DQN
raise ValueError(f"unsupported algo '{algo}'")
def train_qtable(cfg: dict) -> tuple[EventQTable, dict]:
from .lib.discrete import EventQTable
np.random.seed(int(cfg["seed"]))
env = make_env(cfg)
eval_env = make_env(cfg)
agent = EventQTable(
env.action_space.n,
int(cfg["n_products"]),
(float(cfg["price_low"]), float(cfg["price_high"])),
lr=float(cfg["q_lr"]),
gamma=float(cfg["gamma"]),
n_bins=int(cfg["q_bins"]),
)
eps = float(cfg["eps_start"])
obs, _ = env.reset(seed=int(cfg["seed"]))
for t in range(int(cfg["total_timesteps"])):
a, s = agent.act(obs, eps)
nxt, reward, term, trunc, info = env.step(a)
done = term or trunc
agent.update(s, a, float(reward), agent.encode(nxt), done)
eps = max(float(cfg["eps_end"]), eps * float(cfg["eps_decay"]))
if HAS_WANDB and wandb.run and (t + 1) % int(cfg["log_freq"]) == 0:
econ = info.get("economics", {})
wandb.log(
{
"train/reward": float(reward),
"train/revenue": float(econ.get("revenue", 0.0)),
"train/epsilon": float(eps),
},
step=t + 1,
)
obs = env.reset()[0] if done else nxt
metrics = evaluate(agent, eval_env, int(cfg["eval_episodes"]))
metrics["train/global_step"] = int(cfg["total_timesteps"])
env.close()
eval_env.close()
return agent, metrics
def train_sb3(cfg: dict) -> tuple[object, dict]:
if not HAS_SB3:
raise ImportError("stable-baselines3 is required for SB3 models")
from .lib.callbacks import CheckpointArtifactCallback, MetricsCallback
env = make_env(cfg)
eval_env = make_env(cfg)
env = Monitor(env)
eval_env = Monitor(eval_env)
model = build_model(cfg, env)
resume_step = 0
if HAS_WANDB and wandb.run is not None:
sweep_id = getattr(wandb.run, "sweep_id", None)
artifact_name = checkpoint_artifact_name(cfg, backend="sb3", sweep_id=sweep_id)
checkpoint_file = f"phantom_{cfg['algo']}_checkpoint.zip"
restored = download_latest_checkpoint(artifact_name, file_name=checkpoint_file)
if restored is not None:
checkpoint_path, metadata = restored
model = _sb3_model_cls(cfg["algo"]).load(
checkpoint_path.as_posix(), env=env
)
resume_step = int(metadata.get("step", getattr(model, "num_timesteps", 0)))
model.num_timesteps = max(
int(getattr(model, "num_timesteps", 0)), resume_step
)
cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))]
cbs.append(
CheckpointArtifactCallback(
cfg,
interval=int(cfg.get("checkpoint_interval", 10_000)),
)
)
cbs.append(
EvalCallback(
eval_env,
eval_freq=int(cfg["eval_freq"]),
n_eval_episodes=int(cfg["eval_episodes"]),
deterministic=True,
verbose=0,
)
)
target_steps = int(cfg["total_timesteps"])
remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0)))
if remaining_steps > 0:
model.learn(
total_timesteps=remaining_steps,
callback=cbs,
reset_num_timesteps=False,
)
model_path = Path(cfg["model_dir"])
model_path.mkdir(parents=True, exist_ok=True)
model.save(str(model_path / f"phantom_{cfg['algo']}"))
metrics = evaluate(model, eval_env, int(cfg["eval_episodes"]))
metrics["train/global_step"] = int(model.num_timesteps)
env.close()
eval_env.close()
return model, metrics
def train_once(cfg: dict) -> dict:
algo = cfg["algo"]
if cfg.get("use_jax"):
if not JAX_AVAILABLE:
raise ImportError(
"JAX backend requested but JAX is not installed. "
"Install engine/jax/requirements.txt and jax[tpu] for TPU runs."
)
try:
from .jax.train import train_jax
except Exception as exc: # pragma: no cover
raise ImportError(f"Failed to import JAX trainer: {exc}") from exc
_, metrics = train_jax(cfg)
elif algo == "qtable":
_, metrics = train_qtable(cfg)
else:
_, metrics = train_sb3(cfg)
metrics["sweep/score"] = float(
metrics["eval/reward"] + float(cfg["revenue_weight"]) * metrics["eval/revenue"]
)
return metrics
def run_wandb(
project: str, overrides: dict, mode: str = "online", sweep_mode: bool = False
) -> dict:
if not HAS_WANDB:
raise ImportError("wandb is required for sweep runs")
init_kwargs = {"mode": mode}
if sweep_mode:
run = wandb.init(**init_kwargs)
else:
run = wandb.init(project=project, config=overrides, **init_kwargs)
try:
cfg = _cfg(_wandb_cfg_dict())
if sweep_mode:
for k, v in overrides.items():
if k not in wandb.config:
cfg[k] = v
metrics = train_once(cfg)
step = int(metrics.get("train/global_step", cfg["total_timesteps"]))
wandb.log(metrics, step=step)
for k, v in metrics.items():
run.summary[k] = v
return metrics
finally:
if wandb.run is not None:
wandb.finish()
def run_local(overrides: dict) -> dict:
cfg = _cfg(overrides)
metrics = train_once(cfg)
print(json.dumps(metrics, indent=2))
return metrics
def main():
p = argparse.ArgumentParser(description="PHANTOM training and W&B sweeps")
p.add_argument("--project", default=DEFAULT_CFG["project"])
p.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable"])
p.add_argument("--total-timesteps", type=int)
p.add_argument("--alpha", type=float)
p.add_argument("--n-products", type=int)
p.add_argument("--lambda-coi", type=float)
p.add_argument("--robust-radius", type=float)
p.add_argument("--robust-points", type=int)
p.add_argument("--learning-rate", type=float)
p.add_argument("--gamma", type=float)
p.add_argument("--revenue-weight", type=float)
p.add_argument("--max-steps", type=int)
p.add_argument("--margin-floor", type=float)
p.add_argument("--margin-floor-patience", type=int)
p.add_argument("--arch", type=str)
p.add_argument("--activation", type=str)
p.add_argument("--jax", action="store_true")
p.add_argument("--jax-num-envs", type=int)
p.add_argument("--jax-num-steps", type=int)
p.add_argument("--jax-num-minibatches", type=int)
p.add_argument("--jax-update-epochs", type=int)
p.add_argument("--jax-anneal-lr", type=str)
p.add_argument("--checkpoint-interval", type=int)
p.add_argument("--sweep-agent", action="store_true")
p.add_argument("--sweep-id", type=str)
p.add_argument("--count", type=int, default=0)
p.add_argument("--offline", action="store_true")
p.add_argument("--no-wandb", action="store_true")
args = p.parse_args()
overrides = {
"algo": args.algo,
"total_timesteps": args.total_timesteps,
"alpha": args.alpha,
"n_products": args.n_products,
"lambda_coi": args.lambda_coi,
"robust_radius": args.robust_radius,
"robust_points": args.robust_points,
"learning_rate": args.learning_rate,
"gamma": args.gamma,
"revenue_weight": args.revenue_weight,
"max_steps": args.max_steps,
"margin_floor": args.margin_floor,
"margin_floor_patience": args.margin_floor_patience,
"arch": args.arch,
"activation": args.activation,
"use_jax": args.jax,
"jax_num_envs": args.jax_num_envs,
"jax_num_steps": args.jax_num_steps,
"jax_num_minibatches": args.jax_num_minibatches,
"jax_update_epochs": args.jax_update_epochs,
"checkpoint_interval": args.checkpoint_interval,
"jax_anneal_lr": _truthy(args.jax_anneal_lr)
if args.jax_anneal_lr is not None
else None,
}
overrides = {k: v for k, v in overrides.items() if v is not None}
if args.sweep_agent:
if args.no_wandb:
raise ValueError("sweep agent requires wandb")
if not args.sweep_id:
raise ValueError("--sweep-id is required with --sweep-agent")
mode = "offline" if args.offline else "online"
wandb.agent(
args.sweep_id,
function=lambda: run_wandb(
args.project, overrides, mode=mode, sweep_mode=True
),
count=args.count if args.count > 0 else None,
)
return
if args.no_wandb or not HAS_WANDB:
run_local(overrides)
return
run_wandb(args.project, overrides, mode="offline" if args.offline else "online")
if __name__ == "__main__":
main()

366
engine/wrapper.py Normal file
View File

@@ -0,0 +1,366 @@
import gymnasium as gym
from gymnasium import spaces
import numpy as np
from .engine import Limbo, MarketEngine, PricingEngine
from .lib.render import DashboardRenderer
from .lib.coi import (
compute_uplift_coi,
extract_purchases,
compute_agent_probability,
)
from .lib.behavior import get_transition_models, trajectory_to_events
from .lib.wrappers import EconomicMetricsWrapper
class _ActionPricingEngine(PricingEngine):
def __init__(self, n_products: int, price_bounds: tuple):
self._prices = np.full(n_products, price_bounds[0], dtype=float)
def set_prices(self, prices: np.ndarray):
self._prices = np.asarray(prices, dtype=float)
def act(self, _):
return self._prices
class PHANTOM(gym.Env):
"""Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework
reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL
COI_leak uses behavioral divergence to estimate agent probability f(τ')
robust inner step: min over alpha in Wasserstein interval around nominal alpha
actions are discrete global price-scale moves
"""
metadata = {"render_modes": ["human", "ansi"]}
def __init__(
self,
n_products: int = 10,
alpha: float = 0.3,
N: int = 100,
human_params: tuple = (50.0, 10.0),
agent_params: tuple = (45.0, 15.0),
noise_std: float = 1.0,
price_bounds: tuple = (10.0, 150.0),
lambda_coi: float = 0.1,
coi_window: int = 10,
robust_radius: float = 0.0,
robust_points: int = 5,
info_value: float = 1.0,
action_levels: int = 9,
action_scale_low: float = 0.9,
action_scale_high: float = 1.1,
max_steps: int = 100,
margin_floor: float = 0.05,
margin_floor_patience: int = 5,
render_mode: str = None,
):
super().__init__()
self.n_products = n_products
self.price_bounds = price_bounds
self.lambda_coi = lambda_coi
self.coi_window = coi_window
self.max_steps = max(1, int(max_steps))
self.margin_floor = float(
margin_floor
) # terminate if avg margin stays below this for patience steps
self.margin_floor_patience = max(1, int(margin_floor_patience))
self.render_mode = render_mode
self.alpha = float(alpha)
self.nominal_alpha = float(alpha)
self.N = N
self.human_params = human_params
self.agent_params = agent_params
self.robust_radius = max(0.0, float(robust_radius))
self.robust_points = max(1, int(robust_points))
self.info_value = float(info_value)
self.action_levels = max(2, int(action_levels))
self._action_scales = np.linspace(
float(action_scale_low), float(action_scale_high), self.action_levels
)
self.market = MarketEngine(
alpha=alpha,
N=N,
human_params=human_params,
agent_params=agent_params,
noise_std=noise_std,
)
self._platform_stub = _ActionPricingEngine(n_products, price_bounds)
self._limbo = Limbo(self._platform_stub, self.market)
self._set_market_mix(self.nominal_alpha)
self.action_space = spaces.Discrete(self.action_levels)
self.observation_space = spaces.Dict(
{
"demand": spaces.Box(
low=0.0, high=100.0, shape=(n_products,), dtype=np.float32
),
"prices": spaces.Box(
low=price_bounds[0],
high=price_bounds[1],
shape=(n_products,),
dtype=np.float32,
),
}
)
self._prices = None
self._demand = None
self._step_count = 0
self._demand_history = []
self._price_history = []
self._revenue_history = []
self._renderer = None
self._initial_episode_prices = None
self._trajectories = [] # session trajectories for agent prob calculation
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
self._low_margin_streak = 0 # consecutive steps below margin_floor
# load behavioral models for agent probability estimation
try:
self._human_trans, self._agent_trans = get_transition_models()
except Exception:
# fallback if behavioral data unavailable
self._human_trans, self._agent_trans = None, None
def _get_obs(self) -> dict:
demand_arr = np.array(
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
)
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
def _set_market_mix(self, alpha: float):
alpha = float(np.clip(alpha, 0.0, 1.0))
n_agents = int(self.N * alpha)
self.alpha = alpha
self.market.alpha = alpha
self.market.Nagents = n_agents
self.market.Nhumans = self.N - n_agents
def _decode_action(self, action) -> np.ndarray:
base = (
self._prices
if self._prices is not None
else np.full(self.n_products, self.price_bounds[0], dtype=float)
)
if np.isscalar(action):
idx = int(np.clip(int(action), 0, self.action_levels - 1))
return np.clip(base * self._action_scales[idx], *self.price_bounds)
a = np.asarray(action)
if a.size == 1:
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
return np.clip(base * self._action_scales[idx], *self.price_bounds)
return np.clip(a.astype(float), *self.price_bounds)
def _compute_agent_prob(self, trajectories=None) -> float:
trajectories = (
self.market.last_trajectories if trajectories is None else trajectories
)
if not trajectories or self._human_trans is None or self._agent_trans is None:
return float(self.market.alpha)
probs = []
for traj in trajectories:
events = trajectory_to_events(traj)
if len(events) < 2:
continue
probs.append(
compute_agent_probability(events, self._human_trans, self._agent_trans)
)
return float(np.mean(probs)) if probs else float(self.market.alpha)
def _compute_reward(
self, prices: np.ndarray, demand: dict, agent_prob: float, trajectories: list
) -> tuple[float, dict]:
demand_arr = np.array(
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
)
revenue = float(np.dot(prices, demand_arr))
purchases = extract_purchases(trajectories)
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
# multiplicative penalty so COI term scales with revenue magnitude
coi_leakage = float(agent_prob * self.info_value)
discount = float(np.clip(1.0 - self.lambda_coi * coi_leakage, 0.0, 1.0))
coi_penalty = revenue * (1.0 - discount) # absolute penalty in revenue units
reward = revenue * discount
return reward, {
"revenue": revenue,
"coi_mix": float(coi_mix),
"coi_base": 0.0,
"coi_leakage": coi_leakage,
"coi_penalty": coi_penalty,
"coi_discount": discount,
}
def _alpha_candidates(self) -> np.ndarray:
if self.robust_radius <= 0.0 or self.robust_points == 1:
return np.array([self.nominal_alpha], dtype=float)
lo = max(0.0, self.nominal_alpha - self.robust_radius)
hi = min(1.0, self.nominal_alpha + self.robust_radius)
return np.linspace(lo, hi, self.robust_points)
def _select_adversarial_alpha(
self, prices: np.ndarray
) -> tuple[float, dict, list, float]:
"""inner robust step: pick worst-case alpha and return its outcome directly to avoid double-sampling"""
candidates = self._alpha_candidates()
best_alpha, worst_reward = float(candidates[0]), np.inf
best_demand, best_trajectories, best_agent_prob = None, [], 0.0
for alpha in candidates:
self._set_market_mix(float(alpha))
demand = self.market.act(prices)
trajectories = list(self.market.last_trajectories)
agent_prob = self._compute_agent_prob(trajectories)
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
if reward < worst_reward:
worst_reward = reward
best_alpha, best_demand, best_trajectories, best_agent_prob = (
float(alpha),
demand,
trajectories,
agent_prob,
)
return best_alpha, best_demand, best_trajectories, best_agent_prob
def _record_history(self):
demand_arr = np.array(
[self._demand.get(i, 0.0) for i in range(self.n_products)]
)
self._demand_history.append(demand_arr)
self._price_history.append(self._prices.copy())
self._revenue_history.append(np.sum(self._prices * demand_arr))
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self._set_market_mix(self.nominal_alpha)
self._limbo.reset()
self._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
self._platform_stub.set_prices(self._prices)
self._limbo.step()
self._demand = self._limbo.step()
self._initial_episode_prices = self._prices.copy()
self._step_count = 0
self._low_margin_streak = 0
self._demand_history, self._price_history, self._revenue_history = [], [], []
self._trajectories = list(getattr(self.market, "last_trajectories", []))
self._record_history()
return self._get_obs(), {}
def step(self, action):
self._prices = self._decode_action(action)
# inner robust step returns worst-case outcome directly, no re-sampling
alpha_adv, self._demand, trajectories, agent_prob = (
self._select_adversarial_alpha(self._prices)
)
self._set_market_mix(alpha_adv)
self._platform_stub.set_prices(self._prices)
self._step_count += 1
self._trajectories.extend(trajectories)
reward, metrics = self._compute_reward(
self._prices, self._demand, agent_prob, trajectories
)
self._record_history()
# soft early termination when margin collapses for too long
avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max(
float(np.mean(self._prices)), 1e-6
)
if avg_margin < self.margin_floor:
self._low_margin_streak += 1
else:
self._low_margin_streak = 0
margin_collapsed = self._low_margin_streak >= self.margin_floor_patience
terminated = self._step_count >= self.max_steps or margin_collapsed
info = {
"step": self._step_count,
"agent_prob": agent_prob,
"alpha_adv": float(alpha_adv),
"wasserstein_radius": float(self.robust_radius),
**metrics,
"raw_revenue": np.sum(
self._prices
* np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
),
}
return self._get_obs(), reward, terminated, False, info
def _compute_elasticity(self) -> np.ndarray:
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
if len(self._price_history) < 2:
return np.zeros(self.n_products)
p, q = np.array(self._price_history), np.array(self._demand_history)
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
valid = np.abs(dp) > 0.5
with np.errstate(divide="ignore", invalid="ignore"):
elasticity = np.where(
valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0
)
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
return (
np.mean(elasticity, axis=0)
if len(elasticity) > 0
else np.zeros(self.n_products)
)
def render(self):
if self.render_mode == "human":
if self._renderer is None:
self._renderer = DashboardRenderer()
self._renderer.render(self)
elif self.render_mode == "ansi":
return (
f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
)
return None
def close(self):
if self._renderer:
self._renderer.close()
self._renderer = None
if __name__ == "__main__":
import wandb
from .lib import MetricsCallback
class RandomPolicy:
"""Minimal SB3-compatible random policy for baseline testing."""
def __init__(self, env):
self.env = env
self.num_timesteps = 0
def learn(self, total_timesteps, callback=None):
callback.model = self
callback.num_timesteps = 0
callback.locals = {}
callback.on_training_start({}, {})
obs, _ = self.env.reset()
for step in range(total_timesteps):
action = self.env.action_space.sample()
obs, reward, term, trunc, info = self.env.step(action)
self.num_timesteps = step + 1
callback.num_timesteps = self.num_timesteps
callback.locals = {"infos": [info]}
callback.on_step()
if term or trunc:
callback.on_rollout_end()
obs, _ = self.env.reset()
return self
def predict(self, obs, **kwargs):
return self.env.action_space.sample(), None
wandb.init(project="phantom-pricing", config={"policy": "random", "alpha": 0.3})
env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None))
model = RandomPolicy(env)
model.learn(total_timesteps=1000, callback=MetricsCallback())
print(f"Episode revenue: {env.episode_revenue:.1f}")
wandb.finish()
env.close()

117
experiments/agents/run.py Normal file
View File

@@ -0,0 +1,117 @@
from supabase import create_client, Client
import os
import random
import asyncio
import json
from dotenv import load_dotenv
from experiments.agents.agent import get_agent, AgentTypes
from lib.kafka_client import get_interactions
load_dotenv()
RESULTS="/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
client = create_client(
os.getenv("NEXT_PUBLIC_SUPABASE_URL"),
os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
)
def pick_random_task():
mode = 'hotel'
tasks = client.table("tasks").select("*").execute().data
if mode == 'hotel':
# drop all that have 'flight' in the description
tasks = [task for task in tasks if 'flight' not in task['task_description'].lower()]
return random.choice(tasks) if tasks else None
def clear_kafka_data():
"""Delete and recreate Kafka topics to clear all data"""
from kafka.admin import KafkaAdminClient, NewTopic
from kafka.errors import UnknownTopicOrPartitionError
import time
kafka_host = os.getenv('KAFKA_HOST', 'localhost')
kafka_port = os.getenv('KAFKA_PORT', '9092')
broker = f'{kafka_host}:{kafka_port}'
admin = KafkaAdminClient(bootstrap_servers=broker)
topics = ['user-interactions', 'price-logs']
try:
admin.delete_topics(topics, timeout_ms=5000)
print(f"Deleted topics: {topics}")
time.sleep(2)
except UnknownTopicOrPartitionError:
print("Topics don't exist, skipping delete")
except Exception as e:
print(f"Error deleting topics: {e}")
new_topics = [
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
]
try:
admin.create_topics(new_topics=new_topics, validate_only=False)
print(f"Recreated topics: {topics}")
except Exception as e:
print(f"Error creating topics: {e}")
finally:
admin.close()
def create_new_experiment(task_id):
import uuid
subject_name = f"agent_{str(uuid.uuid4())[:8]}"
experiment = {
"subject_name": subject_name,
"xp_human_only": False,
"xp_market_mode": "hotel",
"xp_task_id": task_id,
}
response = client.table("experiments").insert(experiment).execute()
return response.data[0] if response.data else None
if __name__ == "__main__":
clear_kafka_data()
task = pick_random_task()
if not task:
print("No tasks available")
exit(1)
experiment = create_new_experiment(task['id'])
exp_id = experiment['id']
exp_dir = f"{RESULTS}{exp_id}"
os.makedirs(exp_dir, exist_ok=True)
# construct experiment URL with uuid param
base_url = os.getenv('NEXT_PUBLIC_API_BASE', 'http://localhost:3000')
agent_url = f"{base_url}/start-task?uuid={exp_id}"
print(f"Created experiment {exp_id} for task {task['id']}")
print(f"Agent will interact with: {agent_url}")
# instantiate and run agent
agent = get_agent(
AgentTypes.GENERIC_BROWSER_USE_AGENT,
goal=task['task_description'],
url=agent_url,
timeout=300,
headless=True
)
result = asyncio.run(agent.act())
print(f"Agent result: {result}")
# export interaction and price data from kafka
interactions = get_interactions(topic='user-interactions', timeout_ms=3000)
prices = get_interactions(topic='price-logs', timeout_ms=3000)
with open(f"{exp_dir}/int.json", 'w') as f:
json.dump(interactions, f, indent=2)
with open(f"{exp_dir}/price.json", 'w') as f:
json.dump(prices, f, indent=2)
print(f"Experiment {exp_id} completed.")
print(f"Exported {len(interactions)} interactions and {len(prices)} price logs to {exp_dir}")

View File

@@ -1,3 +1,4 @@
from pandas.core.algorithms import factorize_array
from airflow import DAG from airflow import DAG
from airflow.operators.python import PythonOperator from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago from airflow.utils.dates import days_ago
@@ -208,3 +209,12 @@ def create_surge_pricing_dag(store_mode: str) -> DAG:
# instantiate DAGs for Airflow to discover # instantiate DAGs for Airflow to discover
dag_airline = create_surge_pricing_dag('airline') dag_airline = create_surge_pricing_dag('airline')
dag_hotel = create_surge_pricing_dag('hotel') dag_hotel = create_surge_pricing_dag('hotel')
# TODO: Refactor this factory from a surge pricing factory to a general pricing factory
# We will do this by passing a pricing strategy class to the factory, since the generic pipeline is:
# take all interaction data, group by sessionId and assign a new price vector to each session
# in the grouping we get a subset of the interactions per sessionId and we can map that to some Features
# we define a custom _get_features(interactions .) methodin the strategy class
# we then run only the inference which is the .predict(trajectory) per-session which will give us a new price vector
# this we then publish for each sessionId group
# this might include no deleting most of the pricers we have defined and starting with a super simple surge-pricing algorithm that is no-fit only predict. This we can then test end-to-end and observe changes to prices according to a desired strategy - we have to define this one as a very short term strategy because we run sessions that take only a few minutes.

View File

@@ -120,15 +120,31 @@ def apply_surge_pricing(**kwargs):
# rename demand_score to demand for pricer compatibility # rename demand_score to demand for pricer compatibility
data = product_features.rename(columns={'demand_score': 'demand'}) data = product_features.rename(columns={'demand_score': 'demand'})
high_thresh = dag_conf.get('high_threshold', 10)
low_thresh = dag_conf.get('low_threshold', 2)
surge_mult = dag_conf.get('surge_multiplier', 1.2)
discount_mult = dag_conf.get('discount_multiplier', 0.9)
logging.info(f"Surge pricing config: high_thresh={high_thresh}, low_thresh={low_thresh}, surge_mult={surge_mult}, discount_mult={discount_mult}")
logging.info(f"Demand stats: min={data['demand'].min():.2f}, max={data['demand'].max():.2f}, mean={data['demand'].mean():.2f}")
logging.info(f"Products with high demand (>={high_thresh}): {(data['demand'] >= high_thresh).sum()}")
logging.info(f"Products with low demand (<={low_thresh}): {(data['demand'] <= low_thresh).sum()}")
surge_pricer = SimpleSurgePricer( surge_pricer = SimpleSurgePricer(
high_threshold=dag_conf.get('high_threshold', 10), high_threshold=high_thresh,
low_threshold=dag_conf.get('low_threshold', 2), low_threshold=low_thresh,
surge_multiplier=dag_conf.get('surge_multiplier', 1.2), surge_multiplier=surge_mult,
discount_multiplier=dag_conf.get('discount_multiplier', 0.9) discount_multiplier=discount_mult
) )
surge_pricer.fit(data) surge_pricer.fit(data)
data['optimal_price'] = surge_pricer.predict() data['optimal_price'] = surge_pricer.predict()
base_avg = data['base_price'].mean()
optimal_avg = data['optimal_price'].mean()
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
logging.info(f"Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={ prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
'price': 'current_price', 'price': 'current_price',
'demand': 'demand_score' 'demand': 'demand_score'

View File

@@ -1,11 +1,21 @@
from .evals import evaluate from .evals import evaluate
from .arch import ( from .arch import (
XGBoostAgentClassifier, XGBoostAgentClassifier,
LightGBMAgentClassifier LightGBMAgentClassifier,
ContrastiveWeakClassifier,
TrajectoryEncoder,
WeakClassifier,
contrastive_loss,
featurize_trajectory,
) )
__all__ =[ __all__ = [
'evaluate', 'evaluate',
'XGBoostAgentClassifier', 'XGBoostAgentClassifier',
'LightGBMAgentClassifier' 'LightGBMAgentClassifier',
'ContrastiveWeakClassifier',
'TrajectoryEncoder',
'WeakClassifier',
'contrastive_loss',
'featurize_trajectory',
] ]

View File

@@ -1,122 +1,212 @@
# sklearn compatible models for agent detection # sklearn compatible models for agent detection
from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.base import BaseEstimator, ClassifierMixin
from procesing.context import PipelineContext from typing import Any, Optional, Tuple, Dict, List
from typing import Any, Optional, Tuple
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import xgboost as xgb from collections import defaultdict
import lightgbm as lgb
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
import sys
from pathlib import Path
# add lib to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'lib'))
from lib.features import (
transition_histogram as _lib_transition_histogram,
temporal_signature as _lib_temporal_signature,
state_coverage as _lib_state_coverage,
transition_entropy as _lib_transition_entropy,
featurize_trajectory as _lib_featurize_trajectory,
parse_timestamp
)
from lib.state import event_to_state, get_event_name, get_timestamp
TASK = 'classification' TASK = 'classification'
LABELS = ['human', 'agent'] LABELS = ['human', 'agent']
class BaseAgentClassifier(BaseEstimator, ClassifierMixin, ABC): class WeakClassifier(BaseEstimator, ClassifierMixin, ABC):
"""Base class for tree-based agent detection classifiers with common logic""" # a simple contrastive machine learning model learns to distinguish human/agent behavior
# using weakly supervised contrastive learning + augmentation
def __init__(self, **kwargs):
super().__init__()
self.model = None
self.kwargs = kwargs
def __init__(self, context: Optional[PipelineContext] = None, n_estimators: int = 200,
max_depth: int = 6, learning_rate: float = 0.05, class TrajectoryEncoder(nn.Module):
early_stopping_rounds: int = 20): """Encode variable-length event sequences to fixed-dim embedding via bidirectional LSTM"""
self.context = context def __init__(self, input_dim: int, embed_dim: int = 32, hidden_dim: int = 64):
super().__init__()
self.event_embed = nn.Linear(input_dim, hidden_dim)
self.lstm = nn.LSTM(hidden_dim, hidden_dim, batch_first=True, bidirectional=True)
self.proj = nn.Linear(hidden_dim * 2, embed_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, input_dim)
h = F.relu(self.event_embed(x))
_, (hn, _) = self.lstm(h)
hn = torch.cat([hn[-2], hn[-1]], dim=1) # concat bidirectional hidden states
return F.normalize(self.proj(hn), dim=1) # L2 normalized
class ContrastiveWeakClassifier(WeakClassifier):
"""Contrastive learning classifier for human/agent trajectory discrimination"""
def __init__(self, input_dim: int = 64, embed_dim: int = 32, margin: float = 1.0, **kwargs):
super().__init__(**kwargs)
self.input_dim = input_dim
self.embed_dim = embed_dim
self.margin = margin
self.encoder = TrajectoryEncoder(input_dim, embed_dim)
self.classifier = nn.Linear(embed_dim, 2)
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self._fitted = False
def to_device(self):
self.encoder.to(self.device)
self.classifier.to(self.device)
return self
def encode(self, x: torch.Tensor) -> torch.Tensor:
return self.encoder(x.to(self.device))
def forward(self, x: torch.Tensor) -> torch.Tensor:
emb = self.encode(x)
return self.classifier(emb)
def fit(self, X, y=None): # sklearn interface - actual training in weak.train.py
self._fitted = True
return self
def predict(self, X: np.ndarray) -> np.ndarray:
self.encoder.eval()
self.classifier.eval()
with torch.no_grad():
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
logits = self.forward(x)
return torch.argmax(logits, dim=1).cpu().numpy()
def predict_proba(self, X: np.ndarray) -> np.ndarray:
self.encoder.eval()
self.classifier.eval()
with torch.no_grad():
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
logits = self.forward(x)
return F.softmax(logits, dim=1).cpu().numpy()
def contrastive_loss(anchor: torch.Tensor, positive: torch.Tensor, negative: torch.Tensor, margin: float = 0.3) -> torch.Tensor:
"""Triplet loss using cosine similarity (for L2-normalized embeddings). margin in [0,1] range."""
pos_sim = F.cosine_similarity(anchor, positive) # higher = more similar
neg_sim = F.cosine_similarity(anchor, negative)
return F.relu(neg_sim - pos_sim + margin).mean() # want pos_sim > neg_sim + margin
def nt_xent_loss(z_i: torch.Tensor, z_j: torch.Tensor, temperature: float = 0.5) -> torch.Tensor:
"""Normalized temperature-scaled cross entropy loss (SimCLR style)"""
batch_size = z_i.size(0)
z = torch.cat([z_i, z_j], dim=0) # (2N, embed_dim)
sim = F.cosine_similarity(z.unsqueeze(1), z.unsqueeze(0), dim=2) / temperature
mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device)
sim.masked_fill_(mask, -float('inf'))
labels = torch.arange(batch_size, device=z.device)
labels = torch.cat([labels + batch_size, labels]) # positive pairs
return F.cross_entropy(sim, labels)
# feature extraction utilities - delegating to lib.features for unified implementation
# these wrappers maintain backwards compatibility for existing imports
def transition_histogram(events: List, state_fn, max_states: int = 50) -> np.ndarray:
"""Compute normalized histogram of state transitions in trajectory"""
return _lib_transition_histogram(events, state_fn, max_states)
def temporal_signature(events: List, ts_fn) -> np.ndarray:
"""Extract temporal features: mean/std/skew of inter-event times"""
return _lib_temporal_signature(events, ts_fn)
def state_coverage(events: List, state_fn, mdp_states: set) -> float:
"""Fraction of MDP states visited by trajectory"""
return _lib_state_coverage(events, state_fn, mdp_states)
def transition_entropy(events: List, state_fn) -> float:
"""Compute entropy of transition distribution (randomness of navigation)"""
return _lib_transition_entropy(events, state_fn)
def featurize_trajectory(events: List, mdp: Optional[Dict] = None, input_dim: int = 64) -> np.ndarray:
"""Convert trajectory to fixed-dim feature vector - uses lib.features implementation"""
mdp_states = set(mdp.get('states', [])) if mdp else set()
def _ts_fn(e):
return parse_timestamp(get_timestamp(e))
def _event_name_fn(e):
return get_event_name(e)
return _lib_featurize_trajectory(events, event_to_state, _ts_fn, _event_name_fn, mdp_states, input_dim)
# gradient boosting classifiers for comparison baselines
class XGBoostAgentClassifier(BaseEstimator, ClassifierMixin):
"""XGBoost classifier for human/agent detection from session features"""
def __init__(self, n_estimators: int = 100, max_depth: int = 6, learning_rate: float = 0.1, **kwargs):
self.n_estimators = n_estimators self.n_estimators = n_estimators
self.max_depth = max_depth self.max_depth = max_depth
self.learning_rate = learning_rate self.learning_rate = learning_rate
self.early_stopping_rounds = early_stopping_rounds self.model = None
self.model_ = None self.kwargs = kwargs
self.feature_names_ = None
def _to_array(self, X):
"""Convert pandas structures to numpy arrays"""
return X.values if isinstance(X, (pd.DataFrame, pd.Series)) else X
def _compute_pos_weight(self, y_arr):
"""Calculate scale_pos_weight for class imbalance handling"""
n_neg, n_pos = (y_arr == 0).sum(), (y_arr == 1).sum()
return n_neg / n_pos if n_pos > 0 else 1.0
def _prepare_eval_set(self, eval_set):
"""Convert eval_set to numpy arrays if needed"""
if not eval_set:
return None
X_val, y_val = eval_set[0]
return [(self._to_array(X_val), self._to_array(y_val))]
@abstractmethod
def _build_model(self, scale_pos: float):
"""Build the underlying model instance (must be implemented by subclasses)"""
pass
@abstractmethod
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
"""Fit model with evaluation set (must be implemented by subclasses)"""
pass
def fit(self, X, y, eval_set=None):
X_arr, y_arr = self._to_array(X), self._to_array(y)
if isinstance(X, pd.DataFrame):
self.feature_names_ = X.columns.tolist()
scale_pos = self._compute_pos_weight(y_arr)
self.model_ = self._build_model(scale_pos)
eval_arr = self._prepare_eval_set(eval_set)
if eval_arr:
self._fit_with_eval(X_arr, y_arr, eval_arr)
else:
self.model_.fit(X_arr, y_arr)
def fit(self, X: np.ndarray, y: np.ndarray):
try:
import xgboost as xgb
self.model = xgb.XGBClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
learning_rate=self.learning_rate, **self.kwargs)
self.model.fit(X, y)
except ImportError:
raise ImportError("xgboost required for XGBoostAgentClassifier")
return self return self
def predict(self, X): def predict(self, X: np.ndarray) -> np.ndarray:
return self.model_.predict(self._to_array(X)) if self.model is None:
raise ValueError("fit the model first")
return self.model.predict(X)
def predict_proba(self, X): def predict_proba(self, X: np.ndarray) -> np.ndarray:
return self.model_.predict_proba(self._to_array(X)) if self.model is None:
raise ValueError("fit the model first")
@property return self.model.predict_proba(X)
def feature_importances_(self):
return self.model_.feature_importances_ if self.model_ else None
class XGBoostAgentClassifier(BaseAgentClassifier): class LightGBMAgentClassifier(BaseEstimator, ClassifierMixin):
"""XGBoost binary classifier for agent detection with class imbalance handling""" """LightGBM classifier for human/agent detection from session features"""
def __init__(self, n_estimators: int = 100, max_depth: int = -1, learning_rate: float = 0.1, **kwargs):
self.n_estimators = n_estimators
self.max_depth = max_depth
self.learning_rate = learning_rate
self.model = None
self.kwargs = kwargs
def _build_model(self, scale_pos: float): def fit(self, X: np.ndarray, y: np.ndarray):
return xgb.XGBClassifier( try:
n_estimators=self.n_estimators, import lightgbm as lgb
max_depth=self.max_depth, self.model = lgb.LGBMClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
learning_rate=self.learning_rate, learning_rate=self.learning_rate, verbose=-1, **self.kwargs)
scale_pos_weight=scale_pos, self.model.fit(X, y)
eval_metric='auc', except ImportError:
early_stopping_rounds=self.early_stopping_rounds, raise ImportError("lightgbm required for LightGBMAgentClassifier")
random_state=42, return self
tree_method='hist',
enable_categorical=False
)
def _fit_with_eval(self, X_arr, y_arr, eval_arr): def predict(self, X: np.ndarray) -> np.ndarray:
self.model_.fit(X_arr, y_arr, eval_set=eval_arr, verbose=False) if self.model is None:
raise ValueError("fit the model first")
return self.model.predict(X)
def predict_proba(self, X: np.ndarray) -> np.ndarray:
class LightGBMAgentClassifier(BaseAgentClassifier): if self.model is None:
"""LightGBM binary classifier for agent detection with class imbalance handling""" raise ValueError("fit the model first")
return self.model.predict_proba(X)
def _build_model(self, scale_pos: float):
return lgb.LGBMClassifier(
n_estimators=self.n_estimators,
max_depth=self.max_depth,
learning_rate=self.learning_rate,
scale_pos_weight=scale_pos,
metric='auc',
random_state=42,
verbosity=-1
)
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
self.model_.fit(
X_arr, y_arr,
eval_set=eval_arr,
callbacks=[lgb.early_stopping(self.early_stopping_rounds, verbose=False)]
)

View File

@@ -0,0 +1,246 @@
import sys
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
from sim.rl.behavior_loader.loader import AgentLoader, Loader, JointLoader, PayloadModel
from sim.rl.behavior_loader.models import JointBehaviorModel
from arch import ContrastiveWeakClassifier, contrastive_loss, featurize_trajectory
from typing import List, Optional, Dict
from datetime import datetime, timedelta
from copy import deepcopy
import numpy as np
import random
import torch
from torch.utils.data import Dataset, DataLoader
from torch.optim import Adam
from torch.utils.tensorboard import SummaryWriter
RUNS_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
def _perturb_ts(evt: PayloadModel, jitter_ms: int = 500) -> PayloadModel:
"""Add random jitter to event timestamp"""
new_evt = deepcopy(evt)
try:
ts = datetime.fromisoformat(evt.ts.replace('Z', '+00:00'))
delta = timedelta(milliseconds=random.randint(-jitter_ms, jitter_ms))
new_evt.ts = (ts + delta).isoformat()
except:
pass
return new_evt
def augment_trajectory(trajectory: List[PayloadModel], rate: float = 0.1) -> List[PayloadModel]:
"""Apply random augmentation to trajectory for contrastive learning"""
if len(trajectory) < 2:
return trajectory
aug_type = random.choice(['window', 'shuffle', 'noise', 'drop'])
if aug_type == 'window': # random contiguous sub-sequence (70-100% length)
min_len = max(2, int(len(trajectory) * 0.7))
sub_len = random.randint(min_len, len(trajectory))
start = random.randint(0, len(trajectory) - sub_len)
return trajectory[start:start + sub_len]
elif aug_type == 'shuffle': # swap adjacent pairs with probability rate
result = list(trajectory)
for i in range(len(result) - 1):
if random.random() < rate:
result[i], result[i + 1] = result[i + 1], result[i]
return result
elif aug_type == 'drop': # drop events with probability rate
result = [e for e in trajectory if random.random() > rate]
return result if len(result) >= 2 else trajectory[:2]
elif aug_type == 'noise': # perturb timestamps
return [_perturb_ts(e, jitter_ms=500) for e in trajectory]
return trajectory
class TripletDataset(Dataset):
"""Generate (anchor, positive, negative) triplets on-the-fly with augmentation"""
def __init__(self, data: Dict[str, List[PayloadModel]], mdp: Optional[Dict], augment_fn, input_dim: int = 64, multiplier: int = 10):
self.sessions = list(data.items())
self.human_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('human_')]
self.agent_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('agent_')]
self.mdp = mdp
self.augment = augment_fn
self.input_dim = input_dim
self.multiplier = multiplier
if not self.human_ids or not self.agent_ids:
raise ValueError(f"Need both human ({len(self.human_ids)}) and agent ({len(self.agent_ids)}) sessions")
def __len__(self) -> int:
return len(self.sessions) * self.multiplier
def __getitem__(self, idx: int):
anchor_idx = idx % len(self.sessions)
sid, events = self.sessions[anchor_idx]
is_human = sid.startswith('human_')
anchor = featurize_trajectory(events, self.mdp, self.input_dim)
positive = featurize_trajectory(self.augment(events), self.mdp, self.input_dim)
neg_pool = self.agent_ids if is_human else self.human_ids
neg_idx = random.choice(neg_pool)
negative = featurize_trajectory(self.sessions[neg_idx][1], self.mdp, self.input_dim)
label = 0 if is_human else 1 # 0=human, 1=agent
return (torch.tensor(anchor, dtype=torch.float32),
torch.tensor(positive, dtype=torch.float32),
torch.tensor(negative, dtype=torch.float32),
torch.tensor(label, dtype=torch.long))
def train(epochs: int = 100, lr: float = 1e-3, batch_size: int = 4, input_dim: int = 64,
embed_dim: int = 32, margin: float = 0.3, verbose: bool = True, run_name: str = None):
"""Train contrastive weak classifier on human/agent trajectories"""
joint = JointLoader(human_dir, agent_dir)
data = joint.get_data()
if verbose:
print(f"Loaded {len(data)} sessions")
joint_model = JointBehaviorModel(human_dir, agent_dir)
ref_mdp = joint_model.build_MDP()
dataset = TripletDataset(data, ref_mdp, augment_trajectory, input_dim=input_dim)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
model.to_device()
run_name = run_name or f"d{input_dim}_e{embed_dim}_lr{lr}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
writer = SummaryWriter(f"{RUNS_DIR}/train/{run_name}")
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
ce_loss_fn = torch.nn.CrossEntropyLoss()
best_loss = float('inf')
for epoch in range(epochs):
model.encoder.train()
model.classifier.train()
total_loss, n_batches = 0.0, 0
for anchor, positive, negative, labels in loader:
anchor, positive, negative, labels = [t.to(model.device) for t in [anchor, positive, negative, labels]]
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1)) for t in [anchor, positive, negative]]
trip_loss = contrastive_loss(z_a, z_p, z_n, margin=model.margin)
ce = ce_loss_fn(model.classifier(z_a), labels)
loss = trip_loss + 0.5 * ce
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
n_batches += 1
avg_loss = total_loss / max(n_batches, 1)
writer.add_scalar('loss', avg_loss, epoch)
if verbose and (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}/{epochs}: loss={avg_loss:.4f}")
if avg_loss < best_loss:
best_loss = avg_loss
writer.close()
if verbose:
print(f"Done. Best={best_loss:.4f} TB:{RUNS_DIR}/train/{run_name}")
return model, ref_mdp
def evaluate_loocv(input_dim: int = 64, embed_dim: int = 32, epochs_per_fold: int = 50,
lr: float = 1e-3, margin: float = 0.3, run_name: str = None):
"""Leave-one-out cross-validation given limited samples"""
joint = JointLoader(human_dir, agent_dir)
data = joint.get_data()
session_ids = list(data.keys())
joint_model = JointBehaviorModel(human_dir, agent_dir)
ref_mdp = joint_model.build_MDP()
run_name = run_name or f"loocv_d{input_dim}_e{embed_dim}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
writer = SummaryWriter(f"{RUNS_DIR}/eval/{run_name}")
predictions, actuals = [], []
for fold_idx, test_sid in enumerate(session_ids):
train_data = {k: v for k, v in data.items() if k != test_sid}
test_events = data[test_sid]
test_label = 0 if test_sid.startswith('human_') else 1
n_human = sum(1 for k in train_data if k.startswith('human_'))
n_agent = sum(1 for k in train_data if k.startswith('agent_'))
if n_human == 0 or n_agent == 0:
continue
try:
dataset = TripletDataset(train_data, ref_mdp, augment_trajectory, input_dim=input_dim, multiplier=5)
loader = DataLoader(dataset, batch_size=2, shuffle=True, drop_last=True)
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
model.to_device()
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
model.encoder.train()
model.classifier.train()
for _ in range(epochs_per_fold):
for anchor, positive, negative, labels in loader:
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1).to(model.device)) for t in [anchor, positive, negative]]
loss = contrastive_loss(z_a, z_p, z_n, margin=margin)
optimizer.zero_grad()
loss.backward()
optimizer.step()
test_feat = featurize_trajectory(test_events, ref_mdp, input_dim)
pred = model.predict(test_feat.reshape(1, -1))[0]
predictions.append(pred)
actuals.append(test_label)
print(f" {test_sid[:12]}...: pred={pred}, actual={test_label}, {'OK' if pred == test_label else 'MISS'}")
except Exception as e:
print(f"Error: {e}")
if predictions:
acc = sum(p == a for p, a in zip(predictions, actuals)) / len(predictions)
tp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 1)
fp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 0)
fn = sum(1 for p, a in zip(predictions, actuals) if p == 0 and a == 1)
prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
f1 = 2 * prec * rec / max(prec + rec, 1e-10)
writer.add_scalar('accuracy', acc, 0)
writer.add_scalar('f1', f1, 0)
writer.add_scalar('precision', prec, 0)
writer.add_scalar('recall', rec, 0)
writer.close()
print(f"\nAccuracy: {acc:.2%} F1: {f1:.3f} TB:{RUNS_DIR}/eval/{run_name}")
return acc, predictions, actuals
writer.close()
return 0.0, [], []
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--mode', choices=['train', 'eval'], default='train')
parser.add_argument('--epochs', type=int, default=100)
parser.add_argument('--lr', type=float, default=1e-3)
parser.add_argument('--margin', type=float, default=0.3)
parser.add_argument('--input-dim', type=int, default=64)
parser.add_argument('--embed-dim', type=int, default=32)
parser.add_argument('--run-name', type=str, default=None)
args = parser.parse_args()
if args.mode == 'train':
model, mdp = train(epochs=args.epochs, lr=args.lr, input_dim=args.input_dim,
embed_dim=args.embed_dim, margin=args.margin, run_name=args.run_name)
else:
evaluate_loocv(input_dim=args.input_dim, embed_dim=args.embed_dim, epochs_per_fold=args.epochs,
lr=args.lr, margin=args.margin, run_name=args.run_name)

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
import os
import random
from pathlib import Path
from types import SimpleNamespace
import pandas as pd
from lib.separability import estimate_alpha, load_artifacts, score_session
# use relative import when in package context, fallback for standalone
try:
from sim.rl.behavior_loader.models import AgentBehaviorModel
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "sim" / "rl" / "behavior_loader"))
from models import AgentBehaviorModel
# paths should be configurable via environment or relative to project root
PROJECT_ROOT = Path(__file__).parent.parent.parent
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', PROJECT_ROOT / "experiments" / "agents" / "collected_data"))
try:
SEPARABILITY_ARTIFACTS = load_artifacts()
except FileNotFoundError:
SEPARABILITY_ARTIFACTS = None
def remap_schema(df: pd.DataFrame, mapping: dict, on: str = "event_type") -> pd.DataFrame:
"""remap column values according to mapping dict, preserving unmapped values"""
df = df.copy()
df[on] = df[on].map(mapping).fillna(df[on])
return df
def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
events: list[SimpleNamespace] = []
for idx, state in enumerate(states):
parts = state.split("|") if isinstance(state, str) else ["page", "product", str(state)]
page = f"/{parts[0]}" if parts else "/"
product = parts[1] if len(parts) > 1 else "unknown"
event_name = parts[2] if len(parts) > 2 else parts[-1]
events.append(
SimpleNamespace(
eventName=event_name,
page=page,
productId=product,
ts=float(idx),
)
)
return events
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
contamination_rate: float = 0.1,
agent_data_dir: Path = None) -> pd.DataFrame:
"""inject synthetic agent trajectories into a dataset
contamination_rate: fraction of final dataset that should be agent data (0.1 = 10% agents)
"""
data_dir = agent_data_dir or AGENT_DATA_DIR
model = AgentBehaviorModel(str(data_dir))
model.build_MDP() # ensure MDP is built before sampling
# compute event distribution from original data
event_dist = df[on].value_counts(normalize=True).to_dict()
total = sum(event_dist.values())
event_dist = {k: v / total for k, v in event_dist.items()}
# calculate how many synthetic events to add
N = len(df)
N_final = N / (1 - contamination_rate)
N_contaminate = int(N_final - N)
# sample start states weighted by original distribution
start_events = random.choices(list(event_dist.keys()), weights=list(event_dist.values()), k=N_contaminate)
# generate synthetic trajectories
new_rows = []
alpha_estimates = []
for start_event in start_events:
# sample trajectory from agent model, using a state that contains the event type
mdp_states = model.mdp.get('states', []) if model.mdp else []
matching_starts = [s for s in mdp_states if start_event in s]
if not matching_starts:
continue # skip if no matching start state
start_state = random.choice(matching_starts)
trajectory = model.sample_traj(start_state, max_len=20)
score_payload: list[SimpleNamespace] = []
score: dict[str, float] = {}
if SEPARABILITY_ARTIFACTS:
score_payload = _states_to_events(trajectory)
score = score_session(score_payload, SEPARABILITY_ARTIFACTS)
alpha_estimates.append(
estimate_alpha(score["prob_agent"], score["delta_h"], score["delta_a"], temperature=2.0)
)
for state in trajectory:
parts = state.split('|') if isinstance(state, str) else [start_event]
new_rows.append({
on: parts[-1] if parts else start_event,
'source': 'synthetic_agent',
'prob_agent': score.get('prob_agent') if SEPARABILITY_ARTIFACTS and score_payload else None,
'delta_h': score.get('delta_h') if SEPARABILITY_ARTIFACTS and score_payload else None,
'delta_a': score.get('delta_a') if SEPARABILITY_ARTIFACTS and score_payload else None,
})
if new_rows:
contaminate_df = pd.DataFrame(new_rows)
df = pd.concat([df, contaminate_df], ignore_index=True)
if alpha_estimates:
df['estimated_alpha'] = sum(alpha_estimates) / len(alpha_estimates)
return df

View File

@@ -7,15 +7,6 @@ import pandas as pd
class PricingFunction(ABC): class PricingFunction(ABC):
""" """
Abstract base for pricing functions. Abstract base for pricing functions.
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
Where:
Q_t ∈ R^n: demand vector at time t
P_t ∈ R^n: price vector at time t
S_t: session features (behavioral signals, interactions)
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
Objective: Objective:
maximize E[R_T] = E[Σ P_t^T · Q_t] maximize E[R_T] = E[Σ P_t^T · Q_t]
subject to: subject to:
@@ -28,10 +19,10 @@ class PricingFunction(ABC):
def fit(self, *kwargs): def fit(self, *kwargs):
""" """
Offline training on historical data. Offline training on historical data.
This is where we can think about some maximization of expected revenue
over historical trajectories to learn parameters of the pricing function.
(This however we cover move in the RL side of things)
Args:
historical_data: DataFrame with elasticity, prices, demand signals
**kwargs: additional training parameters
""" """
pass pass
@@ -39,12 +30,18 @@ class PricingFunction(ABC):
def predict(self, *kwargs) -> np.ndarray: def predict(self, *kwargs) -> np.ndarray:
""" """
Generate optimal prices given current state. Generate optimal prices given current state.
This is an abstract method that transitions from τ -> P*
which is the mapping from the trajectory to optimal prices under
some subset of session grouping (so, per sessionId)
"""
pass
Args: @abstractmethod
state_space: StateSpace object containing Q_t, P_t, S_t, H_t def _get_features(self, *kwargs) -> np.ndarray:
"""
Extract features from trajectory for pricing decision.
Returns: Returns:
P_{t+1}: price vector in R^n np.ndarray of shape (n_products, n_features)
""" """
pass pass

View File

@@ -57,3 +57,13 @@ class ElasticityBasedPricer(PricingFunction):
# enforce bounds # enforce bounds
prices = np.clip(prices, self.price_floor, self.price_ceil) prices = np.clip(prices, self.price_floor, self.price_ceil)
return prices return prices
def _get_features(self, state_space=None) -> np.ndarray:
"""Extract elasticity, demand, and demand deviation for each product"""
if state_space is None or self.elasticity is None:
n = len(self.elasticity) if self.elasticity is not None else 0
return np.zeros((n, 3))
demand = np.asarray(state_space.demand)
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
return np.column_stack([self.elasticity, demand, demand_dev])

View File

@@ -107,6 +107,36 @@ class SessionAwarePricer(PricingFunction):
return prices return prices
def _get_features(self, state_space=None) -> np.ndarray:
"""Extract elasticity, demand, and session features"""
if state_space is None or self.elasticity is None:
n = len(self.elasticity) if self.elasticity is not None else 0
return np.zeros((n, 5))
demand = np.asarray(state_space.demand)
n_products = len(demand)
# extract session features
velocity = 0.0
view_depth = 0.0
cart_to_view = 0.0
if not state_space.session_features.empty:
sf = state_space.session_features.iloc[0]
velocity = sf.get('interaction_velocity', 0.0)
view_depth = sf.get('product_view_depth', 0.0)
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
# broadcast session features to all products
features = np.column_stack([
self.elasticity,
demand,
np.full(n_products, velocity),
np.full(n_products, view_depth),
np.full(n_products, cart_to_view)
])
return features
class ProductSpecificSessionPricer(PricingFunction): class ProductSpecificSessionPricer(PricingFunction):
""" """
@@ -170,3 +200,12 @@ class ProductSpecificSessionPricer(PricingFunction):
prices = np.clip(base_prices, self.price_floor, self.price_ceil) prices = np.clip(base_prices, self.price_floor, self.price_ceil)
return prices return prices
def _get_features(self, state_space=None) -> np.ndarray:
"""Extract elasticity and demand features for product-specific pricing"""
if state_space is None or self.elasticity is None:
n = len(self.elasticity) if self.elasticity is not None else 0
return np.zeros((n, 2))
demand = np.asarray(state_space.demand)
return np.column_stack([self.elasticity, demand])

View File

@@ -3,6 +3,46 @@ import pandas as pd
from procesing.pricers.base import PricingFunction from procesing.pricers.base import PricingFunction
def session_features_to_demand(session_features: pd.DataFrame) -> float:
"""
Map session behavioral features to demand proxy.
THIS is the critical θ̂ → D transformation for rule-based pricing.
Logic:
- High velocity → agent behavior → price up (revenue recovery)
- High cart ratio → purchase intent → price up
- Low activity → discount to convert
Returns: demand proxy score (0-20 range, higher = more demand)
"""
if session_features.empty:
return 1.0
feat = session_features.iloc[0] if len(session_features) > 0 else {}
velocity = feat.get('interaction_velocity', 0)
cart_ratio = feat.get('cart_to_view_ratio', 0)
item_views = feat.get('item_views', 0)
cart_adds = feat.get('cart_adds', 0)
# baseline demand
demand = 1.0
# agent detection: high velocity → treat as high "demand" to price up
if velocity > 2.0:
demand += 10.0 # strong agent signal
# conversion intent: cart interaction → price up
if cart_ratio > 0.1 or cart_adds > 0:
demand += 5.0
# browsing depth: many views → interest signal
if item_views > 3:
demand += min(item_views, 5.0)
return min(demand, 20.0) # cap at 20
class StaticPricer(PricingFunction): class StaticPricer(PricingFunction):
"""Static pricing: always return fixed base prices""" """Static pricing: always return fixed base prices"""
@@ -25,6 +65,11 @@ class StaticPricer(PricingFunction):
raise ValueError("Must call fit() or provide base_prices in constructor") raise ValueError("Must call fit() or provide base_prices in constructor")
return self.base_prices.copy() return self.base_prices.copy()
def _get_features(self, state_space=None) -> np.ndarray:
"""Static pricer uses no features, returns empty array"""
n = len(self.base_prices) if self.base_prices is not None else 0
return np.zeros((n, 0))
class RandomPricer(PricingFunction): class RandomPricer(PricingFunction):
"""Random pricing within bounds (for baseline comparison)""" """Random pricing within bounds (for baseline comparison)"""
@@ -47,6 +92,11 @@ class RandomPricer(PricingFunction):
self.n_products = len(state_space.demand) self.n_products = len(state_space.demand)
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products) return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
def _get_features(self, state_space=None) -> np.ndarray:
"""Random pricer uses no features"""
n = self.n_products if self.n_products else 0
return np.zeros((n, 0))
class SimpleSurgePricer(PricingFunction): class SimpleSurgePricer(PricingFunction):
""" """
@@ -67,21 +117,25 @@ class SimpleSurgePricer(PricingFunction):
self.surge_multiplier = surge_multiplier self.surge_multiplier = surge_multiplier
self.discount_multiplier = discount_multiplier self.discount_multiplier = discount_multiplier
def fit(self, market_data : pd.DataFrame): def fit(self, market_data: pd.DataFrame):
"""Extract base prices from product catalog or historical averages""" """Extract base prices from product catalog or historical averages"""
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices) return self
def predict(self) -> np.ndarray: def predict(self, state_space) -> np.ndarray:
""" """
Adjust prices based on current demand using surge rules. Adjust prices based on current demand using surge rules.
state_space.demand: demand counts per product state_space.demand: demand proxy per product (from session features)
state_space.prices: current prices (fallback if base_prices not set) state_space.prices: base prices
""" """
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99 demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices) base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
new_prices = current_prices.copy()
if base is None:
base = np.ones(len(demand)) * 99.99
# ensure float dtype to allow multiplication by float multipliers
new_prices = base.astype(np.float64).copy()
high_mask = demand >= self.high_threshold high_mask = demand >= self.high_threshold
new_prices[high_mask] *= self.surge_multiplier new_prices[high_mask] *= self.surge_multiplier
@@ -89,3 +143,16 @@ class SimpleSurgePricer(PricingFunction):
new_prices[low_mask] *= self.discount_multiplier new_prices[low_mask] *= self.discount_multiplier
return new_prices return new_prices
def _get_features(self, state_space=None) -> np.ndarray:
"""Extract demand and base price features for each product"""
if state_space is None:
n = len(self.base_prices) if self.base_prices is not None else 0
return np.zeros((n, 2))
demand = np.asarray(state_space.demand) if hasattr(state_space, 'demand') else np.array([0])
base = np.asarray(state_space.prices) if hasattr(state_space, 'prices') else self.base_prices
if base is None:
base = np.ones(len(demand)) * 99.99
return np.column_stack([demand, base])

View File

@@ -135,6 +135,7 @@ class ExtractSessionFeaturesStep(BaseContextStep):
Vectorized session feature extraction - replaces O(n^2) per-row loop. Vectorized session feature extraction - replaces O(n^2) per-row loop.
Input: interactions_df Input: interactions_df
Output: session-level feature matrix Output: session-level feature matrix
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
""" """
def transform(self, X: pd.DataFrame) -> pd.DataFrame: def transform(self, X: pd.DataFrame) -> pd.DataFrame:

View File

@@ -6,6 +6,7 @@ from procesing.steps import (
) )
def test_compute_demand(pipeline_context): def test_compute_demand(pipeline_context):
random.seed(42) # deterministic test
step = ComputeDemandStep(context=pipeline_context) step = ComputeDemandStep(context=pipeline_context)
# Test with normal interaction data # Test with normal interaction data
@@ -26,6 +27,7 @@ def test_compute_demand(pipeline_context):
def test_compute_demand_skewed(pipeline_context): def test_compute_demand_skewed(pipeline_context):
random.seed(42) # deterministic test
step = ComputeDemandStep(context=pipeline_context) step = ComputeDemandStep(context=pipeline_context)
# Test with normal interaction data # Test with normal interaction data

41
lib/__init__.py Normal file
View File

@@ -0,0 +1,41 @@
"""PHANTOM shared library
Exports unified utilities for features, state, config, kafka, and model registry
"""
from .config import (
PROJECT_ROOT, DATA_DIR, EXPERIMENTS_DIR,
AGENT_DATA_DIR, HUMAN_DATA_DIR, SIM_RUNS_DIR, MODEL_REGISTRY_DIR,
COLLECTED_DATA_DIR, NOTEBOOK_OUTPUT_DIR,
ensure_dir, get_data_path, get_experiments_path, get_sim_path,
KAFKA_HOST, KAFKA_PORT, KAFKA_BROKER,
REDIS_HOST, REDIS_PORT,
SUPABASE_URL, SUPABASE_ANON_KEY,
BACKEND_PORT, PROVIDER_PORT
)
from .state import (
make_state_repr, event_to_state, parse_state,
get_event_name, get_timestamp,
create_state_fn, create_event_name_fn, create_timestamp_fn
)
from .features import (
transition_histogram, temporal_signature, state_coverage, transition_entropy,
event_type_distribution, featurize_trajectory, parse_timestamp
)
__all__ = [
# config
'PROJECT_ROOT', 'DATA_DIR', 'EXPERIMENTS_DIR',
'AGENT_DATA_DIR', 'HUMAN_DATA_DIR', 'SIM_RUNS_DIR', 'MODEL_REGISTRY_DIR',
'COLLECTED_DATA_DIR', 'NOTEBOOK_OUTPUT_DIR',
'ensure_dir', 'get_data_path', 'get_experiments_path', 'get_sim_path',
'KAFKA_HOST', 'KAFKA_PORT', 'KAFKA_BROKER',
'REDIS_HOST', 'REDIS_PORT',
'SUPABASE_URL', 'SUPABASE_ANON_KEY',
'BACKEND_PORT', 'PROVIDER_PORT',
# state
'make_state_repr', 'event_to_state', 'parse_state',
'get_event_name', 'get_timestamp',
'create_state_fn', 'create_event_name_fn', 'create_timestamp_fn',
# features
'transition_histogram', 'temporal_signature', 'state_coverage', 'transition_entropy',
'event_type_distribution', 'featurize_trajectory', 'parse_timestamp',
]

65
lib/config.py Normal file
View File

@@ -0,0 +1,65 @@
"""Unified path configuration for PHANTOM project
All hardcoded paths should reference this module
Paths can be overridden via environment variables
"""
import os
from pathlib import Path
# project root (directory containing lib/, experiments/, sim/, web/, backend/)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
# data directories
DATA_DIR = Path(os.getenv('PHANTOM_DATA_DIR', PROJECT_ROOT / 'data'))
EXPERIMENTS_DIR = Path(os.getenv('PHANTOM_EXPERIMENTS_DIR', PROJECT_ROOT / 'experiments'))
# agent/human interaction data
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', DATA_DIR / 'agents'))
HUMAN_DATA_DIR = Path(os.getenv('PHANTOM_HUMAN_DATA_DIR', DATA_DIR / 'humans'))
# RL simulation runs
SIM_RUNS_DIR = Path(os.getenv('PHANTOM_SIM_RUNS_DIR', PROJECT_ROOT / 'sim' / 'rl' / 'runs'))
# model artifacts
MODEL_REGISTRY_DIR = Path(os.getenv('PHANTOM_MODEL_REGISTRY_DIR', DATA_DIR / 'models'))
# collected experiment data
COLLECTED_DATA_DIR = Path(os.getenv('PHANTOM_COLLECTED_DATA_DIR', EXPERIMENTS_DIR / 'agents' / 'collected_data'))
# notebook outputs
NOTEBOOK_OUTPUT_DIR = Path(os.getenv('PHANTOM_NOTEBOOK_OUTPUT_DIR', EXPERIMENTS_DIR / 'notebooks' / 'outputs'))
def ensure_dir(path: Path) -> Path:
"""ensure directory exists, create if needed"""
path.mkdir(parents=True, exist_ok=True)
return path
def get_data_path(*parts: str) -> Path:
"""construct path relative to DATA_DIR"""
return DATA_DIR.joinpath(*parts)
def get_experiments_path(*parts: str) -> Path:
"""construct path relative to EXPERIMENTS_DIR"""
return EXPERIMENTS_DIR.joinpath(*parts)
def get_sim_path(*parts: str) -> Path:
"""construct path relative to SIM_RUNS_DIR"""
return SIM_RUNS_DIR.joinpath(*parts)
# service configuration (from .env)
KAFKA_HOST = os.getenv('KAFKA_HOST', 'localhost')
KAFKA_PORT = os.getenv('KAFKA_PORT', '9092')
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
SUPABASE_URL = os.getenv('NEXT_PUBLIC_SUPABASE_URL', '')
SUPABASE_ANON_KEY = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
BACKEND_PORT = int(os.getenv('BACKEND_PORT', '5000'))
PROVIDER_PORT = int(os.getenv('PROVIDER_PORT', '5001'))

125
lib/features.py Normal file
View File

@@ -0,0 +1,125 @@
"""Unified featurization utilities for trajectory -> feature vector conversion
Used by both experiments/ml/ and sim/rl/ components
"""
import numpy as np
from collections import defaultdict
from typing import List, Dict, Callable, Optional, Any, Set
from datetime import datetime
def transition_histogram(events: List, state_fn: Callable, max_states: int = 50) -> np.ndarray:
"""compute normalized histogram of state transitions in trajectory
events: list of event objects/dicts
state_fn: function mapping event -> state string
max_states: maximum dimensions for histogram
"""
if len(events) < 2:
return np.zeros(max_states, dtype=np.float32)
states = [state_fn(e) for e in events]
trans_counts = defaultdict(int)
for s, s_next in zip(states, states[1:]):
trans_counts[(s, s_next)] += 1
total = sum(trans_counts.values())
hist = np.array(list(trans_counts.values())[:max_states], dtype=np.float32)
hist = np.pad(hist, (0, max(0, max_states - len(hist))))
return hist / (total + 1e-10)
def temporal_signature(events: List, ts_fn: Callable) -> np.ndarray:
"""extract temporal features: mean/std/skew of inter-event times plus count
events: list of event objects/dicts
ts_fn: function mapping event -> timestamp (float seconds)
returns: [mean_dt, std_dt, skew, n_intervals] array
"""
if len(events) < 2:
return np.zeros(4, dtype=np.float32)
times = sorted([ts_fn(e) for e in events])
diffs = np.diff(times).astype(np.float32)
if len(diffs) == 0:
return np.zeros(4, dtype=np.float32)
mean_dt, std_dt = np.mean(diffs), np.std(diffs) + 1e-10
skew = np.mean(((diffs - mean_dt) / std_dt) ** 3) if std_dt > 1e-8 else 0.0
return np.array([mean_dt, std_dt, skew, len(diffs)], dtype=np.float32)
def state_coverage(events: List, state_fn: Callable, mdp_states: Set[str]) -> float:
"""fraction of MDP states visited by trajectory
events: list of event objects/dicts
state_fn: function mapping event -> state string
mdp_states: set of all possible MDP states
"""
if not mdp_states:
return 0.0
visited = set(state_fn(e) for e in events)
return len(visited & mdp_states) / len(mdp_states)
def transition_entropy(events: List, state_fn: Callable) -> float:
"""compute entropy of transition distribution (randomness of navigation)
higher entropy = more random browsing pattern
"""
if len(events) < 2:
return 0.0
states = [state_fn(e) for e in events]
trans_counts = defaultdict(int)
for s, s_next in zip(states, states[1:]):
trans_counts[(s, s_next)] += 1
total = sum(trans_counts.values())
probs = [c / total for c in trans_counts.values()]
return -sum(p * np.log(p + 1e-10) for p in probs)
def event_type_distribution(events: List, event_name_fn: Callable) -> np.ndarray:
"""compute proportions of different event type categories
returns: [page_view_ratio, hover_ratio, cart_ratio, purchase_ratio]
"""
if not events:
return np.zeros(4, dtype=np.float32)
n = len(events)
names = [event_name_fn(e).lower() for e in events]
return np.array([
sum(1 for nm in names if 'page' in nm or 'view' in nm) / n,
sum(1 for nm in names if 'hover' in nm) / n,
sum(1 for nm in names if 'cart' in nm) / n,
sum(1 for nm in names if 'purchase' in nm or 'checkout' in nm) / n
], dtype=np.float32)
def featurize_trajectory(events: List, state_fn: Callable, ts_fn: Callable,
event_name_fn: Callable, mdp_states: Optional[Set[str]] = None,
output_dim: int = 64) -> np.ndarray:
"""convert trajectory to fixed-dimension feature vector
events: list of event objects/dicts
state_fn: function mapping event -> state string
ts_fn: function mapping event -> timestamp (float)
event_name_fn: function mapping event -> event name string
mdp_states: optional set of all MDP states for coverage calculation
output_dim: desired output dimension (will pad/truncate)
"""
feats = []
feats.extend(transition_histogram(events, state_fn, max_states=40)) # 40 dims
feats.extend(temporal_signature(events, ts_fn)) # 4 dims
feats.append(state_coverage(events, state_fn, mdp_states or set())) # 1 dim
feats.append(transition_entropy(events, state_fn)) # 1 dim
feats.append(float(len(events))) # trajectory length
feats.append(float(len(set(state_fn(e) for e in events)))) # unique states
feats.extend(event_type_distribution(events, event_name_fn)) # 4 dims
feats = np.array(feats[:output_dim], dtype=np.float32)
if len(feats) < output_dim:
feats = np.pad(feats, (0, output_dim - len(feats)))
return feats
def parse_timestamp(ts: Any) -> float:
"""parse various timestamp formats to float seconds"""
if ts is None:
return 0.0
if isinstance(ts, (int, float)):
return float(ts)
if isinstance(ts, str):
try:
return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
except ValueError:
return 0.0
return 0.0

54
lib/kafka_client.py Executable file
View File

@@ -0,0 +1,54 @@
from kafka import KafkaConsumer
import json
import os
from dotenv import load_dotenv
load_dotenv()
def get_interactions(
topic='user-interactions',
bootstrap_servers=None,
from_beginning=True,
max_records=None,
timeout_ms=5000
):
"""Consume interaction events from Kafka.
Args:
topic: Kafka topic name
bootstrap_servers: Kafka broker address (default from env)
from_beginning: Start from earliest offset if True
max_records: Max number of records to fetch (None = all available)
timeout_ms: Consumer poll timeout
Returns:
List of parsed interaction event dicts
"""
if not bootstrap_servers:
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
bootstrap_servers = f'{host}:{port}'
consumer = KafkaConsumer(
topic,
bootstrap_servers=bootstrap_servers,
auto_offset_reset='earliest' if from_beginning else 'latest',
enable_auto_commit=False,
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
consumer_timeout_ms=timeout_ms
)
events = []
try:
for msg in consumer:
events.append(msg.value)
if max_records and len(events) >= max_records:
break
finally:
consumer.close()
return events
if __name__ == '__main__':
interactions = get_interactions(max_records=10)
for event in interactions:
print(event)

View File

@@ -178,3 +178,49 @@ class ModelRegistry:
return True return True
except: except:
return False return False
def set_session_prices(self, session_id: str, prices: Dict[str, float], ttl: int = 1800):
"""
Store prices for a specific session.
THIS is the write path for session-aware pricing.
Args:
session_id: session identifier
prices: dict of {productId: price}
ttl: time-to-live in seconds (default 30min)
"""
if not prices:
return
key = f"session:{session_id}:prices"
# use Redis hash for O(1) lookup per product
self.redis_client.hset(key, mapping={k: str(v) for k, v in prices.items()})
self.redis_client.expire(key, ttl)
def get_session_price(self, session_id: str, product_id: str) -> Optional[float]:
"""
Lookup price for (sessionId, productId).
THIS is the read path for fast provider lookup.
Returns: price or None if not found
"""
key = f"session:{session_id}:prices"
price_str = self.redis_client.hget(key, product_id)
if price_str is None:
return None
return float(price_str.decode('utf-8') if isinstance(price_str, bytes) else price_str)
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
"""Get all prices for a session."""
key = f"session:{session_id}:prices"
prices_raw = self.redis_client.hgetall(key)
if not prices_raw:
return {}
return {
(k.decode('utf-8') if isinstance(k, bytes) else k): float(v.decode('utf-8') if isinstance(v, bytes) else v)
for k, v in prices_raw.items()
}

72
lib/state.py Normal file
View File

@@ -0,0 +1,72 @@
"""Unified state representation utilities for MDP state encoding
Used by both experiments/ and sim/ components for consistent state handling
"""
from typing import Any, Callable
def make_state_repr(page: str = None, product_id: str = None, event_name: str = None) -> str:
"""create canonical state representation string from components
format: page|productId|eventName
"""
p = page or 'unk'
pid = product_id or 'none'
en = event_name or 'unknown'
return f"{p}|{pid}|{en}"
def event_to_state(evt: Any) -> str:
"""convert event object/dict to state string
supports both object attributes and dict keys
"""
if isinstance(evt, dict):
return make_state_repr(
page=evt.get('page'),
product_id=evt.get('productId'),
event_name=evt.get('eventName') or evt.get('event_type')
)
return make_state_repr(
page=getattr(evt, 'page', None),
product_id=getattr(evt, 'productId', None),
event_name=getattr(evt, 'eventName', None) or getattr(evt, 'event_type', None)
)
def parse_state(state_str: str) -> dict:
"""parse state string back to components
returns: {'page': str, 'productId': str, 'eventName': str}
"""
parts = state_str.split('|')
return {
'page': parts[0] if len(parts) > 0 and parts[0] != 'unk' else None,
'productId': parts[1] if len(parts) > 1 and parts[1] != 'none' else None,
'eventName': parts[2] if len(parts) > 2 and parts[2] != 'unknown' else None
}
def get_event_name(evt: Any) -> str:
"""extract event name from event object/dict"""
if isinstance(evt, dict):
return evt.get('eventName') or evt.get('event_type') or ''
return getattr(evt, 'eventName', None) or getattr(evt, 'event_type', None) or ''
def get_timestamp(evt: Any) -> Any:
"""extract timestamp from event object/dict"""
if isinstance(evt, dict):
return evt.get('ts') or evt.get('timestamp')
return getattr(evt, 'ts', None) or getattr(evt, 'timestamp', None)
def create_state_fn() -> Callable:
"""factory for state representation function"""
return event_to_state
def create_event_name_fn() -> Callable:
"""factory for event name extraction function"""
return get_event_name
def create_timestamp_fn() -> Callable:
"""factory for timestamp extraction function (returns raw value, use features.parse_timestamp to convert)"""
return get_timestamp

View File

@@ -42,6 +42,10 @@ EOF
# Process each directory # Process each directory
echo "Concatenating code from source directories..." echo "Concatenating code from source directories..."
# Engine
find "$PROJECT_ROOT/engine" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
add_file "$file"
done
# Backend # Backend
find "$PROJECT_ROOT/backend" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do find "$PROJECT_ROOT/backend" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
add_file "$file" add_file "$file"
@@ -53,7 +57,7 @@ find "$PROJECT_ROOT/experiments" -type d \( -name ".venv" -o -name "__pycache__"
done done
# Docker # Docker
find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "*.Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
add_file "$file" add_file "$file"
done done

View File

@@ -16,7 +16,6 @@
"chapters/04-results" "chapters/04-results"
"chapters/05-discussion" "chapters/05-discussion"
"chapters/06-conclusion" "chapters/06-conclusion"
"../build/concatenated_code"
"article" "article"
"art12")) "art12"))
:latex) :latex)

View File

@@ -26,7 +26,7 @@
file = {PDF:/home/velocitatem/Zotero/storage/Q7J5EBEJ/3447815.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/Q7J5EBEJ/3447815.pdf:application/pdf},
} }
@phdthesis{salassa_politecnico_nodate, @phdthesis{salassa_politecnico_2024,
title = {Politecnico di {Torino} {Algorithmic} {Pricing} in the digital age "{Ethical} considerations on its economic and social implications, and an analysis of possible solutions to overcome its critical issues" {Tutor}: {Candidate}}, title = {Politecnico di {Torino} {Algorithmic} {Pricing} in the digital age "{Ethical} considerations on its economic and social implications, and an analysis of possible solutions to overcome its critical issues" {Tutor}: {Candidate}},
abstract = {Algorithmic pricing is an emerging business practice that uses computational algorithms to determine abstract = {Algorithmic pricing is an emerging business practice that uses computational algorithms to determine
the prices of products and services based on a number of dynamic factors. The aim of this thesis is to the prices of products and services based on a number of dynamic factors. The aim of this thesis is to
@@ -50,6 +50,8 @@ laws, for fair and non-discriminatory use.},
urldate = {2025-11-12}, urldate = {2025-11-12},
school = {Politecnico di Torino}, school = {Politecnico di Torino},
author = {Salassa, Fabio and Pautassi, Paolo}, author = {Salassa, Fabio and Pautassi, Paolo},
month = apr,
year = {2024},
file = {PDF:/home/velocitatem/Zotero/storage/L95WYQ8B/m-api-06aad998-d926-0d59-5593-82fdce5a678b.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/L95WYQ8B/m-api-06aad998-d926-0d59-5593-82fdce5a678b.pdf:application/pdf},
} }
@@ -62,11 +64,12 @@ laws, for fair and non-discriminatory use.},
file = {PDF:/home/velocitatem/Zotero/storage/IZD3C5SR/m-api-26f6207c-cc89-4aed-29b6-34629f18fe9b.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/IZD3C5SR/m-api-26f6207c-cc89-4aed-29b6-34629f18fe9b.pdf:application/pdf},
} }
@article{shahidi_coasean_nodate, @article{shahidi_coasean_2025,
title = {The {Coasean} {Singularity}? {Demand}, {Supply}, and {Market} {Design} with {AI} {Agents}}, title = {The {Coasean} {Singularity}? {Demand}, {Supply}, and {Market} {Design} with {AI} {Agents}},
abstract = {AI agents—autonomous systems that perceive, reason, and act on behalf of human principals—are poised to transform digital markets by dramatically reducing transaction costs. This chapter evaluates the economic implications of this transition, adopting a consumeroriented view of agents as market participants that can search, negotiate, and transact directly. From the demand side, agent adoption reflects derived demand: users trade off decision quality against effort reduction, with outcomes mediated by agent capability and task context. On the supply side, firms will design, integrate, and monetize agents, with outcomes hinging on whether agents operate within or across platforms. At the market level, agents create efficiency gains from lower search, communication, and contracting costs, but also introduce frictions such as congestion and price obfuscation. By lowering the costs of preference elicitation, contract enforcement, and identity verification, agents expand the feasible set of market designs but also raise novel regulatory challenges. While the net welfare effects remain an empirical question, the rapid onset of AI-mediated transactions presents a unique opportunity for economic research to inform real-world policy and market design.}, abstract = {AI agents—autonomous systems that perceive, reason, and act on behalf of human principals—are poised to transform digital markets by dramatically reducing transaction costs. This chapter evaluates the economic implications of this transition, adopting a consumeroriented view of agents as market participants that can search, negotiate, and transact directly. From the demand side, agent adoption reflects derived demand: users trade off decision quality against effort reduction, with outcomes mediated by agent capability and task context. On the supply side, firms will design, integrate, and monetize agents, with outcomes hinging on whether agents operate within or across platforms. At the market level, agents create efficiency gains from lower search, communication, and contracting costs, but also introduce frictions such as congestion and price obfuscation. By lowering the costs of preference elicitation, contract enforcement, and identity verification, agents expand the feasible set of market designs but also raise novel regulatory challenges. While the net welfare effects remain an empirical question, the rapid onset of AI-mediated transactions presents a unique opportunity for economic research to inform real-world policy and market design.},
language = {en}, language = {en},
author = {Shahidi, Peyman and Rusak, Gili and Manning, Benjamin S and Fradkin, Andrey and Horton, John J}, author = {Shahidi, Peyman and Rusak, Gili and Manning, Benjamin S and Fradkin, Andrey and Horton, John J},
year = {2025},
file = {PDF:/home/velocitatem/Zotero/storage/TQCAPJDP/Shahidi et al. - The Coasean Singularity Demand, Supply, and Market Design with AI Agents.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/TQCAPJDP/Shahidi et al. - The Coasean Singularity Demand, Supply, and Market Design with AI Agents.pdf:application/pdf},
} }
@@ -84,10 +87,14 @@ laws, for fair and non-discriminatory use.},
file = {PDF:/home/velocitatem/Zotero/storage/ZLJQ4DQ9/Byrnes - 2025 - Intro to Brain-Like-AGI Safety.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/ZLJQ4DQ9/Byrnes - 2025 - Intro to Brain-Like-AGI Safety.pdf:application/pdf},
} }
@article{shannon_mathematical_nodate, @article{shannon_mathematical_1948,
title = {A {Mathematical} {Theory} of {Communication}}, title = {A {Mathematical} {Theory} of {Communication}},
volume = {27},
language = {en}, language = {en},
journal = {Bell System Technical Journal},
author = {Shannon, C E}, author = {Shannon, C E},
month = oct,
year = {1948},
file = {PDF:/home/velocitatem/Zotero/storage/FJRFRWK2/Shannon - A Mathematical Theory of Communication.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/FJRFRWK2/Shannon - A Mathematical Theory of Communication.pdf:application/pdf},
} }
@@ -96,11 +103,13 @@ laws, for fair and non-discriminatory use.},
file = {PDF:/home/velocitatem/Zotero/storage/D3QRGY9Z/order_stats.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/D3QRGY9Z/order_stats.pdf:application/pdf},
} }
@article{devine_nonlinear_nodate, @article{devine_nonlinear_2017,
title = {Nonlinear {Pricing} with {Costly} {Information} {Acquisition}}, title = {Nonlinear {Pricing} with {Costly} {Information} {Acquisition}},
abstract = {This paper examines a nonlinear pricing model where the firm can choose to acquire costly information prior to offering contract menus to consumers; such as paying a consultant or investing in machine learning technologies. Information provides the firm with a signal about consumers types, whose accuracy increases as the firm acquires larger amounts of information. We show that the firm chooses to acquire information, only if it can purchase a sufficient amount that could alter its initial prior beliefs. Relative to standard settings where firms cannot acquire information, we identify how information acquisition changes optimal contract offers, equilibrium profits, information rents, and welfare. A better-informed firm increases its expected profits, but it can also increase expected utility when the cost of information is intermediate. Our results recommend balanced online privacy laws.}, abstract = {This paper examines a nonlinear pricing model where the firm can choose to acquire costly information prior to offering contract menus to consumers; such as paying a consultant or investing in machine learning technologies. Information provides the firm with a signal about consumers types, whose accuracy increases as the firm acquires larger amounts of information. We show that the firm chooses to acquire information, only if it can purchase a sufficient amount that could alter its initial prior beliefs. Relative to standard settings where firms cannot acquire information, we identify how information acquisition changes optimal contract offers, equilibrium profits, information rents, and welfare. A better-informed firm increases its expected profits, but it can also increase expected utility when the cost of information is intermediate. Our results recommend balanced online privacy laws.},
language = {en}, language = {en},
author = {Devine, Brett R and Munoz-Garcia, Felix}, author = {Devine, Brett R and Munoz-Garcia, Felix},
month = nov,
year = {2017},
file = {PDF:/home/velocitatem/Zotero/storage/GQ28KVBF/Devine and Munoz-Garcia - Nonlinear Pricing with Costly Information Acquisition.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/GQ28KVBF/Devine and Munoz-Garcia - Nonlinear Pricing with Costly Information Acquisition.pdf:application/pdf},
} }
@@ -202,10 +211,11 @@ laws, for fair and non-discriminatory use.},
file = {PDF:/home/velocitatem/Zotero/storage/U7A5Q78V/Karten et al. - 2025 - LLM Economist Large Population Models and Mechanism Design in Multi-Agent Generative Simulacra.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/U7A5Q78V/Karten et al. - 2025 - LLM Economist Large Population Models and Mechanism Design in Multi-Agent Generative Simulacra.pdf:application/pdf},
} }
@techreport{mullapudi_reinforcement_nodate, @techreport{mullapudi_reinforcement_2025,
title = {A {Reinforcement} {Learning} {Approach} to {Dynamic} {Pricing}}, title = {A {Reinforcement} {Learning} {Approach} to {Dynamic} {Pricing}},
abstract = {Dynamic pricing represents a critical strategic challenge in modern e-commerce, where firms must navigate fluctuating demand, inventory constraints, and aggressive competitor actions. Traditional static and heuristic-based pricing models often fail to capture the complex, non-linear dynamics of competitive digital markets, leading to suboptimal profitability. This paper proposes a model-free reinforcement learning (RL) framework to address this challenge. Specifically, we design, implement, and evaluate a Q-learning agent capable of learning an optimal, state-dependent pricing policy. The agent is trained and evaluated within a simulated market environment constructed from the publicly available "Retail Price Optimization" dataset from Kaggle, which provides a rich feature set including historical sales, product characteristics, seasonality, and, crucially, competitor pricing data. The problem is formulated as a Markov Decision Process (MDP), where the agent's state incorporates its price position relative to competitors, competitor price trends, and seasonal factors. The agent's performance is benchmarked against three baseline strategies: static pricing, a reactive "follow-the-leader" heuristic, and random pricing. The results demonstrate that the Q-learning agent achieves a substantial increase in total cumulative profit over the evaluation period, outperforming all baselines by learning a nuanced policy that strategically balances price adjustments in response to market conditions. This work provides a practical and reproducible blueprint for applying reinforcement learning to optimize pricing decisions in a simulated yet realistic competitive retail environment, highlighting the potential of RL to automate complex strategic decision-making.}, abstract = {Dynamic pricing represents a critical strategic challenge in modern e-commerce, where firms must navigate fluctuating demand, inventory constraints, and aggressive competitor actions. Traditional static and heuristic-based pricing models often fail to capture the complex, non-linear dynamics of competitive digital markets, leading to suboptimal profitability. This paper proposes a model-free reinforcement learning (RL) framework to address this challenge. Specifically, we design, implement, and evaluate a Q-learning agent capable of learning an optimal, state-dependent pricing policy. The agent is trained and evaluated within a simulated market environment constructed from the publicly available "Retail Price Optimization" dataset from Kaggle, which provides a rich feature set including historical sales, product characteristics, seasonality, and, crucially, competitor pricing data. The problem is formulated as a Markov Decision Process (MDP), where the agent's state incorporates its price position relative to competitors, competitor price trends, and seasonal factors. The agent's performance is benchmarked against three baseline strategies: static pricing, a reactive "follow-the-leader" heuristic, and random pricing. The results demonstrate that the Q-learning agent achieves a substantial increase in total cumulative profit over the evaluation period, outperforming all baselines by learning a nuanced policy that strategically balances price adjustments in response to market conditions. This work provides a practical and reproducible blueprint for applying reinforcement learning to optimize pricing decisions in a simulated yet realistic competitive retail environment, highlighting the potential of RL to automate complex strategic decision-making.},
author = {Mullapudi, Pavan}, author = {Mullapudi, Pavan},
year = {2025},
note = {Publication Title: International Journal on Science and Technology (IJSAT) IJSAT25049558 note = {Publication Title: International Journal on Science and Technology (IJSAT) IJSAT25049558
Volume: 16 Volume: 16
Issue: 4}, Issue: 4},
@@ -294,10 +304,11 @@ Issue: 4},
file = {PDF:/home/velocitatem/Zotero/storage/S8635QX6/varian95a.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/S8635QX6/varian95a.pdf:application/pdf},
} }
@book{russell_artificial_nodate, @book{russell_artificial_2021,
title = {Artificial {Intelligence} {A} {Modern} {Approach} {Fourth} {Edition} {Global} {Edition}}, title = {Artificial {Intelligence} {A} {Modern} {Approach} {Fourth} {Edition} {Global} {Edition}},
isbn = {978-1-292-40117-1}, isbn = {978-1-292-40117-1},
author = {Russell, Stuart and Norvig, Peter}, author = {Russell, Stuart and Norvig, Peter},
year = {2021},
file = {PDF:/home/velocitatem/Zotero/storage/6B8W8S27/efdd4d1d4c2087fe1cbe03d9ced67f34.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/6B8W8S27/efdd4d1d4c2087fe1cbe03d9ced67f34.pdf:application/pdf},
} }
@@ -312,10 +323,11 @@ Volume: 21},
file = {PDF:/home/velocitatem/Zotero/storage/N9JNXFJW/live-1333-2265-jair.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/N9JNXFJW/live-1333-2265-jair.pdf:application/pdf},
} }
@techreport{shoham_multiagent_nodate, @techreport{shoham_multiagent_2009,
title = {Multiagent {Systems}: {Algorithmic}, {Game}-{Theoretic}, and {Logical} {Foundations}}, title = {Multiagent {Systems}: {Algorithmic}, {Game}-{Theoretic}, and {Logical} {Foundations}},
url = {http://www.masfoundations.org.}, url = {http://www.masfoundations.org.},
author = {Shoham, Yoav and Leyton-Brown, Kevin}, author = {Shoham, Yoav and Leyton-Brown, Kevin},
year = {2009},
keywords = {algorithms, auctions, communication, competition, cooperation, distributed problem solving, game theory, learning, logic, mechanism design, social choice}, keywords = {algorithms, auctions, communication, competition, cooperation, distributed problem solving, game theory, learning, logic, mechanism design, social choice},
file = {PDF:/home/velocitatem/Zotero/storage/QZVYS7V9/shoham09a.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/QZVYS7V9/shoham09a.pdf:application/pdf},
} }
@@ -331,11 +343,13 @@ Volume: 21},
file = {PDF:/home/velocitatem/Zotero/storage/H8IS64AW/2411.13768v2.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/H8IS64AW/2411.13768v2.pdf:application/pdf},
} }
@techreport{xie_osworld_nodate, @techreport{xie_osworld_2024,
title = {{OSWORLD}: {Benchmarking} {Multimodal} {Agents} for {Open}-{Ended} {Tasks} in {Real} {Computer} {Environments}}, title = {{OSWORLD}: {Benchmarking} {Multimodal} {Agents} for {Open}-{Ended} {Tasks} in {Real} {Computer} {Environments}},
url = {https://os-world.github.io}, url = {https://os-world.github.io},
abstract = {Autonomous agents that accomplish complex computer tasks with minimal human interventions have the potential to transform human-computer interaction, significantly enhancing accessibility and productivity. However, existing benchmarks either lack an interactive environment or are limited to environments specific to certain applications or domains, failing to reflect the diverse and complex nature of real-world computer use, thereby limiting the scope of tasks and agent scalability. To address this issue, we introduce OSWORLD, the first-of-its-kind scalable, real computer environment for multimodal agents, supporting task setup, execution-based evaluation, and interactive learning across various operating systems such as Ubuntu, Windows, and macOS. OSWORLD can serve as a unified, integrated computer environment for assessing open-ended computer tasks that involve arbitrary applications. Building upon OSWORLD, we create a benchmark of 369 computer tasks involving real web and desktop apps in open domains, OS file I/O, and workflows spanning multiple applications. Each task example is derived from real-world computer use cases and includes a detailed initial state setup configuration and a custom execution-based evaluation script for reliable, reproducible evaluation. Extensive evaluation of state-of-the-art LLM/VLM-based agents on OSWORLD reveals significant deficiencies in their ability to serve as computer assistants. While humans can accomplish over 72.36\% of the tasks, the best model achieves only 12.24\% success, primarily struggling with GUI grounding and operational knowledge. Comprehensive analysis using OSWORLD provides valuable insights for developing multimodal generalist agents that were not possible with previous benchmarks. Our code, environment, baseline models, and data are publicly available at https://os-world.github.io.}, abstract = {Autonomous agents that accomplish complex computer tasks with minimal human interventions have the potential to transform human-computer interaction, significantly enhancing accessibility and productivity. However, existing benchmarks either lack an interactive environment or are limited to environments specific to certain applications or domains, failing to reflect the diverse and complex nature of real-world computer use, thereby limiting the scope of tasks and agent scalability. To address this issue, we introduce OSWORLD, the first-of-its-kind scalable, real computer environment for multimodal agents, supporting task setup, execution-based evaluation, and interactive learning across various operating systems such as Ubuntu, Windows, and macOS. OSWORLD can serve as a unified, integrated computer environment for assessing open-ended computer tasks that involve arbitrary applications. Building upon OSWORLD, we create a benchmark of 369 computer tasks involving real web and desktop apps in open domains, OS file I/O, and workflows spanning multiple applications. Each task example is derived from real-world computer use cases and includes a detailed initial state setup configuration and a custom execution-based evaluation script for reliable, reproducible evaluation. Extensive evaluation of state-of-the-art LLM/VLM-based agents on OSWORLD reveals significant deficiencies in their ability to serve as computer assistants. While humans can accomplish over 72.36\% of the tasks, the best model achieves only 12.24\% success, primarily struggling with GUI grounding and operational knowledge. Comprehensive analysis using OSWORLD provides valuable insights for developing multimodal generalist agents that were not possible with previous benchmarks. Our code, environment, baseline models, and data are publicly available at https://os-world.github.io.},
author = {Xie, Tianbao and Zhang, Danyang and Chen, Jixuan and Li, Xiaochuan and Zhao, Siheng and Cao, Ruisheng and Jing Hua, Toh and Cheng, Zhoujun and Shin, Dongchan and Lei, Fangyu and Liu, Yitao and Xu, Yiheng and Zhou, Shuyan and Savarese, Silvio and Xiong, Caiming and Zhong, Victor and Yu, Tao}, author = {Xie, Tianbao and Zhang, Danyang and Chen, Jixuan and Li, Xiaochuan and Zhao, Siheng and Cao, Ruisheng and Jing Hua, Toh and Cheng, Zhoujun and Shin, Dongchan and Lei, Fangyu and Liu, Yitao and Xu, Yiheng and Zhou, Shuyan and Savarese, Silvio and Xiong, Caiming and Zhong, Victor and Yu, Tao},
month = may,
year = {2024},
note = {arXiv: 2404.07972v2}, note = {arXiv: 2404.07972v2},
file = {PDF:/home/velocitatem/Zotero/storage/LLRKXIC7/full-text.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/LLRKXIC7/full-text.pdf:application/pdf},
} }
@@ -364,17 +378,21 @@ Volume: 21},
file = {PDF:/home/velocitatem/Zotero/storage/QNXZJLRM/S2444883425000038.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/QNXZJLRM/S2444883425000038.pdf:application/pdf},
} }
@misc{ghaffary_amazon_nodate, @misc{ghaffary_amazon_2025,
title = {Amazon {Sues} to {Stop} {Perplexity} {From} {Using} {AI} {Tool} to {Buy} {Stuff}}, title = {Amazon {Sues} to {Stop} {Perplexity} {From} {Using} {AI} {Tool} to {Buy} {Stuff}},
url = {https://www.bloomberg.com/news/articles/2025-11-04/amazon-demands-perplexity-stop-ai-agent-from-making-purchases}, url = {https://www.bloomberg.com/news/articles/2025-11-04/amazon-demands-perplexity-stop-ai-agent-from-making-purchases},
author = {Ghaffary, Shirin and Day, Matt}, author = {Ghaffary, Shirin and Day, Matt},
month = nov,
year = {2025},
file = {PDF:/home/velocitatem/Zotero/storage/IQL6FPWE/Amazon Sues to Stop Perplexity From Using AI Tool to Buy Stuff - Bloomberg.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/IQL6FPWE/Amazon Sues to Stop Perplexity From Using AI Tool to Buy Stuff - Bloomberg.pdf:application/pdf},
} }
@techreport{besbes_dynamic_nodate, @techreport{besbes_dynamic_2007,
title = {Dynamic {Pricing} {Without} {Knowing} the {Demand} {Function}: {Risk} {Bounds} and {Near}-{Optimal} {Algorithms} *}, title = {Dynamic {Pricing} {Without} {Knowing} the {Demand} {Function}: {Risk} {Bounds} and {Near}-{Optimal} {Algorithms} *},
abstract = {We consider a single product revenue management problem where, given an initial inventory, the objective is to dynamically adjust prices over a finite sales horizon to maximize expected revenues. Realized demand is observed over time, but the underlying functional relationship between price and mean demand rate that governs these observations (otherwise known as the demand function or demand curve), is not known. We consider two instances of this problem: i.) a setting where the demand function is assumed to belong to a known parametric family with unknown parameter values; and ii.) a setting where the demand function is assumed to belong to a broad class of functions that need not admit any parametric representation. In each case we develop policies that learn the demand function "on the fly," and optimize prices based on that. The performance of these algorithms is measured in terms of the regret: the revenue loss relative to the maximal revenues that can be extracted when the demand function is known prior to the start of the selling season. We derive lower bounds on the regret that hold for any admissible pricing policy, and then show that our proposed algorithms achieve a regret that is "close" to this lower bound. The magnitude of the regret can be interpreted as the economic value of prior knowledge on the demand function; manifested as the revenue loss due to model uncertainty.}, abstract = {We consider a single product revenue management problem where, given an initial inventory, the objective is to dynamically adjust prices over a finite sales horizon to maximize expected revenues. Realized demand is observed over time, but the underlying functional relationship between price and mean demand rate that governs these observations (otherwise known as the demand function or demand curve), is not known. We consider two instances of this problem: i.) a setting where the demand function is assumed to belong to a known parametric family with unknown parameter values; and ii.) a setting where the demand function is assumed to belong to a broad class of functions that need not admit any parametric representation. In each case we develop policies that learn the demand function "on the fly," and optimize prices based on that. The performance of these algorithms is measured in terms of the regret: the revenue loss relative to the maximal revenues that can be extracted when the demand function is known prior to the start of the selling season. We derive lower bounds on the regret that hold for any admissible pricing policy, and then show that our proposed algorithms achieve a regret that is "close" to this lower bound. The magnitude of the regret can be interpreted as the economic value of prior knowledge on the demand function; manifested as the revenue loss due to model uncertainty.},
author = {Besbes, Omar and Zeevi, Assaf}, author = {Besbes, Omar and Zeevi, Assaf},
month = dec,
year = {2007},
note = {Publication Title: Operations Research}, note = {Publication Title: Operations Research},
keywords = {learning, asymptotic analysis, estimation, exploration-exploitation, pricing, Revenue management, value of information}, keywords = {learning, asymptotic analysis, estimation, exploration-exploitation, pricing, Revenue management, value of information},
file = {PDF:/home/velocitatem/Zotero/storage/SBAIB4V2/Dp_wo_demand_risk_ob_az_posted.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/SBAIB4V2/Dp_wo_demand_risk_ob_az_posted.pdf:application/pdf},
@@ -423,3 +441,178 @@ Volume: 21},
keywords = {Computer Science - Computation and Language}, keywords = {Computer Science - Computation and Language},
file = {PDF:/home/velocitatem/Zotero/storage/3Z2XK4QC/Ganie - 2025 - Uncertainty in Authorship Why Perfect AI Detection Is Mathematically Impossible.pdf:application/pdf}, file = {PDF:/home/velocitatem/Zotero/storage/3Z2XK4QC/Ganie - 2025 - Uncertainty in Authorship Why Perfect AI Detection Is Mathematically Impossible.pdf:application/pdf},
} }
@article{shi_distributionally_2024,
title = {Distributionally {Robust} {Model}-{Based} {Offline} {Reinforcement} {Learning} with {Near}-{Optimal} {Sample} {Complexity}},
abstract = {This paper concerns the central issues of model robustness and sample efficiency in offline reinforcement learning (RL), which aims to learn to perform decision making from history data without active exploration. Due to uncertainties and variabilities of the environment, it is critical to learn a robust policy—with as few samples as possible—that performs well even when the deployed environment deviates from the nominal one used to collect the history dataset. We consider a distributionally robust formulation of offline RL, focusing on tabular robust Markov decision processes with an uncertainty set specified by the Kullback-Leibler divergence in both finite-horizon and infinite-horizon settings. To combat with sample scarcity, a model-based algorithm that combines distributionally robust value iteration with the principle of pessimism in the face of uncertainty is proposed, by penalizing the robust value estimates with a carefully designed data-driven penalty term. Under a mild and tailored assumption of the history dataset that measures distribution shift without requiring full coverage of the state-action space, we establish the finite-sample complexity of the proposed algorithms. We further develop an informationtheoretic lower bound, which suggests that learning RMDPs is at least as hard as the standard MDPs when the uncertainty level is sufficient small, and corroborates the tightness of our upper bound up to polynomial factors of the (effective) horizon length for a range of uncertainty levels. To the best our knowledge, this provides the first provably near-optimal robust offline RL algorithm that learns under model uncertainty and partial coverage.},
language = {en},
author = {Shi, Laixi and Chi, Yuejie},
month = jun,
year = {2024},
file = {PDF:/home/velocitatem/Zotero/storage/K56G4EIP/Shi and Chi - Distributionally Robust Model-Based Offline Reinforcement Learning with Near-Optimal Sample Complexity.pdf:application/pdf},
}
@article{dutting_mechanism_2025,
title = {Mechanism {Design} for {Large} {Language} {Models} ({Extended} {Abstract})},
abstract = {We investigate auction mechanisms for AIgenerated content, focusing on applications like ad creative generation. In our model, agents preferences over stochastically generated content are encoded as large language models (LLMs). We propose an auction format that operates on a tokenby-token basis, and allows LLM agents to influence content creation through single dimensional bids. We formulate two desirable incentive properties and prove their equivalence to a monotonicity condition on output aggregation. This equivalence enables a second-price rule design, even absent explicit agent valuation functions. Our design is supported by demonstrations on a publicly available LLM.},
language = {en},
author = {Dütting, Paul and Mirrokni, Vahab and Leme, Renato Paes and Xu, Haifeng and Zuo, Song},
year = {2025},
file = {PDF:/home/velocitatem/Zotero/storage/2ABDEYDN/Dütting et al. - Mechanism Design for Large Language Models (Extended Abstract).pdf:application/pdf},
}
@misc{fcmi_machine_2025,
title = {Machine {Speed} {Markets}: {AI} {Agent} {Market} {Strategy} \& {Growth}},
shorttitle = {Machine {Speed} {Markets}},
url = {https://www.360strategy.co.uk/post/machine-speed-markets-ai-agents},
abstract = {Recent research by NBER economists suggests these AI agents in particular, could drive a "Coasean singularity," a point where transaction costs fall towards zero, radically reshaping how markets function. In essence, tasks like finding information, negotiating deals, and enforcing contracts which are traditionally costly frictions in commerce, may become nearly instantaneous and costless.},
language = {en},
urldate = {2026-01-20},
journal = {360 Strategy},
author = {FCMi, CMgr, Mark Evans MBA},
month = nov,
year = {2025},
file = {Snapshot:/home/velocitatem/Zotero/storage/Z22P9JJH/machine-speed-markets-ai-agents.html:text/html},
}
@article{coase_nature_1937,
title = {The {Nature} of the {Firm}},
volume = {4},
issn = {1468-0335},
url = {https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1468-0335.1937.tb00002.x},
doi = {10.1111/j.1468-0335.1937.tb00002.x},
language = {en},
number = {16},
urldate = {2026-01-20},
journal = {Economica},
author = {Coase, R. H.},
year = {1937},
pages = {386--405},
file = {Full Text PDF:/home/velocitatem/Zotero/storage/TABLLPEU/Coase - 1937 - The Nature of the Firm.pdf:application/pdf;Snapshot:/home/velocitatem/Zotero/storage/Q5RFW9LJ/j.1468-0335.1937.tb00002.html:text/html},
}
@misc{fish_algorithmic_2025,
title = {Algorithmic {Collusion} by {Large} {Language} {Models}},
url = {http://arxiv.org/abs/2404.00806},
doi = {10.48550/arXiv.2404.00806},
abstract = {The rise of algorithmic pricing raises concerns of algorithmic collusion. We conduct experiments with algorithmic pricing agents based on Large Language Models (LLMs). We find that LLM-based pricing agents quickly and autonomously reach supracompetitive prices and profits in oligopoly settings and that variation in seemingly innocuous phrases in LLM instructions (“prompts”) may substantially influence the degree of supracompetitive pricing. Off-path analysis using novel techniques uncovers price-war concerns as contributing to these phenomena. Our results extend to auction settings. Our findings uncover unique challenges to any future regulation of LLM-based pricing agents, and AI-based pricing agents more broadly.},
language = {en},
urldate = {2026-01-20},
publisher = {arXiv},
author = {Fish, Sara and Gonczarowski, Yannai A. and Shorrer, Ran I.},
month = sep,
year = {2025},
note = {arXiv:2404.00806 [econ]},
keywords = {Computer Science - Computer Science and Game Theory, Computer Science - Artificial Intelligence, Economics - General Economics},
file = {PDF:/home/velocitatem/Zotero/storage/QHWVISCZ/Fish et al. - 2025 - Algorithmic Collusion by Large Language Models.pdf:application/pdf},
}
@misc{hardt_strategic_2015,
title = {Strategic {Classification}},
url = {http://arxiv.org/abs/1506.06980},
doi = {10.48550/arXiv.1506.06980},
abstract = {Machine learning relies on the assumption that unseen test instances of a classification problem follow the same distribution as observed training data. However, this principle can break down when machine learning is used to make important decisions about the welfare (employment, education, health) of strategic individuals. Knowing information about the classifier, such individuals may manipulate their attributes in order to obtain a better classification outcome. As a result of this behavior—often referred to as gaming—the performance of the classifier may deteriorate sharply. Indeed, gaming is a well-known obstacle for using machine learning methods in practice; in financial policy-making, the problem is widely known as Goodharts law. In this paper, we formalize the problem, and pursue algorithms for learning classifiers that are robust to gaming.},
language = {en},
urldate = {2026-01-20},
publisher = {arXiv},
author = {Hardt, Moritz and Megiddo, Nimrod and Papadimitriou, Christos and Wootters, Mary},
month = nov,
year = {2015},
note = {arXiv:1506.06980 [cs]},
keywords = {Computer Science - Machine Learning},
file = {PDF:/home/velocitatem/Zotero/storage/HNCDYGWS/Hardt et al. - 2015 - Strategic Classification.pdf:application/pdf},
}
@misc{liu_contextual_2024,
title = {Contextual {Dynamic} {Pricing} with {Strategic} {Buyers}},
url = {http://arxiv.org/abs/2307.04055},
doi = {10.48550/arXiv.2307.04055},
abstract = {Personalized pricing, which involves tailoring prices based on individual characteristics, is commonly used by firms to implement a consumer-specific pricing policy. In this process, buyers can also strategically manipulate their feature data to obtain a lower price, incurring certain manipulation costs. Such strategic behavior can hinder firms from maximizing their profits. In this paper, we study the contextual dynamic pricing problem with strategic buyers. The seller does not observe the buyer's true feature, but a manipulated feature according to buyers' strategic behavior. In addition, the seller does not observe the buyers' valuation of the product, but only a binary response indicating whether a sale happens or not. Recognizing these challenges, we propose a strategic dynamic pricing policy that incorporates the buyers' strategic behavior into the online learning to maximize the seller's cumulative revenue. We first prove that existing non-strategic pricing policies that neglect the buyers' strategic behavior result in a linear \$Ω(T)\$ regret with \$T\$ the total time horizon, indicating that these policies are not better than a random pricing policy. We then establish that our proposed policy achieves a sublinear regret upper bound of \$O({\textbackslash}sqrt\{T\})\$. Importantly, our policy is not a mere amalgamation of existing dynamic pricing policies and strategic behavior handling algorithms. Our policy can also accommodate the scenario when the marginal cost of manipulation is unknown in advance. To account for it, we simultaneously estimate the valuation parameter and the cost parameter in the online pricing policy, which is shown to also achieve an \$O({\textbackslash}sqrt\{T\})\$ regret bound. Extensive experiments support our theoretical developments and demonstrate the superior performance of our policy compared to other pricing policies that are unaware of the strategic behaviors.},
language = {en},
urldate = {2026-01-20},
publisher = {arXiv},
author = {Liu, Pangpang and Yang, Zhuoran and Wang, Zhaoran and Sun, Will Wei},
month = jun,
year = {2024},
note = {arXiv:2307.04055 [stat]},
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Computer Science - Computer Science and Game Theory, Computer Science - Artificial Intelligence},
file = {PDF:/home/velocitatem/Zotero/storage/MVJNULK3/Liu et al. - 2024 - Contextual Dynamic Pricing with Strategic Buyers.pdf:application/pdf},
}
@techreport{dhir_http_2025,
type = {Internet {Draft}},
title = {{HTTP} {Agent} {Profile} ({HAP}): {Authenticated} and {Monetized} {Agent} {Traffic} on the {Web}},
shorttitle = {{HTTP} {Agent} {Profile} ({HAP})},
url = {https://datatracker.ietf.org/doc/draft-dhir-http-agent-profile},
abstract = {Autonomous agents such as LLM-powered crawlers, browser-integrated assistants, and task-oriented bots are rapidly becoming first-class HTTP clients on the Web. Todays infrastructure largely assumes a human behind a browser and monetizes content through advertising and coarse subscriptions. Automated agents consume content at scale without rendering pages or viewing ads, exacerbating bot-mitigation arms races and economic misalignment between content providers and AI systems. This document describes an HTTP Agent Profile (HAP) that enables: (1) cryptographic authentication of agent traffic using HTTP Message Signatures; (2) clear separation between human and agent traffic using privacy-preserving human tokens; and (3) protocol-level value exchange for agents via HTTP status code 402 ("Payment Required") and pluggable micropayment mechanisms. The profile reuses existing HTTP features and is designed for incremental deployment via reverse proxies, CDNs, and agent libraries.},
number = {draft-dhir-http-agent-profile-00},
urldate = {2026-01-20},
institution = {Internet Engineering Task Force},
author = {Dhir, Sanat},
month = nov,
year = {2025},
note = {Num Pages: 13},
}
@misc{noauthor_amazoncom_2026,
title = {Amazon.com {Services} {LLC} v. {Perplexity} {AI}, {Inc}},
language = {en},
month = jan,
year = {2026},
note = {No. 3:25-cv-09514-MMC},
file = {PDF:/home/velocitatem/Zotero/storage/4JWZSTXJ/Posner - UNITED STATES DISTRICT COURT NORTHERN DISTRICT OF CALIFORNIA SAN FRANCISCO DIVISION.pdf:application/pdf},
}
@article{wright_2026_2025,
title = {2026 {Artificial} {Intelligence} {Outlook}: {The} {Great} {Competition} {Wars} {Have} {Begun}},
language = {en},
journal = {Pitchbook},
author = {Wright, Brian and Javaheri, Ali and Bellomo, Eric and Hernandez, Derek and Yang, Rudy and MacDonagh, John and DeGagne, Aaron and Frederick, Alex and Geurkink, Jonathan and Zabelin, Dimitri and Ulan, James},
month = dec,
year = {2025},
file = {PDF:/home/velocitatem/Zotero/storage/AIY5K3TX/Wright et al. - 2025 - Institutional Research Group.pdf:application/pdf},
}
@misc{rachitsky_marc_2026,
title = {Marc {Andreessen}: {The} real {AI} boom hasnt even started yet},
shorttitle = {Marc {Andreessen}},
url = {https://www.lennysnewsletter.com/p/marc-andreessen-the-real-ai-boom},
abstract = {On raising kids, why job loss fears are overblown, the future of PM/eng/design careers, and the macro force you should pay attention to},
language = {en},
urldate = {2026-02-01},
author = {Rachitsky, Lenny},
month = feb,
year = {2026},
file = {Snapshot:/home/velocitatem/Zotero/storage/DGW8PHMV/marc-andreessen-the-real-ai-boom.html:text/html},
}
@misc{noauthor_tpu_2025,
title = {{TPU} v6e},
url = {https://cloud.google.com/tpu/docs/v6e},
language = {es-419-x-mtfrom-en},
urldate = {2026-02-17},
journal = {Google Cloud Documentation},
month = dec,
year = {2025},
file = {Snapshot:/home/velocitatem/Zotero/storage/RNMB32KD/v6e.html:text/html},
}
@misc{noauthor_tpu_2025-1,
title = {{TPU} v5e {\textbar} {Google} {Cloud} {Documentation}},
url = {https://cloud.google.com/tpu/docs/v5e},
language = {es-419-x-mtfrom-en},
urldate = {2026-02-17},
month = dec,
year = {2025},
file = {Snapshot:/home/velocitatem/Zotero/storage/BLLG9NZC/v5e.html:text/html},
}
@misc{noauthor_tpu_2026,
title = {{TPU} v4 {\textbar} {Google} {Cloud} {Documentation}},
url = {https://cloud.google.com/tpu/docs/v4},
language = {es-419-x-mtfrom-en},
urldate = {2026-02-17},
month = feb,
year = {2026},
file = {Snapshot:/home/velocitatem/Zotero/storage/N724QGF6/v4.html:text/html},
}

View File

@@ -10,27 +10,37 @@
In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove separability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners. In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove separability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 31st 2026.}
\subsection{Motivation and Market Context} \subsection{Motivation and Market Context}
The current innovation boom in generative artificial intelligence and its applications to knowledge-based work tasks has brought many competing technologies for browser-use automation, with benchmarks and evaluations \cite{xia_evaluation-driven_2025} motivating the development of capabilities focused on commercial research, understanding, and transaction execution \cite{xie_osworld_nodate}. The ``AI Agent'' market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030. This surge reflects adoption in e-commerce, customer service, and enterprise automation, where agents handle interactions previously done by humans, raising the question of how these systems should be designed for future robustness as well as how to maintain a competitive edge in the analytical components of e-commerce platforms \cite{markntel_advisors_global_2025}. The current innovation boom in generative artificial intelligence and its applications to knowledge-based work tasks has brought many competing technologies for browser-use automation, with benchmarks and evaluations \parencite{xia_evaluation-driven_2025} motivating the development of capabilities focused on commercial research, understanding, and transaction execution \parencite{xie_osworld_2024}. The ``AI Agent'' market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030. This surge reflects adoption in e-commerce, customer service, and enterprise automation, where agents handle interactions previously done by humans, raising the question of how these systems should be designed for future robustness as well as how to maintain a competitive edge in the analytical components of e-commerce platforms \parencite{markntel_advisors_global_2025}.
The key stakeholders affected by the threat of increasing agent-driven traffic include online businesses and platform operators (especially in bot-heavy sectors like retail, travel, and financial services), their security, fraud, and engineering teams, end users whose accounts and data are exposed and whose experience degrades, regulators and legal stakeholders responding to breaches and fraud, and the attackers or bot operators driving the automation \cite{imperva_rapid_2025}. The key stakeholders affected by the threat of increasing agent-driven traffic include online businesses and platform operators (especially in bot-heavy sectors like retail, travel, and financial services), their security, fraud, and engineering teams, end users whose accounts and data are exposed and whose experience degrades, regulators and legal stakeholders responding to breaches and fraud, and the attackers or bot operators driving the automation \parencite{imperva_rapid_2025}.
The industry has already seen legal action in cases like Amazon against Perplexity \cite{ghaffary_amazon_nodate}, stemming from the difficulty of identifying traffic from hybrid systems like the Commet browser. This paper explores such systems to better understand what the interaction data looks like and what it means for dynamic pricing and recommendation systems downstream. This observed impact indicates a need for prevention of secondary negative effects on the ``legacy'' systems which power modern revenue sources for many companies. Dynamic pricing algorithms rely on directly translating demand features $q$ to new price assignments $\hat{p}$ across a catalogue of products of size $N$. This opens opportunities to design a \textit{tabula rasa} of digital market mechanisms that will shape the future of commerce in the age of artificial intelligence. The industry has already seen legal action in cases like Amazon against Perplexity \parencite{ghaffary_amazon_2025}, stemming from the difficulty of identifying traffic from hybrid systems like the Commet browser. This paper explores such systems to better understand what the interaction data looks like and what it means for dynamic pricing and recommendation systems downstream. This observed impact indicates a need for prevention of secondary negative effects on the ``legacy'' systems which power modern revenue sources for many companies. Dynamic pricing algorithms rely on directly translating demand features $q$ to new price assignments $\hat{p}$ across a catalogue of products of size $N$. This opens opportunities to design a \textit{tabula rasa} of digital market mechanisms that will shape the future of commerce in the age of artificial intelligence.
\subsection{Solution Space Overview} \subsection{Solution Space Overview}
Dynamic pricing systems, as presented in \cite{mueller_low-rank_2019}, often deal with sparse low-rank data of demand signals which, combined with contamination from agents, creates complex interactions that impact pricing. To further complicate the problem, certain commercial settings such as the one presented in \cite{amjad_censored_2017} must address the true demand of products under censored observations. This provides a formulation for handling demand in our case with multiple kinds of commercial mediators: $\hat{q} \gets q_A + q_H$ where $q_A$ represents the distribution of demand generated by agentic mediators and $q_H$ represents that of true human demand, these are two distinct populations with divergent objective functions. Dynamic pricing systems, as presented by \textcite{mueller_low-rank_2019}, often deal with sparse low-rank data of demand signals which, combined with contamination from agents, creates complex interactions that impact pricing. To further complicate the problem, certain commercial settings such as the one presented by \textcite{amjad_censored_2017} must address the true demand of products under censored observations. This provides a formulation for handling demand in our case with multiple kinds of commercial mediators: $\hat{q} \gets q_A + q_H$ where $q_A$ represents the distribution of demand generated by agentic mediators and $q_H$ represents that of true human demand, these are two distinct populations with divergent objective functions.
We formally define interaction data as coming from some actor which can either be an agent ($A$) or human ($H$). For purposes of this research, an agent is an algorithmic loop with the ability to access a web platform and perform actions such as clicks, scrolls, and input field fills. The loop terminates when the internal large language model judges the provided task definition as complete. A detailed breakdown can be found in \cref{algagent-loop}. We formally define interaction data as coming from some actor which can either be an agent ($A$) or human ($H$). For purposes of this research, an agent is an algorithmic loop with the ability to access a web platform and perform actions such as clicks, scrolls, and input field fills. The loop terminates when the internal large language model judges the provided task definition as complete. A detailed breakdown can be found in \cref{algagent-loop}.
\subsection{Research Questions}
This dissertation is organized around one main research question and three supporting sub-questions:
\begin{enumerate}
\item[\textbf{Main RQ}] How can dynamic pricing systems preserve margin integrity when transaction orchestration is increasingly mediated by non-human agents?
\item[\textbf{SQ1}] \textit{Separability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting?
\item[\textbf{SQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems?
\item[\textbf{SQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination?
\end{enumerate}
\begin{algorithm}[t] \begin{algorithm}[t]
\DontPrintSemicolon \DontPrintSemicolon
\SetKwInOut{Input}{Input} \SetKwInput{Input}{Input}
\SetKwInOut{Output}{Output} \SetKwInput{Output}{Output}
\Input{Goal $G$, Platform URL $u$, LLM $\mathcal{M}$} \Input{Goal $G$, Platform URL $u$, LLM $\mathcal{M}$}
\Output{Task completion result $r$} \Output{Task completion result $r$}
@@ -54,4 +64,4 @@ Extract final result $r$ from terminal state\;
\end{algorithm} \end{algorithm}
The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \cite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary. The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary.

View File

@@ -1,28 +1,29 @@
\section{Literature Review} \section{Literature Review}
To better understand all wedges of the work, we must start by exploring the nature of agents and agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \cite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \cite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups. To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups.
\subsection{Agent Taxonomy and Definitions} \subsection{Agent Taxonomy and Definitions}
An agent in the context of artificial intelligence is generally defined by anything that can reason and act upon observations of its environments (collected through some sensory inputs) and carry out actions through effectors. Moreover, a rational agent is an entity that is capable of perceiving the world around them and taking actions to advance specified goals. This definition by \cite{russell_artificial_nodate} is further developed in an economic context by \cite{parkes_economic_2015}, suggesting AI research attempts to construct a synthetic \textit{homo economicus}, which may also be termed \textit{machina economicus}. An agent in the context of artificial intelligence is generally defined by anything that can reason and act upon observations of its environments (collected through some sensory inputs) and carry out actions through effectors. Moreover, a rational agent is an entity that is capable of perceiving the world around them and taking actions to advance specified goals. This definition by \textcite{russell_artificial_2021} is further developed in an economic context by \textcite{parkes_economic_2015}, suggesting AI research attempts to construct a synthetic \textit{homo economicus}, which may also be termed \textit{machina economicus}.
A specific class or taxon of this \textit{machina economicus}, the Large Language Model (LLM) agent, is defined as an autonomous system capable of achieving goals and adapting post-training, often without needing explicit code or fundamental model changes. \cite{xia_evaluation-driven_2025} A specific class or taxon of this \textit{machina economicus}, the Large Language Model (LLM) agent, is defined as an autonomous system capable of achieving goals and adapting post-training, often without needing explicit code or fundamental model changes \parencite{xia_evaluation-driven_2025}.
We must however acknowledge the current SOTA as presented by OSWORLD simulations in \cite{xie_osworld_nodate} have demonstrated that multi-modal tasks across desktop and web interaction modes, have a top-performing score of only 12.24\% success, whereas humans have a higher 72\% success rate. This weakness matters for this research because it clarifies the near-term threat model: practical exploitation does not require a fully competent ``computer assistant'', only enough automation to perform high-volume reconnaissance actions (search/filter/open product pages, probe availability/price boundaries) that can contaminate behavioral signals. With the expected growth of these capabilities, this threat only becomes more perilous to revenue management systems. We must however acknowledge the current SOTA as presented by OSWORLD simulations by \textcite{xie_osworld_2024} have demonstrated that multi-modal tasks across desktop and web interaction modes, have a top-performing score of only 12.24\% success, whereas humans have a higher 72\% success rate; this is linked to the lack of grounding of these agents and their inability of handling unexpected errors. This weakness matters for this research because it clarifies the near-term threat model: practical exploitation does not require a fully competent ``computer assistant'', only enough automation to perform high-volume reconnaissance actions (search/filter/open product pages, probe availability/price boundaries) that can contaminate behavioral signals. With the expected growth of these capabilities, this threat only becomes more perilous to revenue management systems.
We model an agent session as producing some events with lower in-session conversion levels relative to humans, this we state in our assumption that $P(\text{purchase} \vert A) \ll P(\text{purchase} \vert H)$ but with a potentially higher volatility in $\hat{q}$, which we observe through the look-to-book metrics in our simulation. We model an agent session as producing some events with lower in-session conversion levels relative to humans, this we state in our assumption that $P(\text{purchase} \vert A) < P(\text{purchase} \vert H)$ but with a potentially higher volatility in $\hat{q}$, which we observe through the look-to-book metrics in our simulation.
\subsection{Economic Agents: From Homo Economicus to Machina Economicus} \subsection{Economic Agents: From Homo Economicus to Machina Economicus}
Existing behavioral economic models tend to be criticized for the assumption of rational behavior, as is embodied in the term of homo economicus. The definition of a machina economicus by \cite{parkes_economic_2015} is quite appropriate for our case, particularly because these assumptions of rationality have been argued to be a very adequate reference for AI research by \cite{varian_economic_1995}. For modeling this behavior, the trajectories of these agents can be formally defined to be partially observable Markov decision processes. \cite{xie_osworld_nodate} Agents are however not to be confused with web-bots which have previously been known as automated software applications or scrapers which are set with a purpose of carrying out specific tasks on the internet, without a higher level of internal judgement. \cite{imperva_rapid_2025} In our research, we refer to this actor simply as an Agent belonging to the distribution $A$. Existing behavioral economic models tend to be criticized for the assumption of rational behavior, as is embodied in the term of homo economicus. The definition of a machina economicus by \textcite{parkes_economic_2015} is quite appropriate for our case, particularly because these assumptions of rationality have been argued to be a very adequate reference for AI research by \textcite{varian_economic_1995} due to its expected utility maximizing nature. For modeling this behavior, the trajectories of these agents can be formally defined to be partially observable Markov decision processes \parencite{xie_osworld_2024}. Agents are however not to be confused with web-bots which have previously been known as automated software applications or scrapers which are set with a purpose of carrying out specific tasks on the internet, without a higher level of internal judgement \parencite{imperva_rapid_2025}. In our research, we refer to this actor simply as an Agent belonging to the distribution $A$.
This economic framing also helps separate two related but distinct phenomena of agents as buyers (changing market demand composition), and agents as information gatherers (changing the observed interactions used by pricing/recommendation systems). The thesis focuses on the second, where information acquisition strategically precedes purchase execution. We do not however dismiss the proposed expectation that existing economic systems serving humans, will not be populated by AIs across multiple channels and with various possibly misaligned goals as stated by \cite{parkes_economic_2015}. This economic framing also helps separate two related but distinct phenomena of agents as buyers (changing market demand composition), and agents as information gatherers (changing the observed interactions used by pricing/recommendation systems). The thesis focuses on the second, where information acquisition strategically precedes purchase execution. We do not however dismiss the proposed expectation that existing economic systems serving humans, will not be populated by AIs across multiple channels and with various possibly misaligned goals as stated by \textcite{parkes_economic_2015}.
A HAP (HTTP Agent Profile) protocol has been developed as an internet draft by \textcite{dhir_http_2025} in an effort to separate agentic and human internet traffic, however the majority adoption by both the sellers and agent providers would be required for the implementation of such a solution.
\subsection{Problem Evidence and Market Impact} \subsection{Problem Evidence and Market Impact}
The statistical issue of contamination in dynamic pricing systems that observe demand features as a means to update prices has been documented in various previous contexts. The airline industry (which has accounted for 24\% of observed disruptions) has seen malicious activity with a measureable impact on skewing key performance indicators by behavior visible in the look-to-book metrics. Excessive reconnaissance traffic inflates search volume without corresponding completed bookings, thereby skewing demand forecasts and disrupting dynamic pricing models. Demand proxies have also been observed to cause significant threat to inventory management by creating artificial scarcity that distorts the demand-supply relationships in the enterprise model. Censored demand as shown in \cite{amjad_censored_2017} can also be observed in low-bias demand under-estimation caused by a distortion effect coming from non-human traffic data. \cite{imperva_rapid_2025} The statistical issue of contamination in dynamic pricing systems that observe demand features as a means to update prices has been documented in various previous contexts. The airline industry (which has accounted for 24\% of observed disruptions) has seen malicious activity with a measureable impact on skewing key performance indicators by behavior visible in the look-to-book metrics. Excessive reconnaissance traffic inflates search volume without corresponding completed bookings, thereby skewing demand forecasts and disrupting dynamic pricing models. Demand proxies have also been observed to cause significant threat to inventory management by creating artificial scarcity that distorts the demand-supply relationships in the enterprise model. Censored demand as shown by \textcite{amjad_censored_2017} can also be observed in low-bias demand under-estimation caused by a distortion effect coming from non-human traffic data \parencite{imperva_rapid_2025}.
When dynamic pricing algorithms operate on highly contaminated or noisy data, the risk grows significantly in creating inaccurate price inferences. The emergent mitigation driven by un-informed reward and regret signals might lead to price suppression for sales continuity which results in harming margins and resulting in a revenue loss. System that poorly fit undesired behavior might result in price gouging, which calls for strong guardrails while preserving targeted business strategy. \cite{mullapudi_reinforcement_nodate} When dynamic pricing algorithms operate on highly contaminated or noisy data, the risk grows significantly in creating inaccurate price inferences. The emergent mitigation driven by un-informed reward and regret signals might lead to price suppression for sales continuity which results in harming margins and resulting in a revenue loss. System that poorly fit undesired behavior might result in price gouging, which calls for strong guardrails while preserving targeted business strategy \parencite{mullapudi_reinforcement_2025}.
%Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries %Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries
@@ -30,17 +31,42 @@ When dynamic pricing algorithms operate on highly contaminated or noisy data, th
\subsection{Theoretical Foundations: Economic Parallels} \subsection{Theoretical Foundations: Economic Parallels}
Early hints of exploration of prices in a standard English auction explored by \textcite{varian_economic_1995} which hints at exploration of prices in a sequential manner, which leads to a marginally different cost to the bidder than the reservation price of the seller. This is a setting in which there is no cost incured by the buyer for their actions or exploring prices in the market. They propose that any agent responsable for the pricing of a good must be imune to dynamic strategies which might extract private information from a market. A key take-away which relates to the Vickery auction mechanism (also called a \textit{direct mechanism}) suggests that not only would defenses against such exploitation be necessary, but the construction of a mechanism in which revelation of the true willingness to pay is the dominant strategy for commerce.
Early hints of exploration of prices in a standard English auction explored in \cite{varian_economic_1995} which hints at exploration of prices in a sequential manner, which leads to a marginally different cost to the bidder than the reservation price of the seller. This is a setting in which there is no cost incured by the buyer for their actions or exploring prices in the market. They propose that any agent responsable for the pricing of a good must be imune to dynamic strategies which might extract private information from a market. A key take-away which relates to the Vickery auction mechanism (also called a \textit{direct mechanism}) suggests that not only would defenses against such exploitation be necessary, but the construction of a mechanism in which revelation of the true willingness to pay is the dominant strategy for commerce. Like in classical revenue-maximizing auctions \parencite{roughgarden_cs364a_2013} we assume that the human actor in our system has a private valuation $v$ which we formally draw from intrinsically defined distributions. The important note here is that the agent proxy does not have a mechanism to convey this private information into the demand data which directly impacts the pricing systems.
Like in classical revenue-maximizing auctions \cite{roughgarden_cs364a_2013} we assume that the human actor in our system has a private valuation $v$ which we formally draw from later defined distributions. The important note here is that the agent proxy does not have a mechanism to convey this private information into the demand data which directly impacts the pricing systems. The key component of this mediation between agents and commercial platforms lays in the transaction costs related to information gathering and negotiation. As proposed by \textcite{shahidi_coasean_2025} these costs are bound to collapse towards zero (which we demonstrate mathematically), calling for a re-evaluation of the boundaries between firms and markets. As argued by \textcite{coase_nature_1937}, the market participation and time associated with that participation, is critical part of the Coasean transaction cost logic which includes the discovery or relevant pricing within a given market. This process of price discovery without the presence of AI Agents can be time consuming and resource intensive. To build on top of this work we provide a proof of optimal conditions theorised by Coaes as an extension to AI-mediated markets.
% Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance % Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance
% Link Coasean Singularity and other economic market theory and highlight specific information of supra competitive pricing.
\subsection{Landscape of Existing Work} \subsection{Landscape of Existing Work}
Previous efforts in adversarial computer use LLM agents, show how multi-faceted the whole problem is Explorations of the algorithmic collusion by LLMs \parencite{fish_algorithmic_2025} has demonstrated a cross-model tendency of market division with a strong sensitivity to instructions provided in the ``system prompt''. If a dynamic pricing algorithm which is trained to respond to market signals learns to coordinate with competitor agents (or become manipulated by those agents), the market equilibrium is under threat of destabilization. This is particularly true for Q-learning pricing learners as demonstrated by \textcite{calvano_artificial_2018}.
Here we can show a market visualization (venn-like-diagram)
Our effort to combat contamination stems from research by \textcite{hardt_strategic_2015} on strategic classification, in conjunction with \textcite{liu_contextual_2024} who demonstrate a linear regret if contamination is ignored. The strategic classification adversarial effect comes from an effort to manipulate some representative features used in a learning pipeline, which can result in lower prices on loans or lower prices from dynamic pricing algorithms.
To bridge the gap between detection and robust pricing, we look at work in Distributionally Robust Optimization (DRO). As defined by \textcite{kuhn_wasserstein_2024}, DRO provides a framework for decision-making under ambiguity, where the true data distribution is unknown but lies within a ``Wasserstein ball'' of a target distribution. In our context, the ``ambiguity set'' represents the uncertainty introduced by agentic reconnaissance. By optimizing for the worst-case distribution within this set, pricing mechanisms can become resilient to the distributional shifts such as the ones caused by non-human actors, effectively robustifying the revenue function against the contamination described in our problem statement.
In order to create an environment in which prices can be tested against a demand estimate generated by some behavioral model, we take inspiration from the architecture proposed by \textcite{ie_recsim_2019} in the RecSim platform built for recommendation systems. By modeling the distinct user behavior as POMDPs we can generate faithful interactions which allow us to generalize, past the constraint which is also present in recommendation systems, of rarely having enough experience with individual actor's interactions for good recommendations without generalization. The key inspiration comes from the user choice modeling which we translate to a user transition model for each distinct actor type (agent or human). We further consider the possibility of modeling our quantitative research platform using dynamic Bayesian networks for the sake of tractability within the system. The contribution or RecSim enables researchers to better understand learning algorithms in fixed environments, a gap we identify as needing to be bridged within the space of dynamic pricing.
% TODO: mention https://github.com/meta-pytorch/OpenEnv/tree/main/envs/browsergym_env
We also acknowledge the difficulty in similarly affected fields such as authorship, where \textcite{ganie_uncertainty_2025} demonstrate the theoretical limits of the distributional divergence between text authored by a human or large language model. Their approach of computing the divergence between two distributions demonstrates purely theoretically that no classifier can outperform random guessing on their particular task. This is yet another factor to take into consideration when exploring the potential mitigation strategies.
The setting of our work is quite complex and covers a wide range of topics, each with its own set of issues that further complicate the task at hand. There is however promise in the field of reinforcement learning and adversarial robustness to combat these problems. We can summarize the characteristics learned from the review of our environment as:
\begin{enumerate*}[label=(\roman*)]
\item non-stationary demand with temporal noise $\epsilon_t$
\item contaminated behavioral signals from mixed human-agent traffic with unknown mixing ratio $\alpha$
\item partial observability where only demand proxies $\hat{q}$ are available, not true demand $d(\cdot)$
\item strategic actors capable of feature manipulation to influence pricing outcomes
\item information asymmetry with private valuations $v$ drawn from unknown distributions
\item session-based interactions modeled as POMDPs with trajectories $\tau_s$
\item low conversion probability for agents: $P(\text{purchase} \mid A) < P(\text{purchase} \mid H)$
\item distributional uncertainty requiring robust optimization within Wasserstein ambiguity sets
\item potential for adversarial exploitation through false-name bidding and identity whitewashing.
\end{enumerate*}
%Previous efforts in adversarial computer use .LLM agents, show how multi-faceted the whole problem is
%Here we can show a market visualization (venn-like-diagram)

View File

@@ -1,5 +1,8 @@
\section{Methodology} \section{Methodology}
% Extra notes and clarifications: we observed some humans and get their transition probabilities between event types
% We modify behavioral profiles of transition matrices with price elasticity matrices generated by sample valuations of a distributing.
This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven separability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets. This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven separability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets.
\subsection{Problem Formalization} \subsection{Problem Formalization}
@@ -19,13 +22,21 @@ where:
The platform does not directly observe the true underlying demand function $d(p)$. Instead, it observes a behavioral proxy $\hat{q}_t$, which is a composite signal derived from the mixture of actor types. We define the demand proxy for product $i$ at epoch $t$ as a weighted aggregation of events: The platform does not directly observe the true underlying demand function $d(p)$. Instead, it observes a behavioral proxy $\hat{q}_t$, which is a composite signal derived from the mixture of actor types. We define the demand proxy for product $i$ at epoch $t$ as a weighted aggregation of events:
\begin{equation} \begin{equation}
\label{eq:qhat}
\hat{q}_{t,i} = \sum_{s \in \mathcal{S}_t} \sum_{k=1}^{L_s} \omega(a_{s,k}) \cdot \mathbb{1}[i_{s,k} = i] \hat{q}_{t,i} = \sum_{s \in \mathcal{S}_t} \sum_{k=1}^{L_s} \omega(a_{s,k}) \cdot \mathbb{1}[i_{s,k} = i]
\end{equation} \end{equation}
where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay. where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay.
\subsubsection{Actor Types and Demand Curves} In the current engine implementation, we use the normalized variant of this proxy for each step:
We formalize the heterogeneity of actors by introducing a type space $\Theta$. An actor of class $Y_s$ is further parameterized by a type $\theta \sim \mathcal{D}_{Y}$. This type determines the actor's demand response function $d(p; \theta)$, sampled from a distribution of possible demand curves. The total observed demand is a stochastic process governed by the mixture:
\begin{equation} \begin{equation}
\tilde q_{t,i} = 100 \cdot \frac{\hat q_{t,i}}{\sum_{j=1}^{N}\hat q_{t,j} + \varepsilon}
\end{equation}
with fixed category-level weights (cart, dwell, nav, filter) following the same rank order from Table~\ref{tab:action_space}. This keeps the signal dense and directly usable in the simulator.
\subsubsection{Actor Types and Demand Curves}
We formalize the heterogeneity of actors by introducing a type space $\Theta$. An actor of class $Y_s$ is further parameterized by a type $\theta \sim \mathcal{D}_{Y}$. This type determines the actor's demand response function $d(p; \theta)$, sampled from a distribution of possible demand curves. The total observed demand is a stochastic process governed by the naively defined mixture:
\begin{equation}
\label{eq:mixture_demand}
Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t
\end{equation} \end{equation}
where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise. where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise.
@@ -34,15 +45,18 @@ where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of
\subsection{Cost of Information (COI) Framework} \subsection{Cost of Information (COI) Framework}
The \textit{Cost of Information} (COI) represents the markup a pricing policy $\pi$ attempts to extract from the market by leveraging demand signals. We define COI as the expected premium over the minimum viable price $\underline{p}$ (or marginal cost). This also speaks to the financial urgency as a consequence of information asymmetry between the platform and the actors. The platform's pricing power comes from information asymmetry: users who express strong interest signals pay more than the base price. We quantify this markup as the \textit{Cost of Information} (COI), which represents the average premium extracted above marginal cost. COI measures the revenue at risk when information asymmetry collapses.
A top-level view in the current AI discourse is that sufficiently large productivity gains can induce vertical deflation through cost compression and supply expansion \parencite{rachitsky_marc_2026}. Our contribution is narrower and mechanism-level: even under long-run deflation, platform revenue still depends on short-run information costs to the user. We formalize that rent as the Cost of Information (COI) and study how agentic reconnaissance accelerates its erosion.
\begin{definition}[Cost of Information] \begin{definition}[Cost of Information]
Let $\pi(\tau)$ be a pricing policy mapping interaction histories to prices. The COI is defined as: Let $\pi(\tau)$ be a pricing policy mapping interaction histories to prices. The COI is defined as:
\begin{align} \begin{equation}
\text{COI} &= \mathbb{E}[P] - \underline{p} \\ \text{COI} = \mathbb{E}[P] - \underline{p}
&= \int_{\underline{p}}^{\bar{p}} (1 - F_\pi(p)) \, dp \end{equation}
\end{align} where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underline{p}$ is the minimum viable price (marginal cost).
where $F_\pi(p)$ is the cumulative distribution function of prices generated by $\pi$ under standard operating conditions. % Alternative survival function representation (used in proof):
% COI = \int_{\underline{p}}^{\bar{p}} (1 - F_\pi(p)) \, dp
% where F_\pi(p) is the CDF of prices generated by \pi
\end{definition} \end{definition}
\begin{figure}[ht] \begin{figure}[ht]
@@ -79,46 +93,39 @@ where $F_\pi(p)$ is the cumulative distribution function of prices generated by
We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes. We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes.
A fundamental assumption for our claim lies in the alignment of the AI agent through its prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective.
\begin{theorem}[COI Erosion in the Limit] \begin{theorem}[COI Erosion in the Limit]
Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0. Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0.
\end{theorem} \end{theorem}
\begin{proof} \begin{proof}
Let $p_1, \ldots, p_N$ be independent and identically distributed (i.i.d.) price samples drawn from the policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. The realizable price for an optimal searching agent is the first order statistic $p_{(1)} = \min(p_1, \ldots, p_N)$. Consider $N$ independent agents querying the platform, each receiving a price sample $p_i$ drawn from the pricing policy's distribution $F(p)$ bounded by $[\underline{p}, \bar{p}]$. A strategic agent conducting reconnaissance will select the minimum observed price: $p_{(1)} = \min(p_1, \ldots, p_N)$.
% support here means that its the range of possible outputs.
The survival function (or reliability function) of the minimum price is given by: The probability that the minimum price exceeds some threshold $t$ is:
\begin{equation} \begin{equation}
S_{p_{(1)}}(t) = P(p_{(1)} > t) = [1 - F(t)]^N P(p_{(1)} > t) = P(\text{all } p_i > t) = [1 - F(t)]^N
\end{equation} \end{equation}
To determine the expected value $\mathbb{E}[p_{(1)}]$, we recall the property that for any continuous random variable $X$ with support $[A, B]$, the expectation can be expressed as the lower bound plus the integral of the survival function: For any price $t > \underline{p}$, the CDF satisfies $F(t) > 0$, so $1 - F(t) < 1$. As $N$ grows, this probability decays exponentially: $[1 - F(t)]^N \to 0$.
The expected minimum price can be written as:
\begin{equation} \begin{equation}
\mathbb{E}[X] = A + \int_{A}^{B} P(X > t) \, dt \mathbb{E}[p_{(1)}] = \underline{p} + \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt
\end{equation} \end{equation}
Applying this to our pricing statistic where the lower bound is $\underline{p}$: Since the integrand vanishes as $N \to \infty$ for all $t > \underline{p}$, the integral converges to zero. Therefore:
\begin{align}
\mathbb{E}[p_{(1)}] &= \underline{p} + \int_{\underline{p}}^{\bar{p}} P(p_{(1)} > t) \, dt \\
&= \underline{p} + \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt
\end{align}
Since $F(t)$ is a valid CDF, for any $t > \underline{p}$, we have strict inequality $F(t) > 0$, implying $0 \le 1 - F(t) < 1$. By the properties of limits, as $N \to \infty$, the term $[1 - F(t)]^N$ converges to 0 pointwise for all $t > \underline{p}$.
Applying the Lebesgue Dominated Convergence Theorem (noting that the integrand is bounded by 1 on the finite interval $[\underline{p}, \bar{p}]$):
\begin{equation} \begin{equation}
\lim_{N \to \infty} \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt = \int_{\underline{p}}^{\bar{p}} 0 \, dt = 0 \lim_{N \to \infty} \text{COI} = \lim_{N \to \infty} (\mathbb{E}[p_{(1)}] - \underline{p}) = 0
\end{equation} \end{equation}
Substituting this back into the expression for COI:
\begin{align}
\lim_{N \to \infty} \text{COI} &= \lim_{N \to \infty} (\mathbb{E}[p_{(1)}] - \underline{p}) \\
&= \lim_{N \to \infty} \left( (\underline{p} + 0) - \underline{p} \right) \\
&= 0
\end{align}
\end{proof} \end{proof}
This result proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a robust counter-mechanism. This result naively proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a robust counter-mechanism.
% The DRO objective creates a lower bound on COI extraction, effectively guaranteeing a minimum margin even in the presence of adversarial agents. we need to prove this and demonstrate that in a theorem. % The DRO objective creates a lower bound on COI extraction, effectively guaranteeing a minimum margin even in the presence of adversarial agents. we need to prove this and demonstrate that in a theorem.
@@ -129,14 +136,18 @@ This result proves that standard pricing policies $\pi$ fail to extract surplus
In order for our research to have grounding in interactions we built a robust e-commerce web-platform. We initially conducted a survey of the leading platforms of airlines and hotel booking sites to identify the specific interface patterns that effectively manage complex travel data. Our analysis revealed a clear industry standard: while both sectors rely on tabbed service selection and left-sidebar filtering to streamline navigation, they diverge in result presentation: airlines utilize visual date-price bars and multi-step wizards to optimize for logistical transparency, whereas hotel platforms leverage image-led cards and scarcity triggers to drive emotional engagement and urgency. Our web framework defines a highly agnostic boilerplate which can be seeded with any data-modality with an easy-to-tailor pattern, which we leverage to define a \texttt{hotel} and \texttt{airline} mode. Both modes are then individually deployed via an environment level argument which adjusts the proxy routing with a custom middleware inside next.js to render only the desired mode. The purpose of this was to create a baseline adaptable to any use-case or desired commercial application. In order for our research to have grounding in interactions we built a robust e-commerce web-platform. We initially conducted a survey of the leading platforms of airlines and hotel booking sites to identify the specific interface patterns that effectively manage complex travel data. Our analysis revealed a clear industry standard: while both sectors rely on tabbed service selection and left-sidebar filtering to streamline navigation, they diverge in result presentation: airlines utilize visual date-price bars and multi-step wizards to optimize for logistical transparency, whereas hotel platforms leverage image-led cards and scarcity triggers to drive emotional engagement and urgency. Our web framework defines a highly agnostic boilerplate which can be seeded with any data-modality with an easy-to-tailor pattern, which we leverage to define a \texttt{hotel} and \texttt{airline} mode. Both modes are then individually deployed via an environment level argument which adjusts the proxy routing with a custom middleware inside next.js to render only the desired mode. The purpose of this was to create a baseline adaptable to any use-case or desired commercial application.
The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise. The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise.
\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda architecture for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment.
\subsubsection{DevOps Principles} \subsubsection{DevOps Principles}
Reproducible results are key to quality research platforms, this is taken into mind when deploying and working with our research platform. From a deployment standpoint the platform can be deployed across a large variety of providers and can be run locally. When developing a new interaction modality apart from the ones that come out of the box, a simple template pattern can be followed. The middleware of the framework is designed to properly render the chosen modality from environmental variables, thus deployment of different or parallel version of the software can be easily parametrized.
\subsubsection{Online Dynamic Pricing} \subsubsection{Online Dynamic Pricing}
In order to collect data from actors under correct conditions we replicate a naive and simple dynamic pricing algorithm which runs in the background during the experiments.
The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$. The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$): The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$. The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$):
\begin{equation} \begin{equation}
@@ -150,22 +161,31 @@ p_{0,i} & \text{otherwise}
where $p_0 \in \mathbb{R}^N$ is the base price vector (which is seeded into our database distinctly for each mode of the commerce platform), $\theta_{\text{high}}, \theta_{\text{low}} \in \mathbb{R}$ are demand thresholds defining surge and discount regions, and $\lambda_{\text{surge}}, \lambda_{\text{disc}} \in \mathbb{R}^+$ are multiplicative factors with typical values $\lambda_{\text{surge}} = 1.2$ and $\lambda_{\text{disc}} = 0.9$. This piecewise function enables rapid price adjustment in response to observed demand without requiring complex elasticity estimation or historical calibration, allowing us to expose actors within our experiments to a system with a dynamic component of pricing. where $p_0 \in \mathbb{R}^N$ is the base price vector (which is seeded into our database distinctly for each mode of the commerce platform), $\theta_{\text{high}}, \theta_{\text{low}} \in \mathbb{R}$ are demand thresholds defining surge and discount regions, and $\lambda_{\text{surge}}, \lambda_{\text{disc}} \in \mathbb{R}^+$ are multiplicative factors with typical values $\lambda_{\text{surge}} = 1.2$ and $\lambda_{\text{disc}} = 0.9$. This piecewise function enables rapid price adjustment in response to observed demand without requiring complex elasticity estimation or historical calibration, allowing us to expose actors within our experiments to a system with a dynamic component of pricing.
We will for our offilne experimental intents generalize a master function for encompasing distinct demand estimation and pricing strategies. % For our offline experimental setting, we generalize a master value function that can encompass different demand estimation and pricing strategies.
%
\begin{align} % \begin{align}
V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]} % V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]}
\end{align} % \end{align}
%
We follow differnet substitutouns which will server as hyperparameters later on. % We evaluate different substitutions of this objective, which later serve as hyperparameters in the simulator.
\subsection{Experimental Design} \subsection{Experimental Design}
The experimentation begins with the design of goals, with careful consideration to assure a uniform spanning across different variables within each product-architecture of either the hotel or airline platforms. Our crafted collection of goals (jobs to be done) is then tracked in a postgress database with one table to track goals and another table to track different experiment runs, and their associated goals in a experiment-goal one-to-one relationship. We start from a practical constraint: we do not have access to proprietary production data. Because of that, we design our own fictional platform that still represents how commercial platforms work in the real world. The design comes from a survey of hotel and airline websites, where we extracted common interface components and used them as a high-level template for dynamic pricing environments.
The purpose of this effort to gather data on interactions, is the first half of our research. With this collected data on behavioral characteristics, enhanced by our feature augmentation, we can create distribution separation into two bins $y \in \{A,H\}$ with a certain probability $p$ dependent on the session-specific features. To address the second loop of our system, we use this gained capability of discrimination to enhance the learner design involved in our surrogate dynamic pricing task which simulates an independent dynamic pricing scenario under which we can train a more controlled policy with the ability to account for true demand signals under conditions of contamination from non-human actors. The interface is organized as a product catalog where each product belongs to a time-bounded price vector (for example, a daily pricing period). During each period we collect interaction data by instrumenting UI components and predefined action templates that are still customizable. This gives us control without losing realism.
Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants.
% TODO: describe the task pool in detail here -- list the specific tasks used in the experiments
A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor.
Our approach can be well summarized by a three-stage division, first we intend to observe and \textit{vectorize} the behavioral interaction data from our experiments, we then develop the separability which helps us deepen the semantic understanding of the behavioral patterns. Finally we use our newly gained learner to leverage a defensive mechanism within the simulation stage of a controlled dynamic pricing loop. The human data collection involved 18 participants, all of whom provided explicit informed consent prior to their session. Participants had an average age of 21 years and were recruited from a university population. Alongside the 18 human sessions we ran 18 agent sessions of equivalent task scope, giving a balanced dataset of 36 labeled trajectories. Each participant was assigned a single platform mode and a single task drawn from the pool, and completed the session independently without guidance on navigation or pricing strategy.
To evaluate quality and realism of the setup, we store both structured event logs and full interaction transcripts. This lets us combine quantitative analysis with transcript-level qualitative findings. The result is an isolated system where we can control the interaction process while preserving realistic behavior.
Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, run table, and assignment mapping). This data-acquisition phase is the first half of the methodology and is intentionally a disconnected component that feeds the later contributions. The second half uses collected behavioral traces to separate classes $y \in \{A,H\}$ with session-conditioned probability estimates, then injects those estimates into the pricing learner.
Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn separability to characterize human versus agent patterns, and (3) use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator.
\begin{figure}[ht] \begin{figure}[ht]
\resizebox{\columnwidth}{!}{% \resizebox{\columnwidth}{!}{%
@@ -174,23 +194,142 @@ Our approach can be well summarized by a three-stage division, first we intend t
\caption{Overview of the Dynamic Pricing Tasks.} \caption{Overview of the Dynamic Pricing Tasks.}
\end{figure} \end{figure}
Our web platform (developed in similar spirit to RecSim \parencite{ie_recsim_2019}) gives us a controlled environment where tasks are assigned to human and agentic actors and then executed. Each actor receives a browser-level experiment identifier that may persist across multiple session IDs. We then group by experiment and extract session trajectories using the schema below.
Study methodology and approach. Data acquisition strategy. Defined objectives and success criteria. Observable metrics and KPIs. To speak to realism, user interviews reported that the platform architecture mirrored standard booking interfaces and reduced the cognitive load required to learn the system. One participant described the flow as ``intuitive'' and close to a ``normal'' transaction, suggesting observed behavior was primarily driven by pricing treatment rather than interface novelty.
The dynamic pricing mechanism elicited immediate behavioral adjustments. Participants were sensitive to price volatility: sudden boosts triggered urgency and faster booking attempts, while large listing-to-final discrepancies triggered deeper comparison behavior. This is comforting because the controlled setup still produces commercially relevant interaction data.
\subsubsection{Design of Training Factorial Study}
The simulator has multiple configurable factors. We design a multi-factor study across five axes derived from the sweep configurations: (1) RL algorithm (\texttt{ppo}, \texttt{a2c}, \texttt{dqn}, \texttt{qtable}; 4 levels), (2) contamination ratio $\alpha$ sampled from $[0.1, 0.6]$ at four representative levels, (3) robustness radius $\epsilon_\alpha \in \{0.0, 0.15, 0.3\}$ (3 levels), (4) COI penalty weight $\lambda_\text{coi}$ at two reference levels, and (5) pricing action granularity (two discretization settings for \texttt{action\_levels}); giving a grid of $4\times4\times3\times2\times2 = 192$ configurations. Statistical power for the behavioral comparisons is determined by a two-sample test over per-session KL divergence scores; a formal power analysis with minimum detectable effect size at $n=18+18$ is reported in the results.
% Power analysis plan: apply a two-sample Mann-Whitney U (or permutation test) on per-session (delta_H - delta_A) divergence scores comparing the human and agent groups. Compute minimum detectable effect size at alpha=0.05, power=0.8, given n=18 per group. Bootstrap confidence intervals on mean KL are a cleaner complement given the non-normality of divergence distributions.
While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable.
Our training budget is provisioned through TPU Research Cloud and spans 384 chips across TPU v4, v5e, and v6e generations, with a spot-heavy allocation plus an on-demand reserve. At peak BF16 throughput this corresponds to approximately 160 PFLOPS of aggregate compute, which makes repeated seeds, ablations, and sensitivity sweeps feasible within practical wall-clock limits. We allocate v6e capacity to the highest-intensity policy training jobs, use v5e for wider hyperparameter exploration where throughput-per-dollar is favorable, and reserve on-demand v4 capacity for runs that should not be interrupted.
\begin{table}[ht]
\centering
\caption{Compact comparison of TPU generations used in the training stack.}
\label{tab:tpu_specs}
\begin{tabular}{@{}llll@{}}
\toprule
\textbf{Feature} & \textbf{TPU v4} & \textbf{TPU v5e} & \textbf{TPU v6e (Trillium)} \\
\midrule
Peak BF16 per chip (TFLOPS) & 275 & 197 & 918 \\
HBM capacity per chip (GB) & 32 & 16 & 32 \\
HBM bandwidth per chip (GB/s) & 1200 & 819 & 1600 \\
TensorCores per chip & 2 & 1 & 1 \\
Interconnect topology & 3D mesh/torus & 2D torus & 2D torus \\
Max pod size (chips) & 4096 & 256 & 256 \\
\bottomrule
\end{tabular}
\end{table}
\begin{table}[ht]
\centering
\caption{TPU allocation used for the factorial study.}
\label{tab:tpu_allocation}
\begin{tabular}{@{}llll@{}}
\toprule
\textbf{TPU Type} & \textbf{Total Chips} & \textbf{Zone(s)} & \textbf{Provisioning} \\
\midrule
v6e & 128 (64 + 64) & europe-west4-a, us-east1-d & Spot \\
v5e & 128 (64 + 64) & us-central1-a, europe-west4-b & Spot \\
v4 & 64 (32 + 32) & us-central2-b & 32 Spot + 32 On-demand \\
\bottomrule
\end{tabular}
\end{table}
For connections from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs with the benefit of having the most grouped chips within a single region. This regional grouping is important for the deployment of our Kubernetes cluster which cannot span multiple regions. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}.
Design of training processes: we build docker image with the fact in mind of different caching over layers in order to most speed up docker re-building and such we place the most volatile steps towards the end of the image building. What is means in practice is that any dependency installations are isolated so edits to source code do no trigger rebuilds. Only if we update our entry point of training a sweep, Docker will also rebuild the source-code copy stage.
Due to the preemptive nature of the current demand of TPU chips we sttle for running our on demeaned as the primary source of compute. The on demand TPU pod of 32 chips spread across 4 virtual hosts creates a relatively unique parallelization setup. Despite our desire to use a traditional approach of clustering and perhaps deploying SLURM jobs of our sweep agent, the lack of predictability in provisioning each instance of a compute resource makes this an high friction layer we do not want to add.
\subsubsection{Interaction Schema}
We extend the basic event tuple $e_{s,k}$ to capture the full observational signal available to the platform. An interaction event is defined as the extended tuple:
\begin{equation}
e_{s,k} = \left( a_{s,k}, \, i_{s,k}, \, t_{s,k}, \, \mu_{s,k}, \, \delta_{s,k} \right)
\end{equation}
where $\mu_{s,k} \in \mathcal{M}$ is a metadata record containing action-specific context (e.g., price observed, filter parameters, element text), and $\delta_{s,k} \in \mathbb{R}_+$ is the dwell time in milliseconds for attention-based actions.
A session $s$ is itself a structured record:
\begin{equation}
s = \left( \text{sid}, \, \text{eid}, \, t_0, \, \phi, \, \mathcal{U}, \, \tau_s \right)
\end{equation}
where $\text{sid}$ is a unique session identifier (UUID), $\text{eid}$ optionally links to an experiment, $t_0$ is the session start timestamp, $\phi \in \{\texttt{hotel}, \texttt{airline}\}$ denotes the platform mode, $\mathcal{U}$ is the user-agent string, and $\tau_s$ is the trajectory of events.
The action space $\mathcal{A}$ is partitioned into four semantic categories based on the behavioral signal each action conveys:
\begin{table}[ht]
\centering
\caption{Action space partition $\mathcal{A} = \mathcal{A}_{\text{nav}} \cup \mathcal{A}_{\text{cart}} \cup \mathcal{A}_{\text{filter}} \cup \mathcal{A}_{\text{dwell}}$ with signal interpretation.}
\label{tab:action_space}
\begin{tabular}{@{}llll@{}}
\toprule
\textbf{Category} & \textbf{Actions} & \textbf{Signal} & $\boldsymbol{\omega}$ \\
\midrule
$\mathcal{A}_{\text{cart}}$ & \texttt{add\_item}, \texttt{remove}, \texttt{checkout}, \texttt{purchase} & Purchase intent & High \\
$\mathcal{A}_{\text{dwell}}$ & \texttt{hover\_title}, \texttt{hover\_paragraph}, \texttt{hover\_link} & Sustained attention & Medium \\
$\mathcal{A}_{\text{nav}}$ & \texttt{page\_view}, \texttt{view\_item}, \texttt{learn\_more} & Discovery & Low \\
$\mathcal{A}_{\text{filter}}$ & \texttt{search}, \texttt{filter\_date}, \texttt{filter\_price}, \texttt{sort} & Preference refinement & Lowest \\
\bottomrule
\end{tabular}
\end{table}
This partition enables the weight function $\omega$ from Eq.~\ref{eq:qhat} to assign category-specific signal strengths, with $\omega(\mathcal{A}_{\text{cart}}) > \omega(\mathcal{A}_{\text{dwell}}) > \omega(\mathcal{A}_{\text{nav}}) > \omega(\mathcal{A}_{\text{filter}})$ reflecting decreasing commitment.
In the simulator baseline this order is encoded with a compact fixed scale: cart $=4.0$, dwell $=2.0$, nav $=1.0$, filter $=0.5$. Unknown actions are mapped by prefix heuristics to the nearest category.
The metadata record $\mu$ varies by action type. For product views, $\mu$ contains the observed price $p_{\text{obs}}$ and product attributes. For dwell events, $\mu$ includes the element text and accumulated hover duration. This heterogeneous structure is captured via a schema-on-read approach in our Kafka ingestion pipeline, where events are validated against type-specific schemas before storage.
In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response.
\subsection{Generative Contamination and Separability} \subsection{Generative Contamination and Separability}
To develop a robust pricing agent, we require a simulation environment capable of generating realistic, contaminated interaction data. We achieve this by learning from our Phantom platform data using a two-stage approach. To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from Phantom data using a two-stage approach.
\subsubsection{Ground-Truth Separability}
Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $y_s \in \{H, A\}$ are available as ground truth rather than as the output of a heuristic classifier. We therefore estimate separate transition kernels directly from each labeled partition $\mathcal{D}_H$ and $\mathcal{D}_A$, treating the resulting $\hat{\mathcal{T}}_H$ and $\hat{\mathcal{T}}_A$ as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability?
\subsubsection{GOFAI-Based Separability} To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. To contextualize this divergence metric we compare with an intra-class comparison baseline of randomly selected transitions.
We employ Good Old-Fashioned AI (GOFAI) heuristics to generate initial weak labels for separability. We define a set of rule-based predicates $\phi_j: \tau \to \{0, 1\}$ to partition the dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We construct distinct MDPs per each behavioral profile of humans and agents and from those we establish $D_{KL}$. From initial findings we compute a KL divergence of $\approx 2.0236$ across transition probabilities between states which can be seen in \ref{fig:human_mdp_viz} and \ref{fig:agent_mdp_viz}. % To contextualize this figure a useful intra-class baseline is to randomly split D_H into two equal halves, estimate a kernel from each half, compute the same average KL statistic, and repeat for B bootstrap samples (e.g. B=100). The resulting null distribution (mean +/- std) gives the divergence expected purely from estimation noise at this sample size. A between-class KL substantially above this null confirms the separation is real and not a finite-sample artefact. In practice: for each of B splits, partition D_H 50/50 without replacement, run build_kernel() on each half, average the per-state KL values, and collect the B scores into a reference distribution to compare against the 1.8 figure.
\begin{definition}[Kullback-Leibler Divergence for Transition Distributions]
Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is:
\begin{equation}
D_{\mathrm{KL}}(P_e \parallel Q_e) = \sum_{k \in \mathcal{S}_e} P_e(k) \log \frac{P_e(k)}{Q_e(k)}
\end{equation}
where $\mathcal{S}_e$ denotes the set of destination events that follow $e$ in the human trajectories.
\end{definition}
To obtain this statistic, we aggregate transitions by triggering event $e$ and treat normalized outgoing probabilities as categorical distributions $P_e$ (human) and $Q_e$ (agent). We intersect shared event labels, then accumulate log-ratio contributions over shared destinations. Large contributions, including near-zero $Q_e(k)$ cases, identify transitions where one actor class is difficult to mimic.
With these divergence features we train a contrastive model to estimate a weak agent probability $f(\tau)\in[0,1]$, which we later use as a weighting and control signal.
\subsubsection{Transition Probability Estimation}
\label{sec:tpe}
For both subsets, we model session dynamics as an MDP and estimate transition kernel $\mathcal{T}$. For each actor type we estimate global kernels $\hat{\mathcal{T}}_A$ and $\hat{\mathcal{T}}_H$, then cluster into behavioral sub-kernels $\hat{\mathcal{T}}_y^i$ to avoid collapsing all behavior into one average profile. Transition probabilities are estimated by maximum likelihood:
\begin{equation}
\hat{P}(s' \mid s) = \frac{N(s, s')}{\sum_{k \in \mathcal{S}} N(s, k)}
\end{equation}
where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. The properties of an MDP such as ... should be preserved by the operation described below.
To scale this to catalog-level pricing, we expand the base event transition matrix from $T\times T$ into product-specific transitions using the current demand condition. In practice, we normalize the demand vector across products and use it to weight how much transition mass each product pair receives. Concretely, each cell of the base matrix becomes an $N\times N$ block (for $N$ products), so the transition matrix grows from $T\times T$ to $(T\cdot N)\times(T\cdot N)$. Finally, we add $C$ generic states (homepage, login, checkout terminal states), which gives the full kernel size $(T\cdot N + C)\times(T\cdot N + C)$.
% The validity of this demand-weighted block expansion is still subject to formal proof: it needs to be shown that the resulting matrix retains row-stochasticity (rows summing to 1) and that the weighting by the demand vector preserves the Markov property for the expanded state space. In the engine source this is the target of ongoing validation before the expansion is relied on for behavioral generation at scale.
\begin{figure}[ht] \begin{figure}[ht]
\centering \centering
\includegraphics[width=0.8\textwidth]{chapters/mdp_human.pdf} \includegraphics[width=0.8\textwidth]{chapters/mdp_human.pdf}
\caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for human actions.} \caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for \textbf{human} actions.}
\label{fig:human_mdp_viz} \label{fig:human_mdp_viz}
\end{figure} \end{figure}
@@ -201,36 +340,74 @@ We employ Good Old-Fashioned AI (GOFAI) heuristics to generate initial weak labe
\label{fig:agent_mdp_viz} \label{fig:agent_mdp_viz}
\end{figure} \end{figure}
\subsubsection{Transition Probability Estimation}
For both subsets, we model the session dynamics as a Markov Decision Process (MDP) and estimate the transition kernel $\mathcal{T}$. The probability of transitioning to state $s'$ given state $s$ is estimated via maximum likelihood: \subsection{Second-Stage Classification}
\begin{equation} After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure.
\hat{P}(s' \mid s) = \frac{N(s, s')}{\sum_{k \in \mathcal{S}} N(s, k)}
\end{equation}
where $N(s, s')$ is the count of observed transitions. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from the learned transition matrix $\hat{P}_A$ until the effective mixing ratio reaches $\alpha$.
\subsection{Distributionally Robust Reinforcement Learning (DR-RL)} \subsection{Distributionally Robust Reinforcement Learning (DR-RL)}
We formulate the pricing problem as a Stackelberg Game where the Platform (Leader) sets prices $p_t$ and the Aggregate Demand (Follower) responds. However, the exact mixing parameter $\alpha$ and the demand distribution shift are non-stationary and unknown in online settings. Relying on a simple error term $\epsilon$ is insufficient. Instead, we adopt a Distributionally Robust Optimization (DRO) objective. We formulate pricing as a Stackelberg game: the platform (leader) sets prices $p_t$, and the population (follower) responds through trajectories and demand. A useful intuition is that the platform behaves like a distorted mirror at a 45-degree angle: what it mirrors is population demand into an estimated demand proxy, and that proxy drives revenue.
Because contamination level $\alpha$ and demand shift are non-stationary online, a simple error term is not enough. We therefore use a Distributionally Robust Optimization objective. Let $\tau'$ be a newly observed trajectory generated by an unknown actor profile (sampled from the behavioral models in Section~\ref{sec:tpe}). We need a demand mapping conditioned on price and trajectory, $\hat{Q}(p,\tau')$. For each $\tau'$, we compute $\hat{\mathcal{T}}'$ and compare it with controlled baselines $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$:
\begin{align}
\label{eq:delta_H}
\Delta_H &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_H) \\
\label{eq:delta_A}
\Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A)
\end{align}
This yields two centroid-like heuristics that act as a session-level agent score in the engine. On a per-customer or use-case basis a similar study should be done in order to obtain ground truth behavior models for humans and agents and their specific interaction with a given products website.
In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move).
% Mention discretized action space and the clipping and over shotting in continuous action spaces
% Also talk about catastrophic economics, we add termination on bankrupcy or zero demand so market collaps
\subsubsection{Ambiguity Set Construction} \subsubsection{Ambiguity Set Construction}
We define an ambiguity set $\mathcal{U}_p(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: We define an ambiguity set $\mathcal{U}_\epsilon(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face:
\begin{equation} \begin{equation}
\mathcal{U}_\epsilon(\hat{P}_N) = \left\{ Q \in \mathcal{P}(\Xi) : W_p(Q, \hat{P}_N) \le \epsilon \right\} \mathcal{U}_\epsilon(\hat{P}_N) = \left\{ Q \in \mathcal{P}(\Xi) : W_p(Q, \hat{P}_N) \le \epsilon \right\}
\end{equation} \end{equation}
This set captures all distributions that are statistically close to our observed training data but allows for adversarial shifts (e.g., sudden bot spikes). This set captures all distributions that are statistically close to our observed training data but allows for adversarial shifts.
For the current engine baseline, we use a compact inner-robust approximation by applying ambiguity over contamination in a local interval around nominal contamination $\alpha_0$:
\begin{equation}
\mathcal{A}_{\epsilon_\alpha}(\alpha_0)=\left\{\alpha\in[0,1]:\lvert\alpha-\alpha_0\rvert\le\epsilon_\alpha\right\}
\end{equation}
and we evaluate a small fixed grid in $\mathcal{A}_{\epsilon_\alpha}(\alpha_0)$ per step, selecting the worst-case candidate for the learner.
% A proper Wasserstein ball implementation over the full demand distribution (rather than a scalar alpha interval) would use the POT library (Python Optimal Transport): compute W_2 between the empirical reference P_hat and each candidate Q using ot.emd2() or ot.sliced_wasserstein_distance() for scalability, then accept only candidates within epsilon. In practice the inner minimization becomes: candidates = [G(alpha) for alpha in linspace]; dists = [ot.emd2(p_hat, q, M) for q in candidates]; worst = candidates[argmin(reward[dists <= epsilon])]. The current grid-on-alpha approximation is a computationally cheap substitute; moving to a true Wasserstein ball would tighten the worst-case guarantee but requires specifying the ground metric M over the demand space.
\subsubsection{The Min-Max Objective} \subsubsection{The Min-Max Objective}
The robust policy $\pi^*$ is obtained by solving the maximin problem: The robust policy $\pi^*$ is obtained by solving the maximin problem:
\begin{equation} \begin{equation}
\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}(p) \right] \label{eq:robust_policy}
\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}_{\text{leak}}(p,\tau') \right]
\end{equation} \end{equation}
where $R(p, d)$ is the revenue function and $\lambda$ weighs the penalty for information leakage (COI). where $R(p, d)$ is the revenue function and $\lambda$ weighs the information-leakage penalty.
In practice, we parameterize this with a session-level leakage term:
\begin{equation}
\text{COI}_{\text{leak}}(p,\tau') = f(\tau')\cdot \text{InfoValue}(p,\tau')
\end{equation}
where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$.
For the baseline engine reported here, we intentionally use the constant query-tax surrogate to keep the mechanism minimal:
\begin{equation}
r_t = R(p_t,\tilde q_t) - \lambda\,f(\tau_t')\,c_{\text{info}}
\end{equation}
with fixed $c_{\text{info}}>0$.
Another possible extension is to adapt the ambiguity radius online, e.g., $\epsilon(\Delta_H)$, so the Wasserstein ball changes with live divergence. We keep this as future work and retain a fixed-radius setup because Wasserstein ambiguity already handles heavy-tail and ``black swan'' behavior without absolute continuity assumptions \parencite{kuhn_wasserstein_2024}.
\subsubsection{Actor Implementation} \subsubsection{Actor Implementation}
In our simulation, the "Follower" is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. In our simulation, the ``follower'' is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$.
Practical implementation of browser agents is a strongly evolving field with near-weekly releases of SOTA architectures. In this thesis implementation we abstract that layer into trajectory generators learned from observed human/agent transition kernels.
As part of our reward engineering we think about the UX factor ($UX \in [0,1]$) whic his our proxy for user experience degradation, this is computed as a mixture of contribution from the separability model metric of $\frac{1}{\text{Specificity}}$. As part of reward engineering, we keep a UX factor ($UX\in[0,1]$) as an auxiliary evaluation axis. In the current baseline it is not injected into the core reward; it is tracked separately to compare policy trade-offs.
\begin{figure}[ht] \begin{figure}[ht]
\centering \centering
@@ -240,12 +417,40 @@ As part of our reward engineering we think about the UX factor ($UX \in [0,1]$)
\caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-like scale.} \caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-like scale.}
\end{figure} \end{figure}
We also need to think about a policy like taxation to the agents Strategy-Proof Mechanism Design, specifically the Vickrey-Clarke-Groves (VCG) payment rule. We link and prove that this would create an incentive for the dominant strategy to become truth-telling. We also consider taxation-like overlays for agent traffic under strategy-proof mechanism design (e.g., Vickrey-Clarke-Groves style rules). This remains an extension path and is not part of the main implementation in this thesis.
\section{Heuristics as part of neuro-inspired steering systems} \subsubsection{Pricing Mechanism Summary}
Steve Burns, superior culliculus (face heuristics) we create this sort of part of the 'brain' + amortized inference. We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_loop_clean} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories.
We could say that a DQN for example is the learnin subsystem and then within our reward mechanism or some other computational method we introduce a steering subsystem which acts as the proposed ``pricing heuristic'' against the given non human transaction data. \begin{algorithm}[t]
\caption{PHANTOM defensive pricing loop}
\label{alg:phantom_loop_clean}
\DontPrintSemicolon
\SetKwInput{Input}{Input}
\SetKwInput{Output}{Output}
\section{Market construction} \Input{catalog size \(N\); action scale grid \(\mathcal{S}_{act}\); nominal contamination \(\alpha_0\); ambiguity radius \(\epsilon_\alpha\); candidate count \(K\); horizon \(T\); sessions per step \(M\); behavior kernels \(\bar T_H,\bar T_A\); event weights \(\omega\); COI penalty \(\lambda\)}
\Output{trajectory \(\{(p_t,\hat Q_t,\alpha_t^*)\}_{t=0}^{T-1}\)}
\For{\(t \leftarrow 0\) \KwTo \(T-1\)}{
observe \(o_t=[\hat Q_{t-1}, p_{t-1}]\)\;
choose discrete action \(a_t \in \{1,\dots,|\mathcal{S}_{act}|\}\) from policy \(\pi\)\;
set \(p_t \leftarrow \mathrm{clip}(p_{t-1} \cdot \mathcal{S}_{act}[a_t])\)\;
define local ambiguity interval \(\mathcal{A}_{\epsilon_\alpha}(\alpha_0)=\{\alpha:\lvert\alpha-\alpha_0\rvert\le\epsilon_\alpha\}\)\;
\For{\(k \leftarrow 1\) \KwTo \(K\)}{
set \(\alpha_k \in \mathcal{A}_{\epsilon_\alpha}(\alpha_0)\) from a uniform grid\;
sample \(M\) sessions from mixture \((1-\alpha_k)\bar T_H + \alpha_k \bar T_A\)\;
compute demand proxy \(\hat Q_t^{(k)} = \sum_{m=1}^{M}\sum_j \omega(a_{m,j})\,\mathbf{1}[i_{m,j}=i]\)\;
compute \((\Delta_H^{(k)},\Delta_A^{(k)})\) and session score \(f_t^{(k)}\) from KL divergence\;
compute candidate reward \(r_t^{(k)} = R(p_t,\hat Q_t^{(k)}) - \lambda\,f_t^{(k)}\,c_{info}\)\;
}
choose \(k^* \leftarrow \arg\min_k r_t^{(k)}\), set \(\alpha_t^* \leftarrow \alpha_{k^*}\)\;
set \(\hat Q_t \leftarrow \hat Q_t^{(k^*)}\), \(r_t \leftarrow r_t^{(k^*)}\)\;
}
\end{algorithm}
The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform applies one discrete multiplicative price action, the environment samples a batch of sessions, and demand is recomputed from weighted events. Robustness is implemented as an inner minimization over a small local grid of contamination candidates around nominal $\alpha_0$, matching the current engine implementation. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations.
%The defensive price update in Line 24 implements contamination-aware margin shrinkage: as estimated contamination $\hat{\alpha}_t$ rises, the margin $(p^{\mathrm{ref}} - c)$ is reduced by factor $\kappa\in[0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring feasibility. In subsequent experiments this heuristic rule is replaced by DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}.

View File

@@ -1,4 +1,10 @@
\section{Results} \section{Results}
\begin{figure}[ht]
\centering
\input{chapters/figures/supra.tex}
\caption{Evolution of price distributions over experiment steps. The heatmap illustrates the density of price offerings. This is an early baseline simulation which demonstrates supra-competitive price-setting in deep learning agents such as SAC as can be clearly seen by the high density at the highest available price.}
\label{fig:supra_heatmap}
\end{figure}
\subsection{Behavioral Analysis} \subsection{Behavioral Analysis}

View File

@@ -0,0 +1,131 @@
import pandas as pd
import json
import numpy as np
import sys
import os
def process_supra(input_file, output_file):
print(f"Processing {input_file} -> {output_file}")
# Read the CSV
try:
# The CSV has a weird format: "Step","giddy-deluge-6 - distributions/prices"
# The header is on line 1.
# Let's verify the file content format first effectively.
# The previous read showed standard CSV with quoted fields.
df = pd.read_csv(input_file, quotechar='"', skipinitialspace=True)
except Exception as e:
print(f"Error reading CSV: {e}")
return
# Prepare for re-binning
# We need a common set of bins to plot a heatmap (surface)
# First, let's collect all data to determine range
all_min = float("inf")
all_max = float("-inf")
parsed_data = []
# The column names might be dynamic, so let's rely on indices
# Column 0: Step
# Column 1: JSON blob
for index, row in df.iterrows():
try:
step = int(row.iloc[0])
json_str = row.iloc[1]
# Cleaning potential double quotes issue if pandas didn't catch it perfect
# but pandas read_csv usually handles standard CSV escaping well.
data = json.loads(json_str)
bins = np.array(data["bins"])
values = np.array(data["values"])
# Update global range
if bins.min() < all_min:
all_min = bins.min()
if bins.max() > all_max:
all_max = bins.max()
parsed_data.append({"step": step, "bins": bins, "values": values})
except Exception as e:
print(f"Skipping row {index} due to error: {e}")
continue
if not parsed_data:
print("No data parsed.")
return
print(f"Found {len(parsed_data)} steps. Range: {all_min} to {all_max}")
# Define common grid
# Y-axis (Price)
# Using 100 bins for resolution
y_bins_edges = np.linspace(all_min, all_max, 101)
y_bin_centers = (y_bins_edges[:-1] + y_bins_edges[1:]) / 2
# Open output file
with open(output_file, "w") as f:
# PGFPlots 3D format often prefers no header or a specific header.
# We will use named columns.
f.write("step,price,density\n")
# Sort by step to ensure correct mesh ordering
parsed_data.sort(key=lambda x: x["step"])
for item in parsed_data:
step = item["step"]
original_bins = item["bins"]
original_values = item["values"]
# Re-binning logic
current_new_hist = np.zeros(len(y_bin_centers))
for i, (new_start, new_end) in enumerate(
zip(y_bins_edges[:-1], y_bins_edges[1:])
):
val = 0.0
# This inner loop is slightly inefficient O(N*M) but N~3000, M~100 -> 300k ops, totally fine.
for j in range(len(original_values)):
b_start = original_bins[j]
# Handle cases where values array might be 1 shorter than bins (histogram edges vs centers)
# The provided JSON has "bins" array larger than "values" by 1 usually for edges.
if j + 1 >= len(original_bins):
break
b_end = original_bins[j + 1]
b_width = b_end - b_start
if b_width <= 0:
continue
# Calculate overlap
overlap_start = max(new_start, b_start)
overlap_end = min(new_end, b_end)
overlap = max(0, overlap_end - overlap_start)
if overlap > 0:
# Add proportional count
val += original_values[j] * (overlap / b_width)
current_new_hist[i] = val
# Write row to file for this step
for price, density in zip(y_bin_centers, current_new_hist):
# PGFPlots expects x y z
f.write(f"{step},{price},{density}\n")
# Add a blank line for PGFPlots matrix format (essential for 'mesh' or 'surf')
f.write("\n")
if __name__ == "__main__":
# Resolve relative paths relative to where script is run, or use absolute
base_dir = os.path.dirname(os.path.abspath(__file__))
input_path = os.path.join(base_dir, "supra.csv")
output_path = os.path.join(base_dir, "supra_data.csv")
process_supra(input_path, output_path)

View File

@@ -0,0 +1,41 @@
"Step","giddy-deluge-6 - distributions/prices"
"100","{""values"":[2,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1],""_type"":""histogram"",""bins"":[15.76888656616211,17.813893377780914,19.85890018939972,21.903907001018524,23.94891381263733,25.993920624256134,28.03892743587494,30.083934247493744,32.12894105911255,34.173947870731354,36.21895468235016,38.263961493968964,40.30896830558777,42.35397511720657,44.39898192882538,46.44398874044418,48.48899555206299,50.53400236368179,52.5790091753006,54.6240159869194,56.66902279853821,58.71402961015701,60.75903642177582,62.80404323339462,64.84905004501343,66.89405685663223,68.93906366825104,70.98407047986984,73.02907729148865,75.07408410310745,77.11909091472626,79.16409772634506,81.20910453796387,83.25411134958267,85.29911816120148,87.34412497282028,89.38913178443909,91.43413859605789,93.4791454076767,95.5241522192955,97.5691590309143,99.61416584253311,101.65917265415192,103.70417946577072,105.74918627738953,107.79419308900833,109.83919990062714,111.88420671224594,113.92921352386475,115.97422033548355,118.01922714710236,120.06423395872116,122.10924077033997,124.15424758195877,126.19925439357758,128.24426120519638,130.28926801681519,132.334274828434,134.3792816400528,136.4242884516716,138.4692952632904,140.5143020749092,142.55930888652802,144.60431569814682,146.64932250976562]}"
"200","{""_type"":""histogram"",""values"":[1,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,3],""bins"":[10.439504623413086,12.620137363672256,14.800770103931427,16.981402844190598,19.162035584449768,21.34266832470894,23.52330106496811,25.70393380522728,27.88456654548645,30.06519928574562,32.24583202600479,34.42646476626396,36.60709750652313,38.7877302467823,40.96836298704147,43.148995727300644,45.329628467559814,47.510261207818985,49.690893948078156,51.871526688337326,54.0521594285965,56.23279216885567,58.41342490911484,60.59405764937401,62.77469038963318,64.95532312989235,67.13595587015152,69.31658861041069,71.49722135066986,73.67785409092903,75.8584868311882,78.03911957144737,80.21975231170654,82.40038505196571,84.58101779222488,86.76165053248405,88.94228327274323,91.1229160130024,93.30354875326157,95.48418149352074,97.66481423377991,99.84544697403908,102.02607971429825,104.20671245455742,106.38734519481659,108.56797793507576,110.74861067533493,112.9292434155941,115.10987615585327,117.29050889611244,119.47114163637161,121.65177437663078,123.83240711688995,126.01303985714912,128.1936725974083,130.37430533766747,132.55493807792664,134.7355708181858,136.91620355844498,139.09683629870415,141.27746903896332,143.4581017792225,145.63873451948166,147.81936725974083,150]}"
"300","{""bins"":[92.91828918457031,93.81018829345703,94.70209503173828,95.593994140625,96.48589324951172,97.37779998779297,98.26969909667969,99.1615982055664,100.05350494384766,100.94540405273438,101.83731079101562,102.72920989990234,103.62110900878906,104.51301574707031,105.40491485595703,106.29681396484375,107.188720703125,108.08061981201172,108.97251892089844,109.86442565917969,110.7563247680664,111.64822387695312,112.54013061523438,113.4320297241211,114.32392883300781,115.21583557128906,116.10773468017578,116.9996337890625,117.89154052734375,118.78343963623047,119.67533874511719,120.56724548339844,121.45914459228516,122.35104370117188,123.24295043945312,124.13484954833984,125.02674865722656,125.91865539550781,126.81055450439453,127.70245361328125,128.5943603515625,129.48626708984375,130.37815856933594,131.2700653076172,132.16195678710938,133.05386352539062,133.94577026367188,134.83767700195312,135.7295684814453,136.62147521972656,137.51336669921875,138.4052734375,139.29718017578125,140.1890869140625,141.0809783935547,141.97288513183594,142.86477661132812,143.75668334960938,144.64859008789062,145.54049682617188,146.43238830566406,147.3242950439453,148.21620178222656,149.10809326171875,150],""_type"":""histogram"",""values"":[1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,1,0,3]}"
"400","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,3,0,0,0,3],""bins"":[141.8555450439453,141.98280334472656,142.1100616455078,142.23731994628906,142.3645782470703,142.49183654785156,142.6190948486328,142.746337890625,142.87359619140625,143.0008544921875,143.12811279296875,143.25537109375,143.38262939453125,143.5098876953125,143.63714599609375,143.764404296875,143.89166259765625,144.0189208984375,144.14617919921875,144.2734375,144.4006805419922,144.52793884277344,144.6551971435547,144.78245544433594,144.9097137451172,145.03697204589844,145.1642303466797,145.29148864746094,145.4187469482422,145.54600524902344,145.6732635498047,145.80052185058594,145.92776489257812,146.05502319335938,146.18228149414062,146.30953979492188,146.43679809570312,146.56405639648438,146.69131469726562,146.81857299804688,146.94583129882812,147.07308959960938,147.20034790039062,147.32760620117188,147.45486450195312,147.58212280273438,147.70936584472656,147.8366241455078,147.96388244628906,148.0911407470703,148.21839904785156,148.3456573486328,148.47291564941406,148.6001739501953,148.72743225097656,148.8546905517578,148.98194885253906,149.1092071533203,149.2364501953125,149.36370849609375,149.490966796875,149.61822509765625,149.7454833984375,149.87274169921875,150],""_type"":""histogram""}"
"500","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,0,3],""bins"":[142.30267333984375,142.42294311523438,142.543212890625,142.66348266601562,142.78375244140625,142.90402221679688,143.0242919921875,143.14456176757812,143.26483154296875,143.38511657714844,143.50538635253906,143.6256561279297,143.7459259033203,143.86619567871094,143.98646545410156,144.1067352294922,144.2270050048828,144.34727478027344,144.46754455566406,144.5878143310547,144.7080841064453,144.82835388183594,144.94862365722656,145.0688934326172,145.18917846679688,145.3094482421875,145.42971801757812,145.54998779296875,145.67025756835938,145.79052734375,145.91079711914062,146.03106689453125,146.15133666992188,146.2716064453125,146.39187622070312,146.51214599609375,146.63241577148438,146.752685546875,146.87295532226562,146.99322509765625,147.11349487304688,147.23377990722656,147.3540496826172,147.4743194580078,147.59458923339844,147.71485900878906,147.8351287841797,147.9553985595703,148.07566833496094,148.19593811035156,148.3162078857422,148.4364776611328,148.55674743652344,148.67701721191406,148.7972869873047,148.9175567626953,149.037841796875,149.15811157226562,149.27838134765625,149.39865112304688,149.5189208984375,149.63919067382812,149.75946044921875,149.87973022460938,150]}"
"600","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,1,0,0,0,0,1,0,1,0,0,0,0,3],""bins"":[143.02142333984375,143.13046264648438,143.239501953125,143.34854125976562,143.45758056640625,143.56661987304688,143.6756591796875,143.78469848632812,143.89373779296875,144.00279235839844,144.11183166503906,144.2208709716797,144.3299102783203,144.43894958496094,144.54798889160156,144.6570281982422,144.7660675048828,144.87510681152344,144.98414611816406,145.0931854248047,145.2022247314453,145.31126403808594,145.42030334472656,145.5293426513672,145.63839721679688,145.7474365234375,145.85647583007812,145.96551513671875,146.07455444335938,146.18359375,146.29263305664062,146.40167236328125,146.51071166992188,146.6197509765625,146.72879028320312,146.83782958984375,146.94686889648438,147.055908203125,147.16494750976562,147.27398681640625,147.38302612304688,147.49208068847656,147.6011199951172,147.7101593017578,147.81919860839844,147.92823791503906,148.0372772216797,148.1463165283203,148.25535583496094,148.36439514160156,148.4734344482422,148.5824737548828,148.69151306152344,148.80055236816406,148.9095916748047,149.0186309814453,149.127685546875,149.23672485351562,149.34576416015625,149.45480346679688,149.5638427734375,149.67288208007812,149.78192138671875,149.89096069335938,150],""_type"":""histogram""}"
"700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,1,3],""bins"":[147.6887969970703,147.72491455078125,147.76101684570312,147.79713439941406,147.833251953125,147.86935424804688,147.9054718017578,147.94158935546875,147.97769165039062,148.01380920410156,148.0499267578125,148.08602905273438,148.1221466064453,148.15826416015625,148.19436645507812,148.23048400878906,148.2666015625,148.30270385742188,148.3388214111328,148.37493896484375,148.41104125976562,148.44715881347656,148.4832763671875,148.51937866210938,148.5554962158203,148.59161376953125,148.62771606445312,148.66383361816406,148.699951171875,148.73605346679688,148.7721710205078,148.80828857421875,148.84439086914062,148.88050842285156,148.9166259765625,148.95274353027344,148.9888458251953,149.02496337890625,149.0610809326172,149.09718322753906,149.13330078125,149.16941833496094,149.2055206298828,149.24163818359375,149.2777557373047,149.31385803222656,149.3499755859375,149.38609313964844,149.4221954345703,149.45831298828125,149.4944305419922,149.53053283691406,149.566650390625,149.60276794433594,149.6388702392578,149.67498779296875,149.7111053466797,149.74720764160156,149.7833251953125,149.81944274902344,149.8555450439453,149.89166259765625,149.9277801513672,149.96388244628906,150]}"
"800","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,3],""bins"":[149.21865844726562,149.23086547851562,149.24307250976562,149.25527954101562,149.26748657226562,149.27969360351562,149.2919158935547,149.3041229248047,149.3163299560547,149.3285369873047,149.3407440185547,149.3529510498047,149.3651580810547,149.3773651123047,149.3895721435547,149.4017791748047,149.41400146484375,149.42620849609375,149.43841552734375,149.45062255859375,149.46282958984375,149.47503662109375,149.48724365234375,149.49945068359375,149.51165771484375,149.52386474609375,149.53607177734375,149.5482940673828,149.5605010986328,149.5727081298828,149.5849151611328,149.5971221923828,149.6093292236328,149.6215362548828,149.6337432861328,149.6459503173828,149.6581573486328,149.6703643798828,149.68258666992188,149.69479370117188,149.70700073242188,149.71920776367188,149.73141479492188,149.74362182617188,149.75582885742188,149.76803588867188,149.78024291992188,149.79244995117188,149.80465698242188,149.81687927246094,149.82908630371094,149.84129333496094,149.85350036621094,149.86570739746094,149.87791442871094,149.89012145996094,149.90232849121094,149.91453552246094,149.92674255371094,149.93896484375,149.951171875,149.96337890625,149.9755859375,149.98779296875,150]}"
"900","{""_type"":""histogram"",""values"":[1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,3],""bins"":[149.2405548095703,149.25242614746094,149.2642822265625,149.27615356445312,149.28802490234375,149.2998809814453,149.31175231933594,149.32362365722656,149.33547973632812,149.34735107421875,149.35922241210938,149.37107849121094,149.38294982910156,149.3948211669922,149.40667724609375,149.41854858398438,149.430419921875,149.44227600097656,149.4541473388672,149.4660186767578,149.47787475585938,149.48974609375,149.50161743164062,149.5134735107422,149.5253448486328,149.53721618652344,149.549072265625,149.56094360351562,149.57281494140625,149.5846710205078,149.59654235839844,149.60841369628906,149.62026977539062,149.63214111328125,149.64401245117188,149.6558837890625,149.66773986816406,149.6796112060547,149.6914825439453,149.70333862304688,149.7152099609375,149.72708129882812,149.7389373779297,149.7508087158203,149.76268005371094,149.7745361328125,149.78640747070312,149.79827880859375,149.8101348876953,149.82200622558594,149.83387756347656,149.84573364257812,149.85760498046875,149.86947631835938,149.88133239746094,149.89320373535156,149.9050750732422,149.91693115234375,149.92880249023438,149.940673828125,149.95252990722656,149.9644012451172,149.9762725830078,149.98812866210938,150]}"
"1000","{""bins"":[149.04977416992188,149.0646209716797,149.0794677734375,149.0943145751953,149.10916137695312,149.12400817871094,149.13885498046875,149.15370178222656,149.16854858398438,149.1833953857422,149.1982421875,149.2130889892578,149.22793579101562,149.24278259277344,149.25762939453125,149.27247619628906,149.28732299804688,149.30218505859375,149.31703186035156,149.33187866210938,149.3467254638672,149.361572265625,149.3764190673828,149.39126586914062,149.40611267089844,149.42095947265625,149.43580627441406,149.45065307617188,149.4654998779297,149.4803466796875,149.4951934814453,149.51004028320312,149.52488708496094,149.53973388671875,149.55458068847656,149.56942749023438,149.5842742919922,149.59912109375,149.6139678955078,149.62881469726562,149.64366149902344,149.65850830078125,149.67335510253906,149.68820190429688,149.7030487060547,149.7178955078125,149.7327423095703,149.74758911132812,149.762451171875,149.7772979736328,149.79214477539062,149.80699157714844,149.82183837890625,149.83668518066406,149.85153198242188,149.8663787841797,149.8812255859375,149.8960723876953,149.91091918945312,149.92576599121094,149.94061279296875,149.95545959472656,149.97030639648438,149.9851531982422,150],""_type"":""histogram"",""values"":[1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,3]}"
"1100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,0,0,0,0,1,0,0,0,0,3],""bins"":[149.34432983398438,149.3545684814453,149.3648223876953,149.37506103515625,149.38531494140625,149.3955535888672,149.40579223632812,149.41604614257812,149.42628479003906,149.43653869628906,149.44677734375,149.45701599121094,149.46726989746094,149.47750854492188,149.48776245117188,149.4980010986328,149.50823974609375,149.51849365234375,149.5287322998047,149.5389862060547,149.54922485351562,149.55947875976562,149.56971740722656,149.5799560546875,149.5902099609375,149.60044860839844,149.61070251464844,149.62094116210938,149.6311798095703,149.6414337158203,149.65167236328125,149.66192626953125,149.6721649169922,149.68240356445312,149.69265747070312,149.70289611816406,149.71315002441406,149.723388671875,149.73362731933594,149.74388122558594,149.75411987304688,149.76437377929688,149.7746124267578,149.78485107421875,149.79510498046875,149.8053436279297,149.8155975341797,149.82583618164062,149.83609008789062,149.84632873535156,149.8565673828125,149.8668212890625,149.87705993652344,149.88731384277344,149.89755249023438,149.9077911376953,149.9180450439453,149.92828369140625,149.93853759765625,149.9487762451172,149.95901489257812,149.96926879882812,149.97950744628906,149.98976135253906,150]}"
"1200","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,3],""bins"":[148.93704223632812,148.95364379882812,148.9702606201172,148.9868621826172,149.00347900390625,149.02008056640625,149.0366973876953,149.0532989501953,149.06991577148438,149.08651733398438,149.10313415527344,149.11973571777344,149.1363525390625,149.1529541015625,149.16957092285156,149.18617248535156,149.20278930664062,149.21939086914062,149.23599243164062,149.2526092529297,149.2692108154297,149.28582763671875,149.30242919921875,149.3190460205078,149.3356475830078,149.35226440429688,149.36886596679688,149.38548278808594,149.40208435058594,149.418701171875,149.435302734375,149.45191955566406,149.46852111816406,149.48512268066406,149.50173950195312,149.51834106445312,149.5349578857422,149.5515594482422,149.56817626953125,149.58477783203125,149.6013946533203,149.6179962158203,149.63461303710938,149.65121459960938,149.66783142089844,149.68443298339844,149.7010498046875,149.7176513671875,149.7342529296875,149.75086975097656,149.76747131347656,149.78408813476562,149.80068969726562,149.8173065185547,149.8339080810547,149.85052490234375,149.86712646484375,149.8837432861328,149.9003448486328,149.91696166992188,149.93356323242188,149.95018005371094,149.96678161621094,149.9833984375,150],""_type"":""histogram""}"
"1300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,3],""bins"":[149.0462646484375,149.06117248535156,149.07606506347656,149.09097290039062,149.10586547851562,149.1207733154297,149.13568115234375,149.15057373046875,149.1654815673828,149.18038940429688,149.19528198242188,149.21018981933594,149.22509765625,149.239990234375,149.25489807128906,149.26979064941406,149.28469848632812,149.2996063232422,149.3144989013672,149.32940673828125,149.34429931640625,149.3592071533203,149.37411499023438,149.38900756835938,149.40391540527344,149.4188232421875,149.4337158203125,149.44862365722656,149.46353149414062,149.47842407226562,149.4933319091797,149.5082244873047,149.52313232421875,149.5380401611328,149.5529327392578,149.56784057617188,149.58273315429688,149.59764099121094,149.612548828125,149.62744140625,149.64234924316406,149.65725708007812,149.67214965820312,149.6870574951172,149.70196533203125,149.71685791015625,149.7317657470703,149.7466583251953,149.76156616210938,149.77647399902344,149.79136657714844,149.8062744140625,149.8211669921875,149.83607482910156,149.85098266601562,149.86587524414062,149.8807830810547,149.89569091796875,149.91058349609375,149.9254913330078,149.94039916992188,149.95529174804688,149.97019958496094,149.98509216308594,150]}"
"1400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,4],""bins"":[148.76895141601562,148.78819274902344,148.8074188232422,148.82666015625,148.84588623046875,148.86512756347656,148.88436889648438,148.90359497070312,148.92283630371094,148.9420623779297,148.9613037109375,148.9805450439453,148.99977111816406,149.01901245117188,149.03823852539062,149.05747985839844,149.07672119140625,149.095947265625,149.1151885986328,149.13441467285156,149.15365600585938,149.17288208007812,149.19212341308594,149.21136474609375,149.2305908203125,149.2498321533203,149.26905822753906,149.28829956054688,149.3075408935547,149.32676696777344,149.34600830078125,149.365234375,149.3844757080078,149.40371704101562,149.42294311523438,149.4421844482422,149.46141052246094,149.48065185546875,149.49989318847656,149.5191192626953,149.53836059570312,149.55758666992188,149.5768280029297,149.5960693359375,149.61529541015625,149.63453674316406,149.6537628173828,149.67300415039062,149.69223022460938,149.7114715576172,149.730712890625,149.74993896484375,149.76918029785156,149.7884063720703,149.80764770507812,149.82688903808594,149.8461151123047,149.8653564453125,149.88458251953125,149.90382385253906,149.92306518554688,149.94229125976562,149.96153259277344,149.9807586669922,150]}"
"1500","{""bins"":[149.55426025390625,149.56121826171875,149.5681915283203,149.5751495361328,149.58212280273438,149.58908081054688,149.59605407714844,149.60301208496094,149.6099853515625,149.616943359375,149.6239013671875,149.63087463378906,149.63783264160156,149.64480590820312,149.65176391601562,149.6587371826172,149.6656951904297,149.6726531982422,149.67962646484375,149.68658447265625,149.6935577392578,149.7005157470703,149.70748901367188,149.71444702148438,149.72140502929688,149.72837829589844,149.73533630371094,149.7423095703125,149.749267578125,149.75624084472656,149.76319885253906,149.77017211914062,149.77713012695312,149.78408813476562,149.7910614013672,149.7980194091797,149.80499267578125,149.81195068359375,149.8189239501953,149.8258819580078,149.83285522460938,149.83981323242188,149.84677124023438,149.85374450683594,149.86070251464844,149.86767578125,149.8746337890625,149.88160705566406,149.88856506347656,149.89552307128906,149.90249633789062,149.90945434570312,149.9164276123047,149.9233856201172,149.93035888671875,149.93731689453125,149.94427490234375,149.9512481689453,149.9582061767578,149.96517944335938,149.97213745117188,149.97911071777344,149.98606872558594,149.9930419921875,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}"
"1600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,3],""bins"":[149.17001342773438,149.1829833984375,149.19595336914062,149.20892333984375,149.22189331054688,149.23486328125,149.24781799316406,149.2607879638672,149.2737579345703,149.28672790527344,149.29969787597656,149.3126678466797,149.3256378173828,149.33860778808594,149.35157775878906,149.3645477294922,149.37750244140625,149.39047241210938,149.4034423828125,149.41641235351562,149.42938232421875,149.44235229492188,149.455322265625,149.46829223632812,149.48126220703125,149.49423217773438,149.5072021484375,149.52015686035156,149.5331268310547,149.5460968017578,149.55906677246094,149.57203674316406,149.5850067138672,149.5979766845703,149.61094665527344,149.62391662597656,149.6368865966797,149.6498565673828,149.66281127929688,149.67578125,149.68875122070312,149.70172119140625,149.71469116210938,149.7276611328125,149.74063110351562,149.75360107421875,149.76657104492188,149.779541015625,149.79251098632812,149.8054656982422,149.8184356689453,149.83140563964844,149.84437561035156,149.8573455810547,149.8703155517578,149.88328552246094,149.89625549316406,149.9092254638672,149.9221954345703,149.93515014648438,149.9481201171875,149.96109008789062,149.97406005859375,149.98703002929688,150]}"
"1700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,3],""bins"":[149.36141967773438,149.37139892578125,149.38137817382812,149.391357421875,149.40133666992188,149.41131591796875,149.42127990722656,149.43125915527344,149.4412384033203,149.4512176513672,149.46119689941406,149.47117614746094,149.4811553955078,149.4911346435547,149.50111389160156,149.51109313964844,149.52105712890625,149.53103637695312,149.541015625,149.55099487304688,149.56097412109375,149.57095336914062,149.5809326171875,149.59091186523438,149.60089111328125,149.61087036132812,149.620849609375,149.6308135986328,149.6407928466797,149.65077209472656,149.66075134277344,149.6707305908203,149.6807098388672,149.69068908691406,149.70066833496094,149.7106475830078,149.7206268310547,149.73060607910156,149.74057006835938,149.75054931640625,149.76052856445312,149.7705078125,149.78048706054688,149.79046630859375,149.80044555664062,149.8104248046875,149.82040405273438,149.83038330078125,149.84036254882812,149.85032653808594,149.8603057861328,149.8702850341797,149.88026428222656,149.89024353027344,149.9002227783203,149.9102020263672,149.92018127441406,149.93016052246094,149.9401397705078,149.95010375976562,149.9600830078125,149.97006225585938,149.98004150390625,149.99002075195312,150]}"
"1800","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,3],""bins"":[149.42300415039062,149.43202209472656,149.4410400390625,149.45005798339844,149.4590606689453,149.46807861328125,149.4770965576172,149.48611450195312,149.49513244628906,149.504150390625,149.51315307617188,149.5221710205078,149.53118896484375,149.5402069091797,149.54922485351562,149.55824279785156,149.5672607421875,149.57626342773438,149.5852813720703,149.59429931640625,149.6033172607422,149.61233520507812,149.62135314941406,149.63035583496094,149.63937377929688,149.6483917236328,149.65740966796875,149.6664276123047,149.67544555664062,149.6844482421875,149.69346618652344,149.70248413085938,149.7115020751953,149.72052001953125,149.7295379638672,149.73855590820312,149.74755859375,149.75657653808594,149.76559448242188,149.7746124267578,149.78363037109375,149.7926483154297,149.80165100097656,149.8106689453125,149.81968688964844,149.82870483398438,149.8377227783203,149.84674072265625,149.85574340820312,149.86476135253906,149.873779296875,149.88279724121094,149.89181518554688,149.9008331298828,149.90985107421875,149.91885375976562,149.92787170410156,149.9368896484375,149.94590759277344,149.95492553710938,149.9639434814453,149.9729461669922,149.98196411132812,149.99098205566406,150],""_type"":""histogram""}"
"1900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1,0,0,3],""bins"":[148.7623748779297,148.78170776367188,148.80105590820312,148.8203887939453,148.8397216796875,148.85906982421875,148.87840270996094,148.89773559570312,148.91708374023438,148.93641662597656,148.95574951171875,148.97509765625,148.9944305419922,149.01376342773438,149.03311157226562,149.0524444580078,149.07177734375,149.09112548828125,149.11045837402344,149.12979125976562,149.14913940429688,149.16847229003906,149.18780517578125,149.2071533203125,149.2264862060547,149.24581909179688,149.26516723632812,149.2845001220703,149.3038330078125,149.32318115234375,149.34251403808594,149.36184692382812,149.38119506835938,149.40052795410156,149.41986083984375,149.43919372558594,149.4585418701172,149.47787475585938,149.49720764160156,149.5165557861328,149.535888671875,149.5552215576172,149.57456970214844,149.59390258789062,149.6132354736328,149.63258361816406,149.65191650390625,149.67124938964844,149.6905975341797,149.70993041992188,149.72926330566406,149.7486114501953,149.7679443359375,149.7872772216797,149.80662536621094,149.82595825195312,149.8452911376953,149.86463928222656,149.88397216796875,149.90330505371094,149.9226531982422,149.94198608398438,149.96131896972656,149.9806671142578,150]}"
"2000","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,1,0,0,0,0,1,0,0,0,0,0,1,3],""bins"":[149.5408477783203,149.5480194091797,149.55519104003906,149.5623779296875,149.56954956054688,149.57672119140625,149.58389282226562,149.591064453125,149.59823608398438,149.6054229736328,149.6125946044922,149.61976623535156,149.62693786621094,149.6341094970703,149.6412811279297,149.64846801757812,149.6556396484375,149.66281127929688,149.66998291015625,149.67715454101562,149.684326171875,149.69151306152344,149.6986846923828,149.7058563232422,149.71302795410156,149.72019958496094,149.7273712158203,149.73455810546875,149.74172973632812,149.7489013671875,149.75607299804688,149.76324462890625,149.77041625976562,149.77760314941406,149.78477478027344,149.7919464111328,149.7991180419922,149.80628967285156,149.8134765625,149.82064819335938,149.82781982421875,149.83499145507812,149.8421630859375,149.84933471679688,149.8565216064453,149.8636932373047,149.87086486816406,149.87803649902344,149.8852081298828,149.8923797607422,149.89956665039062,149.90673828125,149.91390991210938,149.92108154296875,149.92825317382812,149.9354248046875,149.94261169433594,149.9497833251953,149.9569549560547,149.96412658691406,149.97129821777344,149.9784698486328,149.98565673828125,149.99282836914062,150]}"
"2100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,3],""bins"":[149.5018310546875,149.50961303710938,149.51739501953125,149.52517700195312,149.532958984375,149.54075622558594,149.5485382080078,149.5563201904297,149.56410217285156,149.57188415527344,149.5796661376953,149.5874481201172,149.59524536132812,149.60302734375,149.61080932617188,149.61859130859375,149.62637329101562,149.6341552734375,149.64193725585938,149.64971923828125,149.65750122070312,149.66529846191406,149.67308044433594,149.6808624267578,149.6886444091797,149.69642639160156,149.70420837402344,149.7119903564453,149.71978759765625,149.72756958007812,149.7353515625,149.74313354492188,149.75091552734375,149.75869750976562,149.7664794921875,149.77426147460938,149.78204345703125,149.7898406982422,149.79762268066406,149.80540466308594,149.8131866455078,149.8209686279297,149.82875061035156,149.83653259277344,149.84432983398438,149.85211181640625,149.85989379882812,149.86767578125,149.87545776367188,149.88323974609375,149.89102172851562,149.8988037109375,149.90658569335938,149.9143829345703,149.9221649169922,149.92994689941406,149.93772888183594,149.9455108642578,149.9532928466797,149.96107482910156,149.9688720703125,149.97665405273438,149.98443603515625,149.99221801757812,150]}"
"2200","{""bins"":[149.558349609375,149.56524658203125,149.5721435546875,149.5790557861328,149.58595275878906,149.5928497314453,149.59976196289062,149.60665893554688,149.61355590820312,149.62045288085938,149.62734985351562,149.63426208496094,149.6411590576172,149.64805603027344,149.65496826171875,149.661865234375,149.66876220703125,149.6756591796875,149.68255615234375,149.68946838378906,149.6963653564453,149.70326232910156,149.71017456054688,149.71707153320312,149.72396850585938,149.73086547851562,149.73776245117188,149.7446746826172,149.75157165527344,149.7584686279297,149.765380859375,149.77227783203125,149.7791748046875,149.78607177734375,149.79296875,149.7998809814453,149.80677795410156,149.8136749267578,149.82058715820312,149.82748413085938,149.83438110351562,149.84127807617188,149.84817504882812,149.85508728027344,149.8619842529297,149.86888122558594,149.87579345703125,149.8826904296875,149.88958740234375,149.896484375,149.90338134765625,149.91029357910156,149.9171905517578,149.92408752441406,149.93099975585938,149.93789672851562,149.94479370117188,149.95169067382812,149.95858764648438,149.9654998779297,149.97239685058594,149.9792938232422,149.9862060546875,149.99310302734375,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,2,1,1,0,0,0,3]}"
"2300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,2,1,0,0,3],""bins"":[149.1126251220703,149.12649536132812,149.14035034179688,149.1542205810547,149.1680908203125,149.18194580078125,149.19581604003906,149.20968627929688,149.22354125976562,149.23741149902344,149.25128173828125,149.26513671875,149.2790069580078,149.29287719726562,149.30673217773438,149.3206024169922,149.33447265625,149.34832763671875,149.36219787597656,149.37606811523438,149.38992309570312,149.40379333496094,149.41766357421875,149.4315185546875,149.4453887939453,149.45925903320312,149.47311401367188,149.4869842529297,149.5008544921875,149.51470947265625,149.52857971191406,149.54244995117188,149.55630493164062,149.57017517089844,149.58404541015625,149.59791564941406,149.6117706298828,149.62564086914062,149.63951110839844,149.6533660888672,149.667236328125,149.6811065673828,149.69496154785156,149.70883178710938,149.7227020263672,149.73655700683594,149.75042724609375,149.76429748535156,149.7781524658203,149.79202270507812,149.80589294433594,149.8197479248047,149.8336181640625,149.8474884033203,149.86134338378906,149.87521362304688,149.8890838623047,149.90293884277344,149.91680908203125,149.93067932128906,149.9445343017578,149.95840454101562,149.97227478027344,149.9861297607422,150]}"
"2400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,2,0,0,0,0,0,0,0,0,0,3],""bins"":[149.71124267578125,149.71575927734375,149.7202606201172,149.7247772216797,149.7292938232422,149.73379516601562,149.73831176757812,149.74282836914062,149.74734497070312,149.75184631347656,149.75636291503906,149.76087951660156,149.765380859375,149.7698974609375,149.7744140625,149.77891540527344,149.78343200683594,149.78794860839844,149.79244995117188,149.79696655273438,149.80148315429688,149.8059844970703,149.8105010986328,149.8150177001953,149.81951904296875,149.82403564453125,149.82855224609375,149.83306884765625,149.8375701904297,149.8420867919922,149.8466033935547,149.85110473632812,149.85562133789062,149.86013793945312,149.86463928222656,149.86915588378906,149.87367248535156,149.878173828125,149.8826904296875,149.88720703125,149.8917236328125,149.89622497558594,149.90074157714844,149.90525817871094,149.90975952148438,149.91427612304688,149.91879272460938,149.9232940673828,149.9278106689453,149.9323272705078,149.93682861328125,149.94134521484375,149.94586181640625,149.9503631591797,149.9548797607422,149.9593963623047,149.96389770507812,149.96841430664062,149.97293090820312,149.97744750976562,149.98194885253906,149.98646545410156,149.99098205566406,149.9954833984375,150]}"
"2500","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,0,0,3],""bins"":[149.58355712890625,149.59005737304688,149.59657287597656,149.6030731201172,149.60958862304688,149.6160888671875,149.6226043701172,149.6291046142578,149.6356201171875,149.64212036132812,149.64862060546875,149.65513610839844,149.66163635253906,149.66815185546875,149.67465209960938,149.68116760253906,149.6876678466797,149.6941680908203,149.70068359375,149.70718383789062,149.7136993408203,149.72019958496094,149.72671508789062,149.73321533203125,149.73971557617188,149.74623107910156,149.7527313232422,149.75924682617188,149.7657470703125,149.7722625732422,149.7787628173828,149.7852783203125,149.79177856445312,149.79827880859375,149.80479431152344,149.81129455566406,149.81781005859375,149.82431030273438,149.83082580566406,149.8373260498047,149.84384155273438,149.850341796875,149.85684204101562,149.8633575439453,149.86985778808594,149.87637329101562,149.88287353515625,149.88938903808594,149.89588928222656,149.9023895263672,149.90890502929688,149.9154052734375,149.9219207763672,149.9284210205078,149.9349365234375,149.94143676757812,149.94793701171875,149.95445251464844,149.96095275878906,149.96746826171875,149.97396850585938,149.98048400878906,149.9869842529297,149.99349975585938,150],""_type"":""histogram""}"
"2600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,3],""bins"":[149.67474365234375,149.67982482910156,149.68490600585938,149.6899871826172,149.695068359375,149.7001495361328,149.70523071289062,149.71031188964844,149.71539306640625,149.72048950195312,149.72557067871094,149.73065185546875,149.73573303222656,149.74081420898438,149.7458953857422,149.7509765625,149.7560577392578,149.76113891601562,149.76622009277344,149.77130126953125,149.77638244628906,149.78146362304688,149.7865447998047,149.7916259765625,149.79672241210938,149.8018035888672,149.806884765625,149.8119659423828,149.81704711914062,149.82212829589844,149.82720947265625,149.83229064941406,149.83737182617188,149.8424530029297,149.8475341796875,149.8526153564453,149.85769653320312,149.86277770996094,149.86785888671875,149.87294006347656,149.87802124023438,149.88311767578125,149.88819885253906,149.89328002929688,149.8983612060547,149.9034423828125,149.9085235595703,149.91360473632812,149.91868591308594,149.92376708984375,149.92884826660156,149.93392944335938,149.9390106201172,149.944091796875,149.9491729736328,149.95425415039062,149.9593505859375,149.9644317626953,149.96951293945312,149.97459411621094,149.97967529296875,149.98475646972656,149.98983764648438,149.9949188232422,150]}"
"2700","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,3],""_type"":""histogram"",""bins"":[149.65234375,149.65777587890625,149.6632080078125,149.66864013671875,149.674072265625,149.67950439453125,149.6849365234375,149.69036865234375,149.69580078125,149.70123291015625,149.7066650390625,149.71209716796875,149.717529296875,149.72296142578125,149.7283935546875,149.73382568359375,149.7392578125,149.74468994140625,149.7501220703125,149.75555419921875,149.760986328125,149.76641845703125,149.7718505859375,149.77728271484375,149.78271484375,149.78814697265625,149.7935791015625,149.79901123046875,149.804443359375,149.80987548828125,149.8153076171875,149.82073974609375,149.826171875,149.83160400390625,149.8370361328125,149.84246826171875,149.847900390625,149.85333251953125,149.8587646484375,149.86419677734375,149.86962890625,149.87506103515625,149.8804931640625,149.88592529296875,149.891357421875,149.89678955078125,149.9022216796875,149.90765380859375,149.9130859375,149.91851806640625,149.9239501953125,149.92938232421875,149.934814453125,149.94024658203125,149.9456787109375,149.95111083984375,149.95654296875,149.96197509765625,149.9674072265625,149.97283935546875,149.978271484375,149.98370361328125,149.9891357421875,149.99456787109375,150]}"
"2800","{""bins"":[149.7410430908203,149.74508666992188,149.74913024902344,149.75318908691406,149.75723266601562,149.7612762451172,149.76531982421875,149.7693634033203,149.77340698242188,149.7774658203125,149.78150939941406,149.78555297851562,149.7895965576172,149.79364013671875,149.7976837158203,149.80174255371094,149.8057861328125,149.80982971191406,149.81387329101562,149.8179168701172,149.82196044921875,149.82601928710938,149.83006286621094,149.8341064453125,149.83815002441406,149.84219360351562,149.8462371826172,149.8502960205078,149.85433959960938,149.85838317871094,149.8624267578125,149.86647033691406,149.87051391601562,149.87457275390625,149.8786163330078,149.88265991210938,149.88670349121094,149.8907470703125,149.89480590820312,149.8988494873047,149.90289306640625,149.9069366455078,149.91098022460938,149.91502380371094,149.91908264160156,149.92312622070312,149.9271697998047,149.93121337890625,149.9352569580078,149.93930053710938,149.943359375,149.94740295410156,149.95144653320312,149.9554901123047,149.95953369140625,149.9635772705078,149.96763610839844,149.9716796875,149.97572326660156,149.97976684570312,149.9838104248047,149.98785400390625,149.99191284179688,149.99595642089844,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}"
"2900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,1,0,0,3],""bins"":[149.65863037109375,149.66397094726562,149.66929626464844,149.6746368408203,149.67996215820312,149.685302734375,149.6906280517578,149.6959686279297,149.7012939453125,149.70663452148438,149.71197509765625,149.71730041503906,149.72264099121094,149.72796630859375,149.73330688476562,149.73863220214844,149.7439727783203,149.7493133544922,149.754638671875,149.75997924804688,149.7653045654297,149.77064514160156,149.77597045898438,149.78131103515625,149.78665161132812,149.79197692871094,149.7973175048828,149.80264282226562,149.8079833984375,149.8133087158203,149.8186492919922,149.823974609375,149.82931518554688,149.83465576171875,149.83998107910156,149.84532165527344,149.85064697265625,149.85598754882812,149.86131286621094,149.8666534423828,149.87197875976562,149.8773193359375,149.88265991210938,149.8879852294922,149.89332580566406,149.89865112304688,149.90399169921875,149.90931701660156,149.91465759277344,149.9199981689453,149.92532348632812,149.9306640625,149.9359893798828,149.9413299560547,149.9466552734375,149.95199584960938,149.95733642578125,149.96266174316406,149.96800231933594,149.97332763671875,149.97866821289062,149.98399353027344,149.9893341064453,149.99465942382812,150]}"
"3000","{""_type"":""histogram"",""values"":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3],""bins"":[149.756103515625,149.75991821289062,149.76373291015625,149.7675323486328,149.77134704589844,149.77516174316406,149.77896118164062,149.78277587890625,149.78659057617188,149.7904052734375,149.79421997070312,149.7980194091797,149.8018341064453,149.80564880371094,149.8094482421875,149.81326293945312,149.81707763671875,149.82089233398438,149.82470703125,149.82850646972656,149.8323211669922,149.8361358642578,149.83993530273438,149.84375,149.84756469726562,149.85137939453125,149.85519409179688,149.85899353027344,149.86280822753906,149.8666229248047,149.87042236328125,149.87423706054688,149.8780517578125,149.88186645507812,149.88568115234375,149.8894805908203,149.89329528808594,149.89710998535156,149.90090942382812,149.90472412109375,149.90853881835938,149.912353515625,149.91616821289062,149.9199676513672,149.9237823486328,149.92759704589844,149.931396484375,149.93521118164062,149.93902587890625,149.94284057617188,149.9466552734375,149.95045471191406,149.9542694091797,149.9580841064453,149.96188354492188,149.9656982421875,149.96951293945312,149.97332763671875,149.97714233398438,149.98094177246094,149.98475646972656,149.9885711669922,149.99237060546875,149.99618530273438,150]}"
"3100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,4],""bins"":[149.4569091796875,149.46539306640625,149.473876953125,149.48236083984375,149.4908447265625,149.4993438720703,149.50782775878906,149.5163116455078,149.52479553222656,149.5332794189453,149.54176330566406,149.5502471923828,149.55874633789062,149.56723022460938,149.57571411132812,149.58419799804688,149.59268188476562,149.60116577148438,149.60964965820312,149.61813354492188,149.62661743164062,149.63511657714844,149.6436004638672,149.65208435058594,149.6605682373047,149.66905212402344,149.6775360107422,149.68601989746094,149.69451904296875,149.7030029296875,149.71148681640625,149.719970703125,149.72845458984375,149.7369384765625,149.74542236328125,149.75390625,149.76239013671875,149.77088928222656,149.7793731689453,149.78785705566406,149.7963409423828,149.80482482910156,149.8133087158203,149.82179260253906,149.83029174804688,149.83877563476562,149.84725952148438,149.85574340820312,149.86422729492188,149.87271118164062,149.88119506835938,149.88967895507812,149.89816284179688,149.9066619873047,149.91514587402344,149.9236297607422,149.93211364746094,149.9405975341797,149.94908142089844,149.9575653076172,149.966064453125,149.97454833984375,149.9830322265625,149.99151611328125,150]}"
"3200","{""bins"":[149.8720245361328,149.8740234375,149.8760223388672,149.87802124023438,149.88002014160156,149.88201904296875,149.88401794433594,149.88601684570312,149.8880157470703,149.8900146484375,149.8920135498047,149.89402770996094,149.89602661132812,149.8980255126953,149.9000244140625,149.9020233154297,149.90402221679688,149.90602111816406,149.90802001953125,149.91001892089844,149.91201782226562,149.9140167236328,149.916015625,149.9180145263672,149.92001342773438,149.92201232910156,149.92401123046875,149.92601013183594,149.92800903320312,149.9300079345703,149.9320068359375,149.9340057373047,149.93600463867188,149.93801879882812,149.9400177001953,149.9420166015625,149.9440155029297,149.94601440429688,149.94801330566406,149.95001220703125,149.95201110839844,149.95401000976562,149.9560089111328,149.9580078125,149.9600067138672,149.96200561523438,149.96400451660156,149.96600341796875,149.96800231933594,149.97000122070312,149.9720001220703,149.9739990234375,149.9759979248047,149.97799682617188,149.98001098632812,149.9820098876953,149.9840087890625,149.9860076904297,149.98800659179688,149.99000549316406,149.99200439453125,149.99400329589844,149.99600219726562,149.9980010986328,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3]}"
"3300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,3],""bins"":[149.5899658203125,149.59637451171875,149.602783203125,149.60919189453125,149.6156005859375,149.6219940185547,149.62840270996094,149.6348114013672,149.64122009277344,149.6476287841797,149.65403747558594,149.6604461669922,149.66683959960938,149.67324829101562,149.67965698242188,149.68606567382812,149.69247436523438,149.69888305664062,149.70529174804688,149.71170043945312,149.71810913085938,149.72450256347656,149.7309112548828,149.73731994628906,149.7437286376953,149.75013732910156,149.7565460205078,149.76295471191406,149.76934814453125,149.7757568359375,149.78216552734375,149.78857421875,149.79498291015625,149.8013916015625,149.80780029296875,149.814208984375,149.82061767578125,149.82701110839844,149.8334197998047,149.83982849121094,149.8462371826172,149.85264587402344,149.8590545654297,149.86546325683594,149.87185668945312,149.87826538085938,149.88467407226562,149.89108276367188,149.89749145507812,149.90390014648438,149.91030883789062,149.91671752929688,149.92312622070312,149.9295196533203,149.93592834472656,149.9423370361328,149.94874572753906,149.9551544189453,149.96156311035156,149.9679718017578,149.974365234375,149.98077392578125,149.9871826171875,149.99359130859375,150]}"
"3400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,3],""bins"":[149.78488159179688,149.78823852539062,149.79161071777344,149.7949676513672,149.79832458496094,149.8016815185547,149.8050537109375,149.80841064453125,149.811767578125,149.8151397705078,149.81849670410156,149.8218536376953,149.82521057128906,149.82858276367188,149.83193969726562,149.83529663085938,149.83865356445312,149.84202575683594,149.8453826904297,149.84873962402344,149.85211181640625,149.85546875,149.85882568359375,149.8621826171875,149.8655548095703,149.86891174316406,149.8722686767578,149.87564086914062,149.87899780273438,149.88235473632812,149.88571166992188,149.8890838623047,149.89244079589844,149.8957977294922,149.899169921875,149.90252685546875,149.9058837890625,149.90924072265625,149.91261291503906,149.9159698486328,149.91932678222656,149.92269897460938,149.92605590820312,149.92941284179688,149.93276977539062,149.93614196777344,149.9394989013672,149.94285583496094,149.94622802734375,149.9495849609375,149.95294189453125,149.956298828125,149.9596710205078,149.96302795410156,149.9663848876953,149.96974182128906,149.97311401367188,149.97647094726562,149.97982788085938,149.9832000732422,149.98655700683594,149.9899139404297,149.99327087402344,149.99664306640625,150]}"
"3500","{""bins"":[149.75067138671875,149.7545623779297,149.7584686279297,149.76235961914062,149.76625061035156,149.77015686035156,149.7740478515625,149.77793884277344,149.78182983398438,149.78573608398438,149.7896270751953,149.79351806640625,149.79742431640625,149.8013153076172,149.80520629882812,149.80911254882812,149.81300354003906,149.81689453125,149.82080078125,149.82469177246094,149.82858276367188,149.83248901367188,149.8363800048828,149.84027099609375,149.84417724609375,149.8480682373047,149.85195922851562,149.85585021972656,149.85975646972656,149.8636474609375,149.86753845214844,149.87144470214844,149.87533569335938,149.8792266845703,149.8831329345703,149.88702392578125,149.8909149169922,149.8948211669922,149.89871215820312,149.90260314941406,149.906494140625,149.910400390625,149.91429138183594,149.91818237304688,149.92208862304688,149.9259796142578,149.92987060546875,149.93377685546875,149.9376678466797,149.94155883789062,149.94546508789062,149.94935607910156,149.9532470703125,149.9571533203125,149.96104431152344,149.96493530273438,149.96884155273438,149.9727325439453,149.97662353515625,149.9805145263672,149.9844207763672,149.98831176757812,149.99220275878906,149.99610900878906,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,0,1,0,0,0,0,1,0,0,0,3]}"
"3600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,3],""bins"":[149.89866638183594,149.90025329589844,149.90184020996094,149.90341186523438,149.90499877929688,149.90658569335938,149.90817260742188,149.9097442626953,149.9113311767578,149.9129180908203,149.9145050048828,149.91607666015625,149.91766357421875,149.91925048828125,149.92083740234375,149.9224090576172,149.9239959716797,149.9255828857422,149.9271697998047,149.9287567138672,149.93032836914062,149.93191528320312,149.93350219726562,149.93508911132812,149.93666076660156,149.93824768066406,149.93983459472656,149.94142150878906,149.9429931640625,149.944580078125,149.9461669921875,149.94775390625,149.9493408203125,149.95091247558594,149.95249938964844,149.95408630371094,149.95567321777344,149.95724487304688,149.95883178710938,149.96041870117188,149.96200561523438,149.9635772705078,149.9651641845703,149.9667510986328,149.9683380126953,149.96990966796875,149.97149658203125,149.97308349609375,149.97467041015625,149.97625732421875,149.9778289794922,149.9794158935547,149.9810028076172,149.9825897216797,149.98416137695312,149.98574829101562,149.98733520507812,149.98892211914062,149.99049377441406,149.99208068847656,149.99366760253906,149.99525451660156,149.996826171875,149.9984130859375,150]}"
"3700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,3],""bins"":[149.76803588867188,149.77166748046875,149.77528381347656,149.77891540527344,149.78253173828125,149.78616333007812,149.78977966308594,149.7934112548828,149.79702758789062,149.8006591796875,149.8042755126953,149.8079071044922,149.8115234375,149.81515502929688,149.8187713623047,149.82240295410156,149.82601928710938,149.82965087890625,149.83328247070312,149.83689880371094,149.8405303955078,149.84414672851562,149.8477783203125,149.8513946533203,149.8550262451172,149.858642578125,149.86227416992188,149.8658905029297,149.86952209472656,149.87313842773438,149.87677001953125,149.88038635253906,149.88401794433594,149.8876495361328,149.89126586914062,149.8948974609375,149.8985137939453,149.9021453857422,149.90576171875,149.90939331054688,149.9130096435547,149.91664123535156,149.92025756835938,149.92388916015625,149.92750549316406,149.93113708496094,149.93475341796875,149.93838500976562,149.9420166015625,149.9456329345703,149.9492645263672,149.952880859375,149.95651245117188,149.9601287841797,149.96376037597656,149.96737670898438,149.97100830078125,149.97462463378906,149.97825622558594,149.98187255859375,149.98550415039062,149.98912048339844,149.9927520751953,149.99636840820312,150]}"
"3800","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,2,0,1,0,0,1,0,0,0,3],""bins"":[149.3803253173828,149.3900146484375,149.39968872070312,149.4093780517578,149.41905212402344,149.42874145507812,149.43841552734375,149.44810485839844,149.45777893066406,149.46746826171875,149.47714233398438,149.48683166503906,149.49652099609375,149.50619506835938,149.51588439941406,149.5255584716797,149.53524780273438,149.544921875,149.5546112060547,149.5642852783203,149.573974609375,149.58364868164062,149.5933380126953,149.60302734375,149.61270141601562,149.6223907470703,149.63206481933594,149.64175415039062,149.65142822265625,149.66111755371094,149.67079162597656,149.68048095703125,149.69015502929688,149.69984436035156,149.70953369140625,149.71920776367188,149.72889709472656,149.7385711669922,149.74826049804688,149.7579345703125,149.7676239013672,149.7772979736328,149.7869873046875,149.7966766357422,149.8063507080078,149.8160400390625,149.82571411132812,149.8354034423828,149.84507751464844,149.85476684570312,149.86444091796875,149.87413024902344,149.88380432128906,149.89349365234375,149.90318298339844,149.91285705566406,149.92254638671875,149.93222045898438,149.94190979003906,149.9515838623047,149.96127319335938,149.970947265625,149.9806365966797,149.9903106689453,150],""_type"":""histogram""}"
"3900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,3],""bins"":[149.67698669433594,149.68203735351562,149.6870880126953,149.69212341308594,149.69717407226562,149.7022247314453,149.707275390625,149.71231079101562,149.7173614501953,149.722412109375,149.7274627685547,149.7324981689453,149.737548828125,149.7425994873047,149.74765014648438,149.752685546875,149.7577362060547,149.76278686523438,149.76783752441406,149.77288818359375,149.77792358398438,149.78297424316406,149.78802490234375,149.79307556152344,149.79811096191406,149.80316162109375,149.80821228027344,149.81326293945312,149.81829833984375,149.82334899902344,149.82839965820312,149.8334503173828,149.8385009765625,149.84353637695312,149.8485870361328,149.8536376953125,149.8586883544922,149.8637237548828,149.8687744140625,149.8738250732422,149.87887573242188,149.8839111328125,149.8889617919922,149.89401245117188,149.89906311035156,149.9040985107422,149.90914916992188,149.91419982910156,149.91925048828125,149.92430114746094,149.92933654785156,149.93438720703125,149.93943786621094,149.94448852539062,149.94952392578125,149.95457458496094,149.95962524414062,149.9646759033203,149.96971130371094,149.97476196289062,149.9798126220703,149.98486328125,149.98989868164062,149.9949493408203,150]}"
"4000","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,3],""bins"":[149.80003356933594,149.80316162109375,149.80628967285156,149.8094024658203,149.81253051757812,149.81565856933594,149.81878662109375,149.8218994140625,149.8250274658203,149.82815551757812,149.83128356933594,149.8343963623047,149.8375244140625,149.8406524658203,149.84378051757812,149.84689331054688,149.8500213623047,149.8531494140625,149.8562774658203,149.85940551757812,149.86251831054688,149.8656463623047,149.8687744140625,149.8719024658203,149.87501525878906,149.87814331054688,149.8812713623047,149.8843994140625,149.88751220703125,149.89064025878906,149.89376831054688,149.8968963623047,149.9000244140625,149.90313720703125,149.90626525878906,149.90939331054688,149.9125213623047,149.91563415527344,149.91876220703125,149.92189025878906,149.92501831054688,149.92813110351562,149.93125915527344,149.93438720703125,149.93751525878906,149.9406280517578,149.94375610351562,149.94688415527344,149.95001220703125,149.95314025878906,149.9562530517578,149.95938110351562,149.96250915527344,149.96563720703125,149.96875,149.9718780517578,149.97500610351562,149.97813415527344,149.9812469482422,149.984375,149.9875030517578,149.99063110351562,149.99374389648438,149.9968719482422,150]}"
1 Step giddy-deluge-6 - distributions/prices
2 100 {"values":[2,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1],"_type":"histogram","bins":[15.76888656616211,17.813893377780914,19.85890018939972,21.903907001018524,23.94891381263733,25.993920624256134,28.03892743587494,30.083934247493744,32.12894105911255,34.173947870731354,36.21895468235016,38.263961493968964,40.30896830558777,42.35397511720657,44.39898192882538,46.44398874044418,48.48899555206299,50.53400236368179,52.5790091753006,54.6240159869194,56.66902279853821,58.71402961015701,60.75903642177582,62.80404323339462,64.84905004501343,66.89405685663223,68.93906366825104,70.98407047986984,73.02907729148865,75.07408410310745,77.11909091472626,79.16409772634506,81.20910453796387,83.25411134958267,85.29911816120148,87.34412497282028,89.38913178443909,91.43413859605789,93.4791454076767,95.5241522192955,97.5691590309143,99.61416584253311,101.65917265415192,103.70417946577072,105.74918627738953,107.79419308900833,109.83919990062714,111.88420671224594,113.92921352386475,115.97422033548355,118.01922714710236,120.06423395872116,122.10924077033997,124.15424758195877,126.19925439357758,128.24426120519638,130.28926801681519,132.334274828434,134.3792816400528,136.4242884516716,138.4692952632904,140.5143020749092,142.55930888652802,144.60431569814682,146.64932250976562]}
3 200 {"_type":"histogram","values":[1,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,3],"bins":[10.439504623413086,12.620137363672256,14.800770103931427,16.981402844190598,19.162035584449768,21.34266832470894,23.52330106496811,25.70393380522728,27.88456654548645,30.06519928574562,32.24583202600479,34.42646476626396,36.60709750652313,38.7877302467823,40.96836298704147,43.148995727300644,45.329628467559814,47.510261207818985,49.690893948078156,51.871526688337326,54.0521594285965,56.23279216885567,58.41342490911484,60.59405764937401,62.77469038963318,64.95532312989235,67.13595587015152,69.31658861041069,71.49722135066986,73.67785409092903,75.8584868311882,78.03911957144737,80.21975231170654,82.40038505196571,84.58101779222488,86.76165053248405,88.94228327274323,91.1229160130024,93.30354875326157,95.48418149352074,97.66481423377991,99.84544697403908,102.02607971429825,104.20671245455742,106.38734519481659,108.56797793507576,110.74861067533493,112.9292434155941,115.10987615585327,117.29050889611244,119.47114163637161,121.65177437663078,123.83240711688995,126.01303985714912,128.1936725974083,130.37430533766747,132.55493807792664,134.7355708181858,136.91620355844498,139.09683629870415,141.27746903896332,143.4581017792225,145.63873451948166,147.81936725974083,150]}
4 300 {"bins":[92.91828918457031,93.81018829345703,94.70209503173828,95.593994140625,96.48589324951172,97.37779998779297,98.26969909667969,99.1615982055664,100.05350494384766,100.94540405273438,101.83731079101562,102.72920989990234,103.62110900878906,104.51301574707031,105.40491485595703,106.29681396484375,107.188720703125,108.08061981201172,108.97251892089844,109.86442565917969,110.7563247680664,111.64822387695312,112.54013061523438,113.4320297241211,114.32392883300781,115.21583557128906,116.10773468017578,116.9996337890625,117.89154052734375,118.78343963623047,119.67533874511719,120.56724548339844,121.45914459228516,122.35104370117188,123.24295043945312,124.13484954833984,125.02674865722656,125.91865539550781,126.81055450439453,127.70245361328125,128.5943603515625,129.48626708984375,130.37815856933594,131.2700653076172,132.16195678710938,133.05386352539062,133.94577026367188,134.83767700195312,135.7295684814453,136.62147521972656,137.51336669921875,138.4052734375,139.29718017578125,140.1890869140625,141.0809783935547,141.97288513183594,142.86477661132812,143.75668334960938,144.64859008789062,145.54049682617188,146.43238830566406,147.3242950439453,148.21620178222656,149.10809326171875,150],"_type":"histogram","values":[1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,1,0,3]}
5 400 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,3,0,0,0,3],"bins":[141.8555450439453,141.98280334472656,142.1100616455078,142.23731994628906,142.3645782470703,142.49183654785156,142.6190948486328,142.746337890625,142.87359619140625,143.0008544921875,143.12811279296875,143.25537109375,143.38262939453125,143.5098876953125,143.63714599609375,143.764404296875,143.89166259765625,144.0189208984375,144.14617919921875,144.2734375,144.4006805419922,144.52793884277344,144.6551971435547,144.78245544433594,144.9097137451172,145.03697204589844,145.1642303466797,145.29148864746094,145.4187469482422,145.54600524902344,145.6732635498047,145.80052185058594,145.92776489257812,146.05502319335938,146.18228149414062,146.30953979492188,146.43679809570312,146.56405639648438,146.69131469726562,146.81857299804688,146.94583129882812,147.07308959960938,147.20034790039062,147.32760620117188,147.45486450195312,147.58212280273438,147.70936584472656,147.8366241455078,147.96388244628906,148.0911407470703,148.21839904785156,148.3456573486328,148.47291564941406,148.6001739501953,148.72743225097656,148.8546905517578,148.98194885253906,149.1092071533203,149.2364501953125,149.36370849609375,149.490966796875,149.61822509765625,149.7454833984375,149.87274169921875,150],"_type":"histogram"}
6 500 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,0,3],"bins":[142.30267333984375,142.42294311523438,142.543212890625,142.66348266601562,142.78375244140625,142.90402221679688,143.0242919921875,143.14456176757812,143.26483154296875,143.38511657714844,143.50538635253906,143.6256561279297,143.7459259033203,143.86619567871094,143.98646545410156,144.1067352294922,144.2270050048828,144.34727478027344,144.46754455566406,144.5878143310547,144.7080841064453,144.82835388183594,144.94862365722656,145.0688934326172,145.18917846679688,145.3094482421875,145.42971801757812,145.54998779296875,145.67025756835938,145.79052734375,145.91079711914062,146.03106689453125,146.15133666992188,146.2716064453125,146.39187622070312,146.51214599609375,146.63241577148438,146.752685546875,146.87295532226562,146.99322509765625,147.11349487304688,147.23377990722656,147.3540496826172,147.4743194580078,147.59458923339844,147.71485900878906,147.8351287841797,147.9553985595703,148.07566833496094,148.19593811035156,148.3162078857422,148.4364776611328,148.55674743652344,148.67701721191406,148.7972869873047,148.9175567626953,149.037841796875,149.15811157226562,149.27838134765625,149.39865112304688,149.5189208984375,149.63919067382812,149.75946044921875,149.87973022460938,150]}
7 600 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,1,0,0,0,0,1,0,1,0,0,0,0,3],"bins":[143.02142333984375,143.13046264648438,143.239501953125,143.34854125976562,143.45758056640625,143.56661987304688,143.6756591796875,143.78469848632812,143.89373779296875,144.00279235839844,144.11183166503906,144.2208709716797,144.3299102783203,144.43894958496094,144.54798889160156,144.6570281982422,144.7660675048828,144.87510681152344,144.98414611816406,145.0931854248047,145.2022247314453,145.31126403808594,145.42030334472656,145.5293426513672,145.63839721679688,145.7474365234375,145.85647583007812,145.96551513671875,146.07455444335938,146.18359375,146.29263305664062,146.40167236328125,146.51071166992188,146.6197509765625,146.72879028320312,146.83782958984375,146.94686889648438,147.055908203125,147.16494750976562,147.27398681640625,147.38302612304688,147.49208068847656,147.6011199951172,147.7101593017578,147.81919860839844,147.92823791503906,148.0372772216797,148.1463165283203,148.25535583496094,148.36439514160156,148.4734344482422,148.5824737548828,148.69151306152344,148.80055236816406,148.9095916748047,149.0186309814453,149.127685546875,149.23672485351562,149.34576416015625,149.45480346679688,149.5638427734375,149.67288208007812,149.78192138671875,149.89096069335938,150],"_type":"histogram"}
8 700 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,1,3],"bins":[147.6887969970703,147.72491455078125,147.76101684570312,147.79713439941406,147.833251953125,147.86935424804688,147.9054718017578,147.94158935546875,147.97769165039062,148.01380920410156,148.0499267578125,148.08602905273438,148.1221466064453,148.15826416015625,148.19436645507812,148.23048400878906,148.2666015625,148.30270385742188,148.3388214111328,148.37493896484375,148.41104125976562,148.44715881347656,148.4832763671875,148.51937866210938,148.5554962158203,148.59161376953125,148.62771606445312,148.66383361816406,148.699951171875,148.73605346679688,148.7721710205078,148.80828857421875,148.84439086914062,148.88050842285156,148.9166259765625,148.95274353027344,148.9888458251953,149.02496337890625,149.0610809326172,149.09718322753906,149.13330078125,149.16941833496094,149.2055206298828,149.24163818359375,149.2777557373047,149.31385803222656,149.3499755859375,149.38609313964844,149.4221954345703,149.45831298828125,149.4944305419922,149.53053283691406,149.566650390625,149.60276794433594,149.6388702392578,149.67498779296875,149.7111053466797,149.74720764160156,149.7833251953125,149.81944274902344,149.8555450439453,149.89166259765625,149.9277801513672,149.96388244628906,150]}
9 800 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,3],"bins":[149.21865844726562,149.23086547851562,149.24307250976562,149.25527954101562,149.26748657226562,149.27969360351562,149.2919158935547,149.3041229248047,149.3163299560547,149.3285369873047,149.3407440185547,149.3529510498047,149.3651580810547,149.3773651123047,149.3895721435547,149.4017791748047,149.41400146484375,149.42620849609375,149.43841552734375,149.45062255859375,149.46282958984375,149.47503662109375,149.48724365234375,149.49945068359375,149.51165771484375,149.52386474609375,149.53607177734375,149.5482940673828,149.5605010986328,149.5727081298828,149.5849151611328,149.5971221923828,149.6093292236328,149.6215362548828,149.6337432861328,149.6459503173828,149.6581573486328,149.6703643798828,149.68258666992188,149.69479370117188,149.70700073242188,149.71920776367188,149.73141479492188,149.74362182617188,149.75582885742188,149.76803588867188,149.78024291992188,149.79244995117188,149.80465698242188,149.81687927246094,149.82908630371094,149.84129333496094,149.85350036621094,149.86570739746094,149.87791442871094,149.89012145996094,149.90232849121094,149.91453552246094,149.92674255371094,149.93896484375,149.951171875,149.96337890625,149.9755859375,149.98779296875,150]}
10 900 {"_type":"histogram","values":[1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,3],"bins":[149.2405548095703,149.25242614746094,149.2642822265625,149.27615356445312,149.28802490234375,149.2998809814453,149.31175231933594,149.32362365722656,149.33547973632812,149.34735107421875,149.35922241210938,149.37107849121094,149.38294982910156,149.3948211669922,149.40667724609375,149.41854858398438,149.430419921875,149.44227600097656,149.4541473388672,149.4660186767578,149.47787475585938,149.48974609375,149.50161743164062,149.5134735107422,149.5253448486328,149.53721618652344,149.549072265625,149.56094360351562,149.57281494140625,149.5846710205078,149.59654235839844,149.60841369628906,149.62026977539062,149.63214111328125,149.64401245117188,149.6558837890625,149.66773986816406,149.6796112060547,149.6914825439453,149.70333862304688,149.7152099609375,149.72708129882812,149.7389373779297,149.7508087158203,149.76268005371094,149.7745361328125,149.78640747070312,149.79827880859375,149.8101348876953,149.82200622558594,149.83387756347656,149.84573364257812,149.85760498046875,149.86947631835938,149.88133239746094,149.89320373535156,149.9050750732422,149.91693115234375,149.92880249023438,149.940673828125,149.95252990722656,149.9644012451172,149.9762725830078,149.98812866210938,150]}
11 1000 {"bins":[149.04977416992188,149.0646209716797,149.0794677734375,149.0943145751953,149.10916137695312,149.12400817871094,149.13885498046875,149.15370178222656,149.16854858398438,149.1833953857422,149.1982421875,149.2130889892578,149.22793579101562,149.24278259277344,149.25762939453125,149.27247619628906,149.28732299804688,149.30218505859375,149.31703186035156,149.33187866210938,149.3467254638672,149.361572265625,149.3764190673828,149.39126586914062,149.40611267089844,149.42095947265625,149.43580627441406,149.45065307617188,149.4654998779297,149.4803466796875,149.4951934814453,149.51004028320312,149.52488708496094,149.53973388671875,149.55458068847656,149.56942749023438,149.5842742919922,149.59912109375,149.6139678955078,149.62881469726562,149.64366149902344,149.65850830078125,149.67335510253906,149.68820190429688,149.7030487060547,149.7178955078125,149.7327423095703,149.74758911132812,149.762451171875,149.7772979736328,149.79214477539062,149.80699157714844,149.82183837890625,149.83668518066406,149.85153198242188,149.8663787841797,149.8812255859375,149.8960723876953,149.91091918945312,149.92576599121094,149.94061279296875,149.95545959472656,149.97030639648438,149.9851531982422,150],"_type":"histogram","values":[1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,3]}
12 1100 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,0,0,0,0,1,0,0,0,0,3],"bins":[149.34432983398438,149.3545684814453,149.3648223876953,149.37506103515625,149.38531494140625,149.3955535888672,149.40579223632812,149.41604614257812,149.42628479003906,149.43653869628906,149.44677734375,149.45701599121094,149.46726989746094,149.47750854492188,149.48776245117188,149.4980010986328,149.50823974609375,149.51849365234375,149.5287322998047,149.5389862060547,149.54922485351562,149.55947875976562,149.56971740722656,149.5799560546875,149.5902099609375,149.60044860839844,149.61070251464844,149.62094116210938,149.6311798095703,149.6414337158203,149.65167236328125,149.66192626953125,149.6721649169922,149.68240356445312,149.69265747070312,149.70289611816406,149.71315002441406,149.723388671875,149.73362731933594,149.74388122558594,149.75411987304688,149.76437377929688,149.7746124267578,149.78485107421875,149.79510498046875,149.8053436279297,149.8155975341797,149.82583618164062,149.83609008789062,149.84632873535156,149.8565673828125,149.8668212890625,149.87705993652344,149.88731384277344,149.89755249023438,149.9077911376953,149.9180450439453,149.92828369140625,149.93853759765625,149.9487762451172,149.95901489257812,149.96926879882812,149.97950744628906,149.98976135253906,150]}
13 1200 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,3],"bins":[148.93704223632812,148.95364379882812,148.9702606201172,148.9868621826172,149.00347900390625,149.02008056640625,149.0366973876953,149.0532989501953,149.06991577148438,149.08651733398438,149.10313415527344,149.11973571777344,149.1363525390625,149.1529541015625,149.16957092285156,149.18617248535156,149.20278930664062,149.21939086914062,149.23599243164062,149.2526092529297,149.2692108154297,149.28582763671875,149.30242919921875,149.3190460205078,149.3356475830078,149.35226440429688,149.36886596679688,149.38548278808594,149.40208435058594,149.418701171875,149.435302734375,149.45191955566406,149.46852111816406,149.48512268066406,149.50173950195312,149.51834106445312,149.5349578857422,149.5515594482422,149.56817626953125,149.58477783203125,149.6013946533203,149.6179962158203,149.63461303710938,149.65121459960938,149.66783142089844,149.68443298339844,149.7010498046875,149.7176513671875,149.7342529296875,149.75086975097656,149.76747131347656,149.78408813476562,149.80068969726562,149.8173065185547,149.8339080810547,149.85052490234375,149.86712646484375,149.8837432861328,149.9003448486328,149.91696166992188,149.93356323242188,149.95018005371094,149.96678161621094,149.9833984375,150],"_type":"histogram"}
14 1300 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,3],"bins":[149.0462646484375,149.06117248535156,149.07606506347656,149.09097290039062,149.10586547851562,149.1207733154297,149.13568115234375,149.15057373046875,149.1654815673828,149.18038940429688,149.19528198242188,149.21018981933594,149.22509765625,149.239990234375,149.25489807128906,149.26979064941406,149.28469848632812,149.2996063232422,149.3144989013672,149.32940673828125,149.34429931640625,149.3592071533203,149.37411499023438,149.38900756835938,149.40391540527344,149.4188232421875,149.4337158203125,149.44862365722656,149.46353149414062,149.47842407226562,149.4933319091797,149.5082244873047,149.52313232421875,149.5380401611328,149.5529327392578,149.56784057617188,149.58273315429688,149.59764099121094,149.612548828125,149.62744140625,149.64234924316406,149.65725708007812,149.67214965820312,149.6870574951172,149.70196533203125,149.71685791015625,149.7317657470703,149.7466583251953,149.76156616210938,149.77647399902344,149.79136657714844,149.8062744140625,149.8211669921875,149.83607482910156,149.85098266601562,149.86587524414062,149.8807830810547,149.89569091796875,149.91058349609375,149.9254913330078,149.94039916992188,149.95529174804688,149.97019958496094,149.98509216308594,150]}
15 1400 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,4],"bins":[148.76895141601562,148.78819274902344,148.8074188232422,148.82666015625,148.84588623046875,148.86512756347656,148.88436889648438,148.90359497070312,148.92283630371094,148.9420623779297,148.9613037109375,148.9805450439453,148.99977111816406,149.01901245117188,149.03823852539062,149.05747985839844,149.07672119140625,149.095947265625,149.1151885986328,149.13441467285156,149.15365600585938,149.17288208007812,149.19212341308594,149.21136474609375,149.2305908203125,149.2498321533203,149.26905822753906,149.28829956054688,149.3075408935547,149.32676696777344,149.34600830078125,149.365234375,149.3844757080078,149.40371704101562,149.42294311523438,149.4421844482422,149.46141052246094,149.48065185546875,149.49989318847656,149.5191192626953,149.53836059570312,149.55758666992188,149.5768280029297,149.5960693359375,149.61529541015625,149.63453674316406,149.6537628173828,149.67300415039062,149.69223022460938,149.7114715576172,149.730712890625,149.74993896484375,149.76918029785156,149.7884063720703,149.80764770507812,149.82688903808594,149.8461151123047,149.8653564453125,149.88458251953125,149.90382385253906,149.92306518554688,149.94229125976562,149.96153259277344,149.9807586669922,150]}
16 1500 {"bins":[149.55426025390625,149.56121826171875,149.5681915283203,149.5751495361328,149.58212280273438,149.58908081054688,149.59605407714844,149.60301208496094,149.6099853515625,149.616943359375,149.6239013671875,149.63087463378906,149.63783264160156,149.64480590820312,149.65176391601562,149.6587371826172,149.6656951904297,149.6726531982422,149.67962646484375,149.68658447265625,149.6935577392578,149.7005157470703,149.70748901367188,149.71444702148438,149.72140502929688,149.72837829589844,149.73533630371094,149.7423095703125,149.749267578125,149.75624084472656,149.76319885253906,149.77017211914062,149.77713012695312,149.78408813476562,149.7910614013672,149.7980194091797,149.80499267578125,149.81195068359375,149.8189239501953,149.8258819580078,149.83285522460938,149.83981323242188,149.84677124023438,149.85374450683594,149.86070251464844,149.86767578125,149.8746337890625,149.88160705566406,149.88856506347656,149.89552307128906,149.90249633789062,149.90945434570312,149.9164276123047,149.9233856201172,149.93035888671875,149.93731689453125,149.94427490234375,149.9512481689453,149.9582061767578,149.96517944335938,149.97213745117188,149.97911071777344,149.98606872558594,149.9930419921875,150],"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}
17 1600 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,3],"bins":[149.17001342773438,149.1829833984375,149.19595336914062,149.20892333984375,149.22189331054688,149.23486328125,149.24781799316406,149.2607879638672,149.2737579345703,149.28672790527344,149.29969787597656,149.3126678466797,149.3256378173828,149.33860778808594,149.35157775878906,149.3645477294922,149.37750244140625,149.39047241210938,149.4034423828125,149.41641235351562,149.42938232421875,149.44235229492188,149.455322265625,149.46829223632812,149.48126220703125,149.49423217773438,149.5072021484375,149.52015686035156,149.5331268310547,149.5460968017578,149.55906677246094,149.57203674316406,149.5850067138672,149.5979766845703,149.61094665527344,149.62391662597656,149.6368865966797,149.6498565673828,149.66281127929688,149.67578125,149.68875122070312,149.70172119140625,149.71469116210938,149.7276611328125,149.74063110351562,149.75360107421875,149.76657104492188,149.779541015625,149.79251098632812,149.8054656982422,149.8184356689453,149.83140563964844,149.84437561035156,149.8573455810547,149.8703155517578,149.88328552246094,149.89625549316406,149.9092254638672,149.9221954345703,149.93515014648438,149.9481201171875,149.96109008789062,149.97406005859375,149.98703002929688,150]}
18 1700 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,3],"bins":[149.36141967773438,149.37139892578125,149.38137817382812,149.391357421875,149.40133666992188,149.41131591796875,149.42127990722656,149.43125915527344,149.4412384033203,149.4512176513672,149.46119689941406,149.47117614746094,149.4811553955078,149.4911346435547,149.50111389160156,149.51109313964844,149.52105712890625,149.53103637695312,149.541015625,149.55099487304688,149.56097412109375,149.57095336914062,149.5809326171875,149.59091186523438,149.60089111328125,149.61087036132812,149.620849609375,149.6308135986328,149.6407928466797,149.65077209472656,149.66075134277344,149.6707305908203,149.6807098388672,149.69068908691406,149.70066833496094,149.7106475830078,149.7206268310547,149.73060607910156,149.74057006835938,149.75054931640625,149.76052856445312,149.7705078125,149.78048706054688,149.79046630859375,149.80044555664062,149.8104248046875,149.82040405273438,149.83038330078125,149.84036254882812,149.85032653808594,149.8603057861328,149.8702850341797,149.88026428222656,149.89024353027344,149.9002227783203,149.9102020263672,149.92018127441406,149.93016052246094,149.9401397705078,149.95010375976562,149.9600830078125,149.97006225585938,149.98004150390625,149.99002075195312,150]}
19 1800 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,3],"bins":[149.42300415039062,149.43202209472656,149.4410400390625,149.45005798339844,149.4590606689453,149.46807861328125,149.4770965576172,149.48611450195312,149.49513244628906,149.504150390625,149.51315307617188,149.5221710205078,149.53118896484375,149.5402069091797,149.54922485351562,149.55824279785156,149.5672607421875,149.57626342773438,149.5852813720703,149.59429931640625,149.6033172607422,149.61233520507812,149.62135314941406,149.63035583496094,149.63937377929688,149.6483917236328,149.65740966796875,149.6664276123047,149.67544555664062,149.6844482421875,149.69346618652344,149.70248413085938,149.7115020751953,149.72052001953125,149.7295379638672,149.73855590820312,149.74755859375,149.75657653808594,149.76559448242188,149.7746124267578,149.78363037109375,149.7926483154297,149.80165100097656,149.8106689453125,149.81968688964844,149.82870483398438,149.8377227783203,149.84674072265625,149.85574340820312,149.86476135253906,149.873779296875,149.88279724121094,149.89181518554688,149.9008331298828,149.90985107421875,149.91885375976562,149.92787170410156,149.9368896484375,149.94590759277344,149.95492553710938,149.9639434814453,149.9729461669922,149.98196411132812,149.99098205566406,150],"_type":"histogram"}
20 1900 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1,0,0,3],"bins":[148.7623748779297,148.78170776367188,148.80105590820312,148.8203887939453,148.8397216796875,148.85906982421875,148.87840270996094,148.89773559570312,148.91708374023438,148.93641662597656,148.95574951171875,148.97509765625,148.9944305419922,149.01376342773438,149.03311157226562,149.0524444580078,149.07177734375,149.09112548828125,149.11045837402344,149.12979125976562,149.14913940429688,149.16847229003906,149.18780517578125,149.2071533203125,149.2264862060547,149.24581909179688,149.26516723632812,149.2845001220703,149.3038330078125,149.32318115234375,149.34251403808594,149.36184692382812,149.38119506835938,149.40052795410156,149.41986083984375,149.43919372558594,149.4585418701172,149.47787475585938,149.49720764160156,149.5165557861328,149.535888671875,149.5552215576172,149.57456970214844,149.59390258789062,149.6132354736328,149.63258361816406,149.65191650390625,149.67124938964844,149.6905975341797,149.70993041992188,149.72926330566406,149.7486114501953,149.7679443359375,149.7872772216797,149.80662536621094,149.82595825195312,149.8452911376953,149.86463928222656,149.88397216796875,149.90330505371094,149.9226531982422,149.94198608398438,149.96131896972656,149.9806671142578,150]}
21 2000 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,1,0,0,0,0,1,0,0,0,0,0,1,3],"bins":[149.5408477783203,149.5480194091797,149.55519104003906,149.5623779296875,149.56954956054688,149.57672119140625,149.58389282226562,149.591064453125,149.59823608398438,149.6054229736328,149.6125946044922,149.61976623535156,149.62693786621094,149.6341094970703,149.6412811279297,149.64846801757812,149.6556396484375,149.66281127929688,149.66998291015625,149.67715454101562,149.684326171875,149.69151306152344,149.6986846923828,149.7058563232422,149.71302795410156,149.72019958496094,149.7273712158203,149.73455810546875,149.74172973632812,149.7489013671875,149.75607299804688,149.76324462890625,149.77041625976562,149.77760314941406,149.78477478027344,149.7919464111328,149.7991180419922,149.80628967285156,149.8134765625,149.82064819335938,149.82781982421875,149.83499145507812,149.8421630859375,149.84933471679688,149.8565216064453,149.8636932373047,149.87086486816406,149.87803649902344,149.8852081298828,149.8923797607422,149.89956665039062,149.90673828125,149.91390991210938,149.92108154296875,149.92825317382812,149.9354248046875,149.94261169433594,149.9497833251953,149.9569549560547,149.96412658691406,149.97129821777344,149.9784698486328,149.98565673828125,149.99282836914062,150]}
22 2100 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,3],"bins":[149.5018310546875,149.50961303710938,149.51739501953125,149.52517700195312,149.532958984375,149.54075622558594,149.5485382080078,149.5563201904297,149.56410217285156,149.57188415527344,149.5796661376953,149.5874481201172,149.59524536132812,149.60302734375,149.61080932617188,149.61859130859375,149.62637329101562,149.6341552734375,149.64193725585938,149.64971923828125,149.65750122070312,149.66529846191406,149.67308044433594,149.6808624267578,149.6886444091797,149.69642639160156,149.70420837402344,149.7119903564453,149.71978759765625,149.72756958007812,149.7353515625,149.74313354492188,149.75091552734375,149.75869750976562,149.7664794921875,149.77426147460938,149.78204345703125,149.7898406982422,149.79762268066406,149.80540466308594,149.8131866455078,149.8209686279297,149.82875061035156,149.83653259277344,149.84432983398438,149.85211181640625,149.85989379882812,149.86767578125,149.87545776367188,149.88323974609375,149.89102172851562,149.8988037109375,149.90658569335938,149.9143829345703,149.9221649169922,149.92994689941406,149.93772888183594,149.9455108642578,149.9532928466797,149.96107482910156,149.9688720703125,149.97665405273438,149.98443603515625,149.99221801757812,150]}
23 2200 {"bins":[149.558349609375,149.56524658203125,149.5721435546875,149.5790557861328,149.58595275878906,149.5928497314453,149.59976196289062,149.60665893554688,149.61355590820312,149.62045288085938,149.62734985351562,149.63426208496094,149.6411590576172,149.64805603027344,149.65496826171875,149.661865234375,149.66876220703125,149.6756591796875,149.68255615234375,149.68946838378906,149.6963653564453,149.70326232910156,149.71017456054688,149.71707153320312,149.72396850585938,149.73086547851562,149.73776245117188,149.7446746826172,149.75157165527344,149.7584686279297,149.765380859375,149.77227783203125,149.7791748046875,149.78607177734375,149.79296875,149.7998809814453,149.80677795410156,149.8136749267578,149.82058715820312,149.82748413085938,149.83438110351562,149.84127807617188,149.84817504882812,149.85508728027344,149.8619842529297,149.86888122558594,149.87579345703125,149.8826904296875,149.88958740234375,149.896484375,149.90338134765625,149.91029357910156,149.9171905517578,149.92408752441406,149.93099975585938,149.93789672851562,149.94479370117188,149.95169067382812,149.95858764648438,149.9654998779297,149.97239685058594,149.9792938232422,149.9862060546875,149.99310302734375,150],"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,2,1,1,0,0,0,3]}
24 2300 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,2,1,0,0,3],"bins":[149.1126251220703,149.12649536132812,149.14035034179688,149.1542205810547,149.1680908203125,149.18194580078125,149.19581604003906,149.20968627929688,149.22354125976562,149.23741149902344,149.25128173828125,149.26513671875,149.2790069580078,149.29287719726562,149.30673217773438,149.3206024169922,149.33447265625,149.34832763671875,149.36219787597656,149.37606811523438,149.38992309570312,149.40379333496094,149.41766357421875,149.4315185546875,149.4453887939453,149.45925903320312,149.47311401367188,149.4869842529297,149.5008544921875,149.51470947265625,149.52857971191406,149.54244995117188,149.55630493164062,149.57017517089844,149.58404541015625,149.59791564941406,149.6117706298828,149.62564086914062,149.63951110839844,149.6533660888672,149.667236328125,149.6811065673828,149.69496154785156,149.70883178710938,149.7227020263672,149.73655700683594,149.75042724609375,149.76429748535156,149.7781524658203,149.79202270507812,149.80589294433594,149.8197479248047,149.8336181640625,149.8474884033203,149.86134338378906,149.87521362304688,149.8890838623047,149.90293884277344,149.91680908203125,149.93067932128906,149.9445343017578,149.95840454101562,149.97227478027344,149.9861297607422,150]}
25 2400 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,2,0,0,0,0,0,0,0,0,0,3],"bins":[149.71124267578125,149.71575927734375,149.7202606201172,149.7247772216797,149.7292938232422,149.73379516601562,149.73831176757812,149.74282836914062,149.74734497070312,149.75184631347656,149.75636291503906,149.76087951660156,149.765380859375,149.7698974609375,149.7744140625,149.77891540527344,149.78343200683594,149.78794860839844,149.79244995117188,149.79696655273438,149.80148315429688,149.8059844970703,149.8105010986328,149.8150177001953,149.81951904296875,149.82403564453125,149.82855224609375,149.83306884765625,149.8375701904297,149.8420867919922,149.8466033935547,149.85110473632812,149.85562133789062,149.86013793945312,149.86463928222656,149.86915588378906,149.87367248535156,149.878173828125,149.8826904296875,149.88720703125,149.8917236328125,149.89622497558594,149.90074157714844,149.90525817871094,149.90975952148438,149.91427612304688,149.91879272460938,149.9232940673828,149.9278106689453,149.9323272705078,149.93682861328125,149.94134521484375,149.94586181640625,149.9503631591797,149.9548797607422,149.9593963623047,149.96389770507812,149.96841430664062,149.97293090820312,149.97744750976562,149.98194885253906,149.98646545410156,149.99098205566406,149.9954833984375,150]}
26 2500 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,0,0,3],"bins":[149.58355712890625,149.59005737304688,149.59657287597656,149.6030731201172,149.60958862304688,149.6160888671875,149.6226043701172,149.6291046142578,149.6356201171875,149.64212036132812,149.64862060546875,149.65513610839844,149.66163635253906,149.66815185546875,149.67465209960938,149.68116760253906,149.6876678466797,149.6941680908203,149.70068359375,149.70718383789062,149.7136993408203,149.72019958496094,149.72671508789062,149.73321533203125,149.73971557617188,149.74623107910156,149.7527313232422,149.75924682617188,149.7657470703125,149.7722625732422,149.7787628173828,149.7852783203125,149.79177856445312,149.79827880859375,149.80479431152344,149.81129455566406,149.81781005859375,149.82431030273438,149.83082580566406,149.8373260498047,149.84384155273438,149.850341796875,149.85684204101562,149.8633575439453,149.86985778808594,149.87637329101562,149.88287353515625,149.88938903808594,149.89588928222656,149.9023895263672,149.90890502929688,149.9154052734375,149.9219207763672,149.9284210205078,149.9349365234375,149.94143676757812,149.94793701171875,149.95445251464844,149.96095275878906,149.96746826171875,149.97396850585938,149.98048400878906,149.9869842529297,149.99349975585938,150],"_type":"histogram"}
27 2600 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,3],"bins":[149.67474365234375,149.67982482910156,149.68490600585938,149.6899871826172,149.695068359375,149.7001495361328,149.70523071289062,149.71031188964844,149.71539306640625,149.72048950195312,149.72557067871094,149.73065185546875,149.73573303222656,149.74081420898438,149.7458953857422,149.7509765625,149.7560577392578,149.76113891601562,149.76622009277344,149.77130126953125,149.77638244628906,149.78146362304688,149.7865447998047,149.7916259765625,149.79672241210938,149.8018035888672,149.806884765625,149.8119659423828,149.81704711914062,149.82212829589844,149.82720947265625,149.83229064941406,149.83737182617188,149.8424530029297,149.8475341796875,149.8526153564453,149.85769653320312,149.86277770996094,149.86785888671875,149.87294006347656,149.87802124023438,149.88311767578125,149.88819885253906,149.89328002929688,149.8983612060547,149.9034423828125,149.9085235595703,149.91360473632812,149.91868591308594,149.92376708984375,149.92884826660156,149.93392944335938,149.9390106201172,149.944091796875,149.9491729736328,149.95425415039062,149.9593505859375,149.9644317626953,149.96951293945312,149.97459411621094,149.97967529296875,149.98475646972656,149.98983764648438,149.9949188232422,150]}
28 2700 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,3],"_type":"histogram","bins":[149.65234375,149.65777587890625,149.6632080078125,149.66864013671875,149.674072265625,149.67950439453125,149.6849365234375,149.69036865234375,149.69580078125,149.70123291015625,149.7066650390625,149.71209716796875,149.717529296875,149.72296142578125,149.7283935546875,149.73382568359375,149.7392578125,149.74468994140625,149.7501220703125,149.75555419921875,149.760986328125,149.76641845703125,149.7718505859375,149.77728271484375,149.78271484375,149.78814697265625,149.7935791015625,149.79901123046875,149.804443359375,149.80987548828125,149.8153076171875,149.82073974609375,149.826171875,149.83160400390625,149.8370361328125,149.84246826171875,149.847900390625,149.85333251953125,149.8587646484375,149.86419677734375,149.86962890625,149.87506103515625,149.8804931640625,149.88592529296875,149.891357421875,149.89678955078125,149.9022216796875,149.90765380859375,149.9130859375,149.91851806640625,149.9239501953125,149.92938232421875,149.934814453125,149.94024658203125,149.9456787109375,149.95111083984375,149.95654296875,149.96197509765625,149.9674072265625,149.97283935546875,149.978271484375,149.98370361328125,149.9891357421875,149.99456787109375,150]}
29 2800 {"bins":[149.7410430908203,149.74508666992188,149.74913024902344,149.75318908691406,149.75723266601562,149.7612762451172,149.76531982421875,149.7693634033203,149.77340698242188,149.7774658203125,149.78150939941406,149.78555297851562,149.7895965576172,149.79364013671875,149.7976837158203,149.80174255371094,149.8057861328125,149.80982971191406,149.81387329101562,149.8179168701172,149.82196044921875,149.82601928710938,149.83006286621094,149.8341064453125,149.83815002441406,149.84219360351562,149.8462371826172,149.8502960205078,149.85433959960938,149.85838317871094,149.8624267578125,149.86647033691406,149.87051391601562,149.87457275390625,149.8786163330078,149.88265991210938,149.88670349121094,149.8907470703125,149.89480590820312,149.8988494873047,149.90289306640625,149.9069366455078,149.91098022460938,149.91502380371094,149.91908264160156,149.92312622070312,149.9271697998047,149.93121337890625,149.9352569580078,149.93930053710938,149.943359375,149.94740295410156,149.95144653320312,149.9554901123047,149.95953369140625,149.9635772705078,149.96763610839844,149.9716796875,149.97572326660156,149.97976684570312,149.9838104248047,149.98785400390625,149.99191284179688,149.99595642089844,150],"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}
30 2900 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,1,0,0,3],"bins":[149.65863037109375,149.66397094726562,149.66929626464844,149.6746368408203,149.67996215820312,149.685302734375,149.6906280517578,149.6959686279297,149.7012939453125,149.70663452148438,149.71197509765625,149.71730041503906,149.72264099121094,149.72796630859375,149.73330688476562,149.73863220214844,149.7439727783203,149.7493133544922,149.754638671875,149.75997924804688,149.7653045654297,149.77064514160156,149.77597045898438,149.78131103515625,149.78665161132812,149.79197692871094,149.7973175048828,149.80264282226562,149.8079833984375,149.8133087158203,149.8186492919922,149.823974609375,149.82931518554688,149.83465576171875,149.83998107910156,149.84532165527344,149.85064697265625,149.85598754882812,149.86131286621094,149.8666534423828,149.87197875976562,149.8773193359375,149.88265991210938,149.8879852294922,149.89332580566406,149.89865112304688,149.90399169921875,149.90931701660156,149.91465759277344,149.9199981689453,149.92532348632812,149.9306640625,149.9359893798828,149.9413299560547,149.9466552734375,149.95199584960938,149.95733642578125,149.96266174316406,149.96800231933594,149.97332763671875,149.97866821289062,149.98399353027344,149.9893341064453,149.99465942382812,150]}
31 3000 {"_type":"histogram","values":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3],"bins":[149.756103515625,149.75991821289062,149.76373291015625,149.7675323486328,149.77134704589844,149.77516174316406,149.77896118164062,149.78277587890625,149.78659057617188,149.7904052734375,149.79421997070312,149.7980194091797,149.8018341064453,149.80564880371094,149.8094482421875,149.81326293945312,149.81707763671875,149.82089233398438,149.82470703125,149.82850646972656,149.8323211669922,149.8361358642578,149.83993530273438,149.84375,149.84756469726562,149.85137939453125,149.85519409179688,149.85899353027344,149.86280822753906,149.8666229248047,149.87042236328125,149.87423706054688,149.8780517578125,149.88186645507812,149.88568115234375,149.8894805908203,149.89329528808594,149.89710998535156,149.90090942382812,149.90472412109375,149.90853881835938,149.912353515625,149.91616821289062,149.9199676513672,149.9237823486328,149.92759704589844,149.931396484375,149.93521118164062,149.93902587890625,149.94284057617188,149.9466552734375,149.95045471191406,149.9542694091797,149.9580841064453,149.96188354492188,149.9656982421875,149.96951293945312,149.97332763671875,149.97714233398438,149.98094177246094,149.98475646972656,149.9885711669922,149.99237060546875,149.99618530273438,150]}
32 3100 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,4],"bins":[149.4569091796875,149.46539306640625,149.473876953125,149.48236083984375,149.4908447265625,149.4993438720703,149.50782775878906,149.5163116455078,149.52479553222656,149.5332794189453,149.54176330566406,149.5502471923828,149.55874633789062,149.56723022460938,149.57571411132812,149.58419799804688,149.59268188476562,149.60116577148438,149.60964965820312,149.61813354492188,149.62661743164062,149.63511657714844,149.6436004638672,149.65208435058594,149.6605682373047,149.66905212402344,149.6775360107422,149.68601989746094,149.69451904296875,149.7030029296875,149.71148681640625,149.719970703125,149.72845458984375,149.7369384765625,149.74542236328125,149.75390625,149.76239013671875,149.77088928222656,149.7793731689453,149.78785705566406,149.7963409423828,149.80482482910156,149.8133087158203,149.82179260253906,149.83029174804688,149.83877563476562,149.84725952148438,149.85574340820312,149.86422729492188,149.87271118164062,149.88119506835938,149.88967895507812,149.89816284179688,149.9066619873047,149.91514587402344,149.9236297607422,149.93211364746094,149.9405975341797,149.94908142089844,149.9575653076172,149.966064453125,149.97454833984375,149.9830322265625,149.99151611328125,150]}
33 3200 {"bins":[149.8720245361328,149.8740234375,149.8760223388672,149.87802124023438,149.88002014160156,149.88201904296875,149.88401794433594,149.88601684570312,149.8880157470703,149.8900146484375,149.8920135498047,149.89402770996094,149.89602661132812,149.8980255126953,149.9000244140625,149.9020233154297,149.90402221679688,149.90602111816406,149.90802001953125,149.91001892089844,149.91201782226562,149.9140167236328,149.916015625,149.9180145263672,149.92001342773438,149.92201232910156,149.92401123046875,149.92601013183594,149.92800903320312,149.9300079345703,149.9320068359375,149.9340057373047,149.93600463867188,149.93801879882812,149.9400177001953,149.9420166015625,149.9440155029297,149.94601440429688,149.94801330566406,149.95001220703125,149.95201110839844,149.95401000976562,149.9560089111328,149.9580078125,149.9600067138672,149.96200561523438,149.96400451660156,149.96600341796875,149.96800231933594,149.97000122070312,149.9720001220703,149.9739990234375,149.9759979248047,149.97799682617188,149.98001098632812,149.9820098876953,149.9840087890625,149.9860076904297,149.98800659179688,149.99000549316406,149.99200439453125,149.99400329589844,149.99600219726562,149.9980010986328,150],"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3]}
34 3300 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,3],"bins":[149.5899658203125,149.59637451171875,149.602783203125,149.60919189453125,149.6156005859375,149.6219940185547,149.62840270996094,149.6348114013672,149.64122009277344,149.6476287841797,149.65403747558594,149.6604461669922,149.66683959960938,149.67324829101562,149.67965698242188,149.68606567382812,149.69247436523438,149.69888305664062,149.70529174804688,149.71170043945312,149.71810913085938,149.72450256347656,149.7309112548828,149.73731994628906,149.7437286376953,149.75013732910156,149.7565460205078,149.76295471191406,149.76934814453125,149.7757568359375,149.78216552734375,149.78857421875,149.79498291015625,149.8013916015625,149.80780029296875,149.814208984375,149.82061767578125,149.82701110839844,149.8334197998047,149.83982849121094,149.8462371826172,149.85264587402344,149.8590545654297,149.86546325683594,149.87185668945312,149.87826538085938,149.88467407226562,149.89108276367188,149.89749145507812,149.90390014648438,149.91030883789062,149.91671752929688,149.92312622070312,149.9295196533203,149.93592834472656,149.9423370361328,149.94874572753906,149.9551544189453,149.96156311035156,149.9679718017578,149.974365234375,149.98077392578125,149.9871826171875,149.99359130859375,150]}
35 3400 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,3],"bins":[149.78488159179688,149.78823852539062,149.79161071777344,149.7949676513672,149.79832458496094,149.8016815185547,149.8050537109375,149.80841064453125,149.811767578125,149.8151397705078,149.81849670410156,149.8218536376953,149.82521057128906,149.82858276367188,149.83193969726562,149.83529663085938,149.83865356445312,149.84202575683594,149.8453826904297,149.84873962402344,149.85211181640625,149.85546875,149.85882568359375,149.8621826171875,149.8655548095703,149.86891174316406,149.8722686767578,149.87564086914062,149.87899780273438,149.88235473632812,149.88571166992188,149.8890838623047,149.89244079589844,149.8957977294922,149.899169921875,149.90252685546875,149.9058837890625,149.90924072265625,149.91261291503906,149.9159698486328,149.91932678222656,149.92269897460938,149.92605590820312,149.92941284179688,149.93276977539062,149.93614196777344,149.9394989013672,149.94285583496094,149.94622802734375,149.9495849609375,149.95294189453125,149.956298828125,149.9596710205078,149.96302795410156,149.9663848876953,149.96974182128906,149.97311401367188,149.97647094726562,149.97982788085938,149.9832000732422,149.98655700683594,149.9899139404297,149.99327087402344,149.99664306640625,150]}
36 3500 {"bins":[149.75067138671875,149.7545623779297,149.7584686279297,149.76235961914062,149.76625061035156,149.77015686035156,149.7740478515625,149.77793884277344,149.78182983398438,149.78573608398438,149.7896270751953,149.79351806640625,149.79742431640625,149.8013153076172,149.80520629882812,149.80911254882812,149.81300354003906,149.81689453125,149.82080078125,149.82469177246094,149.82858276367188,149.83248901367188,149.8363800048828,149.84027099609375,149.84417724609375,149.8480682373047,149.85195922851562,149.85585021972656,149.85975646972656,149.8636474609375,149.86753845214844,149.87144470214844,149.87533569335938,149.8792266845703,149.8831329345703,149.88702392578125,149.8909149169922,149.8948211669922,149.89871215820312,149.90260314941406,149.906494140625,149.910400390625,149.91429138183594,149.91818237304688,149.92208862304688,149.9259796142578,149.92987060546875,149.93377685546875,149.9376678466797,149.94155883789062,149.94546508789062,149.94935607910156,149.9532470703125,149.9571533203125,149.96104431152344,149.96493530273438,149.96884155273438,149.9727325439453,149.97662353515625,149.9805145263672,149.9844207763672,149.98831176757812,149.99220275878906,149.99610900878906,150],"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,0,1,0,0,0,0,1,0,0,0,3]}
37 3600 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,3],"bins":[149.89866638183594,149.90025329589844,149.90184020996094,149.90341186523438,149.90499877929688,149.90658569335938,149.90817260742188,149.9097442626953,149.9113311767578,149.9129180908203,149.9145050048828,149.91607666015625,149.91766357421875,149.91925048828125,149.92083740234375,149.9224090576172,149.9239959716797,149.9255828857422,149.9271697998047,149.9287567138672,149.93032836914062,149.93191528320312,149.93350219726562,149.93508911132812,149.93666076660156,149.93824768066406,149.93983459472656,149.94142150878906,149.9429931640625,149.944580078125,149.9461669921875,149.94775390625,149.9493408203125,149.95091247558594,149.95249938964844,149.95408630371094,149.95567321777344,149.95724487304688,149.95883178710938,149.96041870117188,149.96200561523438,149.9635772705078,149.9651641845703,149.9667510986328,149.9683380126953,149.96990966796875,149.97149658203125,149.97308349609375,149.97467041015625,149.97625732421875,149.9778289794922,149.9794158935547,149.9810028076172,149.9825897216797,149.98416137695312,149.98574829101562,149.98733520507812,149.98892211914062,149.99049377441406,149.99208068847656,149.99366760253906,149.99525451660156,149.996826171875,149.9984130859375,150]}
38 3700 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,3],"bins":[149.76803588867188,149.77166748046875,149.77528381347656,149.77891540527344,149.78253173828125,149.78616333007812,149.78977966308594,149.7934112548828,149.79702758789062,149.8006591796875,149.8042755126953,149.8079071044922,149.8115234375,149.81515502929688,149.8187713623047,149.82240295410156,149.82601928710938,149.82965087890625,149.83328247070312,149.83689880371094,149.8405303955078,149.84414672851562,149.8477783203125,149.8513946533203,149.8550262451172,149.858642578125,149.86227416992188,149.8658905029297,149.86952209472656,149.87313842773438,149.87677001953125,149.88038635253906,149.88401794433594,149.8876495361328,149.89126586914062,149.8948974609375,149.8985137939453,149.9021453857422,149.90576171875,149.90939331054688,149.9130096435547,149.91664123535156,149.92025756835938,149.92388916015625,149.92750549316406,149.93113708496094,149.93475341796875,149.93838500976562,149.9420166015625,149.9456329345703,149.9492645263672,149.952880859375,149.95651245117188,149.9601287841797,149.96376037597656,149.96737670898438,149.97100830078125,149.97462463378906,149.97825622558594,149.98187255859375,149.98550415039062,149.98912048339844,149.9927520751953,149.99636840820312,150]}
39 3800 {"values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,2,0,1,0,0,1,0,0,0,3],"bins":[149.3803253173828,149.3900146484375,149.39968872070312,149.4093780517578,149.41905212402344,149.42874145507812,149.43841552734375,149.44810485839844,149.45777893066406,149.46746826171875,149.47714233398438,149.48683166503906,149.49652099609375,149.50619506835938,149.51588439941406,149.5255584716797,149.53524780273438,149.544921875,149.5546112060547,149.5642852783203,149.573974609375,149.58364868164062,149.5933380126953,149.60302734375,149.61270141601562,149.6223907470703,149.63206481933594,149.64175415039062,149.65142822265625,149.66111755371094,149.67079162597656,149.68048095703125,149.69015502929688,149.69984436035156,149.70953369140625,149.71920776367188,149.72889709472656,149.7385711669922,149.74826049804688,149.7579345703125,149.7676239013672,149.7772979736328,149.7869873046875,149.7966766357422,149.8063507080078,149.8160400390625,149.82571411132812,149.8354034423828,149.84507751464844,149.85476684570312,149.86444091796875,149.87413024902344,149.88380432128906,149.89349365234375,149.90318298339844,149.91285705566406,149.92254638671875,149.93222045898438,149.94190979003906,149.9515838623047,149.96127319335938,149.970947265625,149.9806365966797,149.9903106689453,150],"_type":"histogram"}
40 3900 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,3],"bins":[149.67698669433594,149.68203735351562,149.6870880126953,149.69212341308594,149.69717407226562,149.7022247314453,149.707275390625,149.71231079101562,149.7173614501953,149.722412109375,149.7274627685547,149.7324981689453,149.737548828125,149.7425994873047,149.74765014648438,149.752685546875,149.7577362060547,149.76278686523438,149.76783752441406,149.77288818359375,149.77792358398438,149.78297424316406,149.78802490234375,149.79307556152344,149.79811096191406,149.80316162109375,149.80821228027344,149.81326293945312,149.81829833984375,149.82334899902344,149.82839965820312,149.8334503173828,149.8385009765625,149.84353637695312,149.8485870361328,149.8536376953125,149.8586883544922,149.8637237548828,149.8687744140625,149.8738250732422,149.87887573242188,149.8839111328125,149.8889617919922,149.89401245117188,149.89906311035156,149.9040985107422,149.90914916992188,149.91419982910156,149.91925048828125,149.92430114746094,149.92933654785156,149.93438720703125,149.93943786621094,149.94448852539062,149.94952392578125,149.95457458496094,149.95962524414062,149.9646759033203,149.96971130371094,149.97476196289062,149.9798126220703,149.98486328125,149.98989868164062,149.9949493408203,150]}
41 4000 {"_type":"histogram","values":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,3],"bins":[149.80003356933594,149.80316162109375,149.80628967285156,149.8094024658203,149.81253051757812,149.81565856933594,149.81878662109375,149.8218994140625,149.8250274658203,149.82815551757812,149.83128356933594,149.8343963623047,149.8375244140625,149.8406524658203,149.84378051757812,149.84689331054688,149.8500213623047,149.8531494140625,149.8562774658203,149.85940551757812,149.86251831054688,149.8656463623047,149.8687744140625,149.8719024658203,149.87501525878906,149.87814331054688,149.8812713623047,149.8843994140625,149.88751220703125,149.89064025878906,149.89376831054688,149.8968963623047,149.9000244140625,149.90313720703125,149.90626525878906,149.90939331054688,149.9125213623047,149.91563415527344,149.91876220703125,149.92189025878906,149.92501831054688,149.92813110351562,149.93125915527344,149.93438720703125,149.93751525878906,149.9406280517578,149.94375610351562,149.94688415527344,149.95001220703125,149.95314025878906,149.9562530517578,149.95938110351562,149.96250915527344,149.96563720703125,149.96875,149.9718780517578,149.97500610351562,149.97813415527344,149.9812469482422,149.984375,149.9875030517578,149.99063110351562,149.99374389648438,149.9968719482422,150]}

View File

@@ -0,0 +1,27 @@
\begin{tikzpicture}
\begin{axis}[
view={0}{90}, % Top-down view for heatmap
xlabel={Step},
ylabel={Price},
ymin=90,
colorbar,
colorbar style={
title={Density},
ylabel={},
},
colormap/viridis,
% Adjust these axis limits if necessary based on data
enlargelimits=false,
axis on top,
width=0.9\columnwidth,
height=0.5\columnwidth,
]
\addplot3[
surf,
shader=flat,
mesh/check=false % Disable check to rely on empty lines
] table [col sep=comma, x=step, y=price, z=density] {chapters/figures/supra_data.csv};
\end{axis}
\end{tikzpicture}

File diff suppressed because it is too large Load Diff

View File

@@ -49,11 +49,11 @@
\node[greenbox, minimum width=3.5cm] (commerce) at (-3.5, 2) {Commerce Experiment}; \node[greenbox, minimum width=3.5cm] (commerce) at (-3.5, 2) {Commerce Experiment};
\node[greenbox, minimum width=1.5cm] (raw) at (-6.5, 0) {Raw\\Logs}; \node[greenbox, minimum width=1.5cm] (raw) at (-6.5, 0) {Raw\\Logs};
\node[greenbox, minimum width=1.5cm] (features) at (-4, -2.5) {Features}; \node[greenbox, minimum width=1.5cm] (features) at (-4, -2.5) {Features};
\node[greenbox, minimum width=2.5cm] (classification) at (-1, -0.5) {Classification\\Training A/H}; \node[greenbox, minimum width=2.5cm] (classification) at (-0.8, 0) {Classification\\Training A/H};
% Right Loop (Blue) Nodes % Right Loop (Blue) Nodes
\node[bluebox, minimum width=2.5cm] (trainedpricing) at (3.2, 2) {Trained Pricing}; \node[bluebox, minimum width=2.5cm] (trainedpricing) at (3.2, 2) {Trained Pricing};
\node[bluebox, minimum width=2.5cm] (policy) at (6.5, 0) {Trained Pricing\\Policy}; \node[bluebox, minimum width=1.5cm] (policy) at (6.5, 0) {Trained\\Pricing\\Policy};
\node[bluebox, minimum width=2.5cm] (rlgym) at (3.2, -2.2) {RL Gym\\Training}; \node[bluebox, minimum width=2.5cm] (rlgym) at (3.2, -2.2) {RL Gym\\Training};
% --- Background Dashed Loops --- % --- Background Dashed Loops ---

BIN
paper/src/graphics/SST.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,24 @@
from PIL import Image, ImageDraw, ImageFont
text = open("banner.txt", "r", encoding="utf-8").read()
scale = 4 # 26 is typical
pad = 10
font_px = 18
font = ImageFont.truetype("DejaVuSansMono.ttf", font_px * scale)
# Measure at high res
dummy = Image.new("RGB", (1, 1), "white")
d = ImageDraw.Draw(dummy)
bbox = d.multiline_textbbox((0, 0), text, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Render at high res
hi = Image.new("RGB", (w + 2*pad*scale, h + 2*pad*scale), "white")
d = ImageDraw.Draw(hi)
d.multiline_text((pad*scale, pad*scale), text, font=font, fill="black")
# Downscale with a good filter
out = hi.resize((hi.width // scale, hi.height // scale), resample=Image.Resampling.LANCZOS)
out.save("banner.png", dpi=(300, 300))

View File

@@ -0,0 +1,23 @@
Actors Trajectories
■════■ interact ┌────────────┐ ┌──┐
║Agent──────┬──────▻Web Platform├──┐ │τ1│ ┌▻Q (demand estimate)─┐
║Human──────┘ └──────△─────┘ └──▻..│──┘ │
╚════■ │ │τK│ │
△ │ └──┘ │
│motivate │ │
└────────┐ │Setting ┌──┐ Pricing Engine │
▲ ┌──┐│ │Prices │p1│ ┌──────────────┐ │
│ ┌─┘ ││ └───────────┤..│◅────│▒▒▒▒▒▒▒▒▒▒▒▒▒▒│◅──┘
│ │ └──┐ │pN│ └─────┬──┬─────┘
│ ┌─┘ │ └──┘ │ │
└─┴─────────┴─▶ │ │
Private Valuations │ │
│ │
╔═══════════════════════════════════════════════════╧══╧════════╗
║ Training Loop / SAC PPO DQN A2C ║
║ ■═════════════════════════════■ ║
║ Q̂_t,i = Σ_s Σ_k ω(a_s,k) · 1[i_s,k = i] │ ║
║ f(τ') from KL( T' || T_H ) and KL( T' || T_A ) │ ║
α* = argmin_{α ∈ Aε(α0)} [ Revenue(p, Q^α) - λ·COI_leak ] │ ║
║ r_t = Revenue - λ·f(τ') | a* ▽ ║
╚═══════════════════════════════════════════════════════════════╝

View File

@@ -1,30 +1,35 @@
% -*- TeX-master: t -*- % -*- TeX-master: t -*-
\documentclass[12pt,letterpaper]{article} \documentclass[12pt,letterpaper]{article}
\pagestyle{plain}
\input{preamble} \input{preamble}
\begin{document} \begin{document}
\title{Adversarially Distributionally Robust Optimization and Reinforcement Learning for Informed Dynamic Pricing under Strategic Demand Contamination} \begin{titlepage}
\centering
\author{ \includegraphics[width=\textwidth]{graphics/banner.png}\\[0.8cm]
Daniel Rösel\thanks{Primary author and student researcher. Email: daniel@alves.world} \\ \LARGE\textbf{PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms}\\[0.5cm]
IE University, Madrid, Spain \\[1em] \Large\textbf{Daniel Rösel}\\
Alberto Martín Izquierdo\thanks{Thesis advisor. Email: amartini@faculty.ie.edu} \\ \large\textit{Bachelor of Computer Science \& Artificial Intelligence}\\[0.5cm]
IE University, Madrid, Spain \Large\textit{Supervised by:}\\
} \Large\textbf{Alberto Martín Izquierdo}\\
\large\textit{IE University, Madrid, Spain}\\[1cm]
\date{\today} \large\today
\end{titlepage}
\maketitle
\begin{abstract} \begin{abstract}
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behavior and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs. With accelerated growth of Lager Language Model agents in e-commerce a novel adversarial dynamic to digital markets emerges. This paper address the vulnerability of dynamic pricing systems to AI intermediaries that decouple the information gather stages from the transaction execution. By conducing reconnaissance isolates sessions, agents circumvent the ``Cost of Information'' (COI) defined as the accumulated price premium typically thought demand expression estimators.
We formally define this phenomenon and derive the Cost of Information Theorem, proving that as the saturation of independent, utility-maximizing agents increases, the platforms ability to sustain a COI converges to zero, rendering standard dynamic pricing mechanisms incentive-incompatible.
To respond to this threat we propose a defensive framework which integrates behavioral economics with Adversarially Distributionally Robust Optimization (DRO). We introduce a custom e-commerce research platform built on hybrid Kappa-Lambda architecture, designed to capture and simulate high-fidelity controlled interaction trajectories. We further demonstrate through modeling that human and agent behaviors exhibit distinct transition probability kernels, enabling the construction of discriminative models based on Kullback-Leibler divergence.
These behavioral signals serve as inputs for a Distributionally Robust Reinforcement Learning (DR-RL) agent. We formulate the pricing problem as a Stackelberg game where the learner optimizes against an ambiguity set of demand distributions defined by the Wasserstein distance. This approach allows the pricing policy to remain robust against non-stationary contamination without overfitting to deterministic demand curves. The research validates a mechanism for preserving margin integrity and market equilibrium in an agent-mediated economy, while minimizing degradation to the legitimate human user experience (UX).
\end{abstract} \end{abstract}
\noindent\textbf{Keywords:} Dynamic Pricing, LLM Agents, Adversarial Machine Learning, E-commerce, Behavioral Detection, Reinforcement Learning
\vspace{1em}
\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program, which provided access to Google Cloud TPU accelerators (including TPU v4, v5e, and v6e).
\clearpage
\input{chapters/01-intro} \input{chapters/01-intro}
\input{chapters/02-literature-review} \input{chapters/02-literature-review}
\input{chapters/03-methodology} \input{chapters/03-methodology}
@@ -32,11 +37,6 @@ The primary objective of this thesis is to develop and validate pricing heuristi
\input{chapters/05-discussion} \input{chapters/05-discussion}
\input{chapters/06-conclusion} \input{chapters/06-conclusion}
\section*{Acknowledgments}
Eugene Bykovets, PhD - ETH for helping with problem formulation.
Research supported with Cloud TPUs from Google's TPU Research Cloud (TRC).
\printbibliography \printbibliography
\clearpage \clearpage
@@ -46,6 +46,6 @@ Research supported with Cloud TPUs from Google's TPU Research Cloud (TRC).
\item[Agent $A$] An actor of non-human nature, powered by an LLM. \item[Agent $A$] An actor of non-human nature, powered by an LLM.
\item[Human $H$] An individual human with some job to be done. \item[Human $H$] An individual human with some job to be done.
\end{description} \end{description}
\input{../build/concatenated_code} % \input{../build/concatenated_code}
\end{document} \end{document}

View File

@@ -1,6 +1,11 @@
% Encoding
\usepackage[utf8]{inputenc}
% Math packages (load before fonts to avoid conflicts) % Math packages (load before fonts to avoid conflicts)
\usepackage{amsmath} \usepackage{amsmath}
\usepackage{amsthm} \usepackage{amsthm}
\usepackage{appendix}
\usepackage[inline]{enumitem}
% Define theorem environments % Define theorem environments
\newtheorem{theorem}{Theorem} \newtheorem{theorem}{Theorem}
@@ -24,11 +29,14 @@
\usepackage{subcaption} \usepackage{subcaption}
\usepackage{siunitx} \usepackage{siunitx}
\usepackage{tikz} \usepackage{tikz}
\usepackage{pgfplots}
\pgfplotsset{compat=1.18}
\usepackage{listings} \usepackage{listings}
\usepackage{xcolor} \usepackage{xcolor}
\usepackage[ruled,vlined]{algorithm2e} \usepackage[ruled,vlined]{algorithm2e}
\usepackage{cleveref} \usepackage{cleveref}
\usepackage{adjustbox}
\usetikzlibrary{trees}
% Configure cleveref for algorithm2e % Configure cleveref for algorithm2e
\crefname{algocf}{Algorithm}{Algorithms} \crefname{algocf}{Algorithm}{Algorithms}
@@ -49,6 +57,16 @@
literate={·}{{\textperiodcentered}}1 {}{{\textminus}}1 {}{{---}}1 {}{{--}}1 literate={·}{{\textperiodcentered}}1 {}{{\textminus}}1 {}{{---}}1 {}{{--}}1
} }
% Use biblatex instead of natbib (acmart default) % Use biblatex with authoryear style for in-text citations like (Author, Year)
\usepackage[backend=bibtex,style=numeric]{biblatex} \usepackage[backend=bibtex,style=authoryear,natbib=true,maxcitenames=2]{biblatex}
\addbibresource{bib/references.bib} \addbibresource{bib/references.bib}
% Page headers (SciTech format)
\usepackage{fancyhdr}
\setlength{\headheight}{14.5pt}
\addtolength{\topmargin}{-2.5pt}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{PHANTOM}
\fancyhead[R]{\thepage}
\renewcommand{\headrulewidth}{0pt}

View File

@@ -12,3 +12,4 @@ uv
scikit-learn scikit-learn
supabase supabase
pymc pymc
wandb

2
sim/case/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Case-specific simulations and experiments."""

View File

@@ -0,0 +1,2 @@
"""Minimal thesis-aligned pricing simulation (self-contained)."""

View File

@@ -0,0 +1,125 @@
"""Cost of Information (COI) computation for thesis pricing system.
Core KPI: COI = E[p_shown] - p_min measures pricing power from information asymmetry.
Theorem 1 shows COI erodes as agent queries increase: as N->inf, p^(1)->p_min.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from .simplified import Session
@dataclass(frozen=True)
class COIWindow:
"""Windowed COI metrics computed from realized price exposures.
policy: E[p_shown] - cost, the definition-level KPI
agent: E[p^(1)] - cost where p^(1) is min price under agent querying
leak: max(policy - agent, 0), observable gap from reconnaissance
survival_ratio: agent/policy, fraction of pricing power retained
"""
policy: float
agent: float
leak: float
survival_ratio: float
policy_by_product: np.ndarray
agent_by_product: np.ndarray
demand_weights: np.ndarray
def aggregate_prices(sessions: List["Session"], mode: str = "all") -> Dict[int, List[float] | float]:
"""Unified price aggregation across sessions.
mode: "all" returns all prices per product, "min_per_session" returns min price per session per product,
"min_across" returns single min price per product
"""
if mode == "min_across":
mins: Dict[int, float] = {}
for s in sessions:
for e in s.events:
pidx, price = int(e.product_idx), float(e.price_seen)
mins[pidx] = min(mins.get(pidx, price), price)
return mins
elif mode == "min_per_session":
result: Dict[int, List[float]] = {}
for s in sessions:
by_p: Dict[int, float] = {}
for e in s.events:
pidx, price = int(e.product_idx), float(e.price_seen)
by_p[pidx] = min(by_p.get(pidx, price), price)
for pidx, pmin in by_p.items():
result.setdefault(pidx, []).append(pmin)
return result
else: # "all"
prices: Dict[int, List[float]] = {}
for s in sessions:
for e in s.events:
prices.setdefault(e.product_idx, []).append(float(e.price_seen))
return prices
def demand_weights_by_product(sessions: List["Session"], demand_mapping: Dict[str, float], n_products: int) -> np.ndarray:
"""Compute demand-weighted importance per product."""
w = np.zeros(n_products, dtype=float)
sessions_by_id = {s.sid: s for s in sessions}
for sid, q in demand_mapping.items():
sess = sessions_by_id.get(sid)
if sess and sess.events:
w[int(sess.events[0].product_idx)] += float(q)
total = float(np.sum(w))
return (w / total) if total > 0 else w
def compute_coi_window(sessions: List["Session"], costs: np.ndarray, demand_mapping: Dict[str, float] | None = None) -> COIWindow:
"""Compute COI metrics over session window.
Aggregates price exposures and computes policy-level vs agent-realized COI.
"""
n = int(len(costs))
prices = aggregate_prices(sessions, mode="all")
agent_sessions = [s for s in sessions if s.actor == "A"]
agent_min = aggregate_prices(agent_sessions, mode="min_across") if agent_sessions else {}
policy_by = np.zeros(n, dtype=float)
agent_by = np.zeros(n, dtype=float)
seen = np.array([(i in prices) for i in range(n)], dtype=bool)
agent_seen = np.array([(i in agent_min) for i in range(n)], dtype=bool)
for pidx, ps in prices.items():
if 0 <= pidx < n and ps:
policy_by[pidx] = float(np.mean(ps) - float(costs[pidx]))
for pidx, pmin in agent_min.items():
if 0 <= pidx < n:
agent_by[pidx] = float(pmin - float(costs[pidx]))
agent_by[seen & ~agent_seen] = policy_by[seen & ~agent_seen] # no erosion if no agent exposure
demand_w = demand_weights_by_product(sessions, demand_mapping, n) if demand_mapping else np.zeros(n, dtype=float)
has_weights = float(np.sum(demand_w)) > 0
if has_weights:
policy, agent = float(np.dot(demand_w, policy_by)), float(np.dot(demand_w, agent_by))
elif np.any(seen):
policy, agent = float(np.mean(policy_by[seen])), float(np.mean(agent_by[seen]))
else:
policy, agent = 0.0, 0.0
leak = float(max(policy - agent, 0.0))
survival = float(np.clip(agent / policy, 0.0, 1.0)) if policy > 0 else 0.0
return COIWindow(policy=policy, agent=agent, leak=leak, survival_ratio=survival,
policy_by_product=policy_by, agent_by_product=agent_by, demand_weights=demand_w)
def coi_erosion(coi_policy: float, coi_agent: float, eps: float = 1e-9) -> float:
"""Thesis-consistent COI erosion: fraction of pricing power destroyed by agent queries.
erosion = 1 - (COI_agent / COI_policy)
When agents find low prices, COI_agent -> 0, erosion -> 1.
"""
if coi_policy <= eps:
return 0.0
return float(np.clip(1.0 - (coi_agent / (coi_policy + eps)), 0.0, 1.0))

View File

@@ -0,0 +1,325 @@
"""COI leakage experiments and policy comparisons.
Demonstrates the core thesis contribution: COI erosion under agent contamination
and recovery via robust pricing policies.
Generates TensorBoard logs for:
- COI erosion curves across contamination levels
- Policy comparison (fixed vs adaptive vs RL)
- Revenue/margin trade-offs
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple
import json
import numpy as np
try:
from torch.utils.tensorboard import SummaryWriter
HAS_TB = True
except ImportError:
HAS_TB = False
from .simplified_env import PricingEnv, EnvConfig, make_env
from .simplified import System
@dataclass
class ExperimentResult:
"""Container for experiment metrics."""
name: str
alpha: float
reward_mean: float
reward_std: float
coi_erosion: float
alpha_error: float
revenue: float
margin: float
def to_dict(self) -> dict:
return {k: getattr(self, k) for k in self.__dataclass_fields__}
def theoretical_coi_erosion_curve(alphas: np.ndarray, n_sessions: int = 1000) -> np.ndarray:
"""Theoretical COI erosion from Theorem 1 using order statistic model.
For N i.i.d. uniform queries on [p_min, p_max]:
E[p^(1)] = p_min + (p_max - p_min)/(N+1), so erosion = 1 - 2/(N+1)
"""
erosions = []
for a in alphas:
n_agents = max(1, int(a * n_sessions))
erosions.append(1.0 - 2.0 / (n_agents + 1))
return np.array(erosions)
def run_policy_episode(
env: PricingEnv,
policy_fn,
n_episodes: int = 10
) -> Tuple[List[float], List[float], List[float], List[float]]:
"""Run policy and collect per-step metrics."""
rewards, coi_erosions, alpha_errors, revenues = [], [], [], []
for _ in range(n_episodes):
obs, info = env.reset()
done = False
while not done:
action = policy_fn(obs, env.n)
obs, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
rewards.append(reward)
if 'coi_erosion' in info:
coi_erosions.append(info['coi_erosion'])
if 'alpha_true' in info and 'alpha_est' in info:
alpha_errors.append(abs(info['alpha_true'] - info['alpha_est']))
if 'revenue' in info:
revenues.append(info['revenue'])
return rewards, coi_erosions, alpha_errors, revenues
class PolicyRegistry:
"""Registry of baseline policies."""
@staticmethod
def fixed(obs: np.ndarray, n: int, margin: float = 0.15) -> np.ndarray:
return np.ones(n, dtype=np.float32) * (1.0 + margin)
@staticmethod
def random(obs: np.ndarray, n: int, rng: np.random.Generator = None) -> np.ndarray:
rng = rng or np.random.default_rng()
return rng.uniform(0.7, 1.3, n).astype(np.float32)
@staticmethod
def adaptive(obs: np.ndarray, n: int, base_margin: float = 0.15) -> np.ndarray:
"""Reduce margins when alpha estimate is high."""
alpha_est = obs[2 * n] if len(obs) > 2 * n else 0.2
margin_scale = 1.0 - 0.4 * alpha_est
return np.ones(n, dtype=np.float32) * (1.0 + base_margin * margin_scale)
@staticmethod
def aggressive(obs: np.ndarray, n: int) -> np.ndarray:
"""High margins, ignores contamination."""
return np.ones(n, dtype=np.float32) * 1.4
@staticmethod
def defensive(obs: np.ndarray, n: int) -> np.ndarray:
"""Low margins, always cautious."""
return np.ones(n, dtype=np.float32) * 1.05
@staticmethod
def alpha_proportional(obs: np.ndarray, n: int, max_margin: float = 0.3) -> np.ndarray:
"""Margin inversely proportional to estimated alpha."""
alpha_est = obs[2 * n] if len(obs) > 2 * n else 0.2
margin = max_margin * (1.0 - alpha_est)
return np.ones(n, dtype=np.float32) * (1.0 + margin)
def run_contamination_sweep(
alphas: List[float],
policies: Dict[str, callable],
n_products: int = 10,
max_steps: int = 200,
n_episodes: int = 10,
seed: int = 42,
log_dir: str = None
) -> Dict[str, List[ExperimentResult]]:
"""Run policies across contamination levels."""
results = {name: [] for name in policies}
writer = SummaryWriter(Path(log_dir) / "sweep") if log_dir and HAS_TB else None
for alpha in alphas:
print(f" alpha={alpha:.2f}", end=" ")
env_cfg = EnvConfig(
n_products=n_products, max_steps=max_steps,
alpha_true=alpha, reward_mode="robust", seed=seed)
env = make_env(env_cfg)
for name, policy_fn in policies.items():
rewards, coi_vals, alpha_errs, revenues = run_policy_episode(env, policy_fn, n_episodes)
result = ExperimentResult(
name=name, alpha=alpha,
reward_mean=float(np.mean(rewards)),
reward_std=float(np.std(rewards)),
coi_erosion=float(np.mean(coi_vals)) if coi_vals else 0.0,
alpha_error=float(np.mean(alpha_errs)) if alpha_errs else 0.0,
revenue=float(np.mean(revenues)) if revenues else 0.0,
margin=float(np.mean([policy_fn(np.zeros(3 * n_products + 3), n_products)]) - 1.0))
results[name].append(result)
if writer:
step = int(alpha * 100)
writer.add_scalar(f'{name}/reward', result.reward_mean, step)
writer.add_scalar(f'{name}/coi_erosion', result.coi_erosion, step)
writer.add_scalar(f'{name}/alpha_error', result.alpha_error, step)
writer.add_scalar(f'{name}/revenue', result.revenue, step)
print(f"done")
# add theoretical curve
if writer:
theo = theoretical_coi_erosion_curve(np.array(alphas))
for i, (a, e) in enumerate(zip(alphas, theo)):
writer.add_scalar('theoretical/coi_erosion', e, int(a * 100))
writer.close()
return results
def run_coi_demonstration(log_dir: str = "sim/case/thesis_simplified/runs", seed: int = 42) -> Dict:
"""Main COI demonstration experiment."""
print("=== COI Leakage Demonstration ===\n")
Path(log_dir).mkdir(parents=True, exist_ok=True)
writer = SummaryWriter(Path(log_dir) / "coi_demo") if HAS_TB else None
# theoretical erosion curve
print("1. Theoretical COI erosion (Theorem 1)")
alphas = np.linspace(0.0, 0.6, 13)
theo_erosion = theoretical_coi_erosion_curve(alphas, n_sessions=1000)
for a, e in zip(alphas, theo_erosion):
print(f" alpha={a:.2f} -> erosion={e:.3f}")
if writer:
writer.add_scalar('theory/coi_erosion', e, int(a * 100))
# policy comparison
print("\n2. Policy comparison across contamination levels")
policies = {
'fixed': lambda obs, n: PolicyRegistry.fixed(obs, n),
'aggressive': PolicyRegistry.aggressive,
'defensive': PolicyRegistry.defensive,
'adaptive': PolicyRegistry.adaptive,
'alpha_proportional': PolicyRegistry.alpha_proportional,
}
sweep_alphas = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5]
results = run_contamination_sweep(
sweep_alphas, policies, n_products=10, max_steps=100,
n_episodes=5, seed=seed, log_dir=log_dir)
# summarize
print("\n3. Summary by policy")
for name, res_list in results.items():
avg_reward = np.mean([r.reward_mean for r in res_list])
avg_coi = np.mean([r.coi_erosion for r in res_list])
print(f" {name:20s}: avg_reward={avg_reward:.2f}, avg_coi={avg_coi:.3f}")
# save results
output = {
'theoretical': {'alphas': alphas.tolist(), 'erosion': theo_erosion.tolist()},
'empirical': {name: [r.to_dict() for r in res_list] for name, res_list in results.items()}}
with open(Path(log_dir) / "coi_demo_results.json", 'w') as f:
json.dump(output, f, indent=2)
if writer:
writer.close()
print(f"\nResults saved to {log_dir}/coi_demo_results.json")
print(f"TensorBoard: tensorboard --logdir {log_dir}")
return output
def run_reward_mode_comparison(log_dir: str = "sim/case/thesis_simplified/runs", seed: int = 42) -> Dict:
"""Compare different reward modes."""
print("=== Reward Mode Comparison ===\n")
Path(log_dir).mkdir(parents=True, exist_ok=True)
writer = SummaryWriter(Path(log_dir) / "reward_modes") if HAS_TB else None
reward_modes = ["revenue", "profit", "robust", "coi_aware"]
alpha = 0.3 # moderate contamination
results = {}
for mode in reward_modes:
print(f" mode={mode}", end=" ")
env_cfg = EnvConfig(
n_products=10, max_steps=200, alpha_true=alpha,
reward_mode=mode, seed=seed)
env = make_env(env_cfg)
rewards, coi_vals, _, revenues = run_policy_episode(
env, PolicyRegistry.adaptive, n_episodes=10)
results[mode] = {
'reward_mean': float(np.mean(rewards)),
'reward_std': float(np.std(rewards)),
'coi_erosion': float(np.mean(coi_vals)) if coi_vals else 0.0,
'revenue': float(np.mean(revenues)) if revenues else 0.0}
if writer:
for k, v in results[mode].items():
writer.add_scalar(f'{mode}/{k}', v, 0)
print(f"reward={results[mode]['reward_mean']:.2f}, coi={results[mode]['coi_erosion']:.3f}")
if writer:
writer.close()
with open(Path(log_dir) / "reward_mode_results.json", 'w') as f:
json.dump(results, f, indent=2)
return results
def run_alpha_drift_experiment(log_dir: str = "sim/case/thesis_simplified/runs", seed: int = 42) -> Dict:
"""Test policy robustness under non-stationary contamination."""
print("=== Alpha Drift Experiment ===\n")
Path(log_dir).mkdir(parents=True, exist_ok=True)
writer = SummaryWriter(Path(log_dir) / "alpha_drift") if HAS_TB else None
drift_rates = [0.0, 0.01, 0.02, 0.05]
results = {}
for drift in drift_rates:
print(f" drift={drift:.2f}", end=" ")
env_cfg = EnvConfig(
n_products=10, max_steps=200, alpha_true=0.2,
alpha_drift=drift, reward_mode="robust", seed=seed)
env = make_env(env_cfg)
rewards, coi_vals, alpha_errs, _ = run_policy_episode(
env, PolicyRegistry.adaptive, n_episodes=10)
results[f'drift_{drift}'] = {
'reward_mean': float(np.mean(rewards)),
'coi_erosion': float(np.mean(coi_vals)) if coi_vals else 0.0,
'alpha_tracking_error': float(np.mean(alpha_errs)) if alpha_errs else 0.0}
if writer:
for k, v in results[f'drift_{drift}'].items():
writer.add_scalar(f'drift_{drift}/{k}', v, 0)
print(f"reward={results[f'drift_{drift}']['reward_mean']:.2f}, "
f"alpha_err={results[f'drift_{drift}']['alpha_tracking_error']:.3f}")
if writer:
writer.close()
return results
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Run COI experiments")
parser.add_argument("--exp", type=str, default="coi", choices=["coi", "reward", "drift", "all"])
parser.add_argument("--log-dir", type=str, default="sim/case/thesis_simplified/runs")
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
if args.exp == "coi" or args.exp == "all":
run_coi_demonstration(args.log_dir, args.seed)
if args.exp == "reward" or args.exp == "all":
run_reward_mode_comparison(args.log_dir, args.seed)
if args.exp == "drift" or args.exp == "all":
run_alpha_drift_experiment(args.log_dir, args.seed)

View File

@@ -0,0 +1,72 @@
"""Behavioral separability for human/agent detection.
Computes divergence signals delta_H, delta_A from session trajectories using
transition kernel estimation and KL divergence to prototype behavioral profiles.
"""
from __future__ import annotations
from typing import Dict, List, Tuple, TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from .simplified import Event, Session
# prototype behavioral kernels for human vs agent sessions
TRANS_H = {
"start": {"view": 0.85, "end": 0.15},
"view": {"detail": 0.4, "cart": 0.3, "view": 0.2, "end": 0.1},
"detail": {"cart": 0.5, "view": 0.3, "end": 0.2},
"cart": {"purchase": 0.6, "view": 0.25, "end": 0.15},
"purchase": {"end": 1.0},
}
TRANS_A = {
"start": {"view": 0.95, "end": 0.05},
"view": {"detail": 0.6, "view": 0.25, "cart": 0.1, "end": 0.05},
"detail": {"view": 0.5, "cart": 0.15, "detail": 0.3, "end": 0.05},
"cart": {"view": 0.4, "purchase": 0.2, "end": 0.4},
"purchase": {"end": 1.0},
}
def kl_div(p: Dict[str, float], q: Dict[str, float], eps: float = 1e-10) -> float:
"""KL divergence D_KL(p || q) for discrete distributions."""
keys = set(p.keys()) | set(q.keys())
return sum(p.get(k, eps) * np.log((p.get(k, eps) + eps) / (q.get(k, eps) + eps)) for k in keys)
def build_kernel(events: List["Event"]) -> Dict[str, Dict[str, float]]:
"""Build empirical transition kernel T' from trajectory events."""
trans: Dict[str, Dict[str, int]] = {}
prev = "start"
for e in events:
curr = e.action
trans.setdefault(prev, {})
trans[prev][curr] = trans[prev].get(curr, 0) + 1
prev = curr
return {s: {d: c / sum(dsts.values()) for d, c in dsts.items()} for s, dsts in trans.items() if sum(dsts.values()) > 0}
def compute_divergence(session: "Session") -> Tuple[float, float]:
"""Compute divergence signals delta_H, delta_A for session.
delta_H = mean KL(T' || T_H) across states, measures distance to human prototype
delta_A = mean KL(T' || T_A) across states, measures distance to agent prototype
"""
kernel = build_kernel(session.events)
if not kernel:
return 0.5, 0.5
delta_h = sum(kl_div(kernel.get(s, {}), TRANS_H.get(s, {})) for s in kernel) / len(kernel)
delta_a = sum(kl_div(kernel.get(s, {}), TRANS_A.get(s, {})) for s in kernel) / len(kernel)
return delta_h, delta_a
def estimate_alpha(session: "Session", beta: float = 2.0) -> float:
"""Per-session contamination estimate alpha_hat = sigma(beta*(delta_H - delta_A)).
Returns probability session is agent-generated based on behavioral divergence.
"""
dh, da = compute_divergence(session)
if (dh + da) <= 0:
return 0.5
return 1.0 / (1.0 + np.exp(-beta * (dh - da)))

View File

@@ -0,0 +1,219 @@
"""Minimal implementation of thesis pricing system.
Implements the core loop: prices -> sessions -> demand -> prices
with behavioral separability and robust pricing objective.
Objects:
- Session trajectories tau_s from mixture of H/A behavioral profiles
- Demand proxy q_hat via weighted action aggregation
- COI leakage penalty for agent reconnaissance
- Limbo: alternating price/demand history for trajectory analysis
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Tuple
import numpy as np
from .coi import COIWindow, compute_coi_window
from .separability import TRANS_H, TRANS_A, kl_div, build_kernel, compute_divergence, estimate_alpha
ACTION_WEIGHTS = {"add_to_cart": 0.8, "checkout": 0.9, "purchase": 1.0, "view": 0.15, "detail": 0.25, "hover": 0.3, "start": 0.05, "end": 0.0}
@dataclass
class Event:
action: str
product_idx: int
price_seen: float
ts: float
@dataclass
class Session:
sid: str
events: List[Event]
actor: str # H or A (ground truth label)
theta: Dict[str, float] = field(default_factory=dict)
def compute_demand(session: Session) -> float:
"""Compute demand proxy q_hat = sum_k omega(a_k) for session."""
return sum(ACTION_WEIGHTS.get(e.action, 0.1) for e in session.events)
def sample_trajectory(rng: np.random.Generator, trans: Dict, prices: np.ndarray, costs: np.ndarray, theta: Dict[str, float],
is_agent: bool, session_noise: float = 0.02, surge: float = 0.08, max_mult: float = 1.8) -> Tuple[List[Event], int]:
"""Sample session trajectory from behavioral kernel."""
pidx = int(rng.integers(0, len(prices)))
cost, base = float(costs[pidx]), float(prices[pidx]) * (1.0 + rng.normal(0.0, session_noise))
base = float(np.clip(base, cost * 1.01, float(prices[pidx]) * 2.0))
price, signal, state, t = base, 0.0, "start", 0.0
events = []
while state != "end" and len(events) < 30:
probs = trans.get(state, {"end": 1.0})
nxt = rng.choice(list(probs.keys()), p=list(probs.values()))
if nxt == "purchase": # purchase conversion check
rel = max((price - cost) / (cost + 1e-6), 0.0)
p_buy = float(np.clip(theta.get("base_conv", 0.2) * np.exp(-theta.get("price_sens", 2.0) * rel), 0.0, 1.0))
if rng.random() > p_buy:
nxt = "end"
state = nxt
if state not in {"start", "end"}:
events.append(Event(action=state, product_idx=pidx, price_seen=float(price), ts=t))
signal += float(ACTION_WEIGHTS.get(state, 0.1))
price = float(np.clip(base * (1.0 + surge * signal), cost * 1.01, base * max_mult))
t += max(0.2, rng.gamma(1.5, 0.8) if is_agent else rng.gamma(2.0, 1.2))
return events, pidx
def put_prices_to_market(prices: np.ndarray, costs: np.ndarray, alpha: float = 0.2, n_sessions: int = 50,
seed: int | None = None) -> Tuple[List[Session], Dict[str, float]]:
"""Generate sessions from mixture model. Returns sessions and demand mapping sid -> q_hat."""
rng = np.random.default_rng(seed)
sessions, demand = [], {}
for i in range(n_sessions):
sid = f"s{i:04d}"
is_agent = rng.random() < alpha
trans = TRANS_A if is_agent else TRANS_H
theta = {"price_sens": rng.uniform(0.05, 0.2), "base_conv": 0.01} if is_agent else \
{"price_sens": rng.uniform(1.5, 4.0), "base_conv": rng.uniform(0.2, 0.5)}
events, _ = sample_trajectory(rng, trans, prices, costs=costs, theta=theta, is_agent=is_agent)
session = Session(sid=sid, events=events, actor="A" if is_agent else "H", theta=theta)
sessions.append(session)
demand[sid] = compute_demand(session)
return sessions, demand
@dataclass
class LimboUpdate:
utype: str # "prices" or "demand"
data: np.ndarray | Dict[str, float]
t: int
class Limbo:
"""Historical trajectory of alternating price/demand observations."""
def __init__(self):
self.history: List[LimboUpdate] = []
self._t = 0
def add_update(self, utype: str, data: np.ndarray | Dict[str, float]) -> Dict:
self.history.append(LimboUpdate(utype=utype, data=data, t=self._t))
self._t += 1
return {"action": "observe_demand" if utype == "prices" else "set_prices"}
def get_prices_history(self) -> List[np.ndarray]:
return [u.data for u in self.history if u.utype == "prices"]
def get_demand_history(self) -> List[Dict[str, float]]:
return [u.data for u in self.history if u.utype == "demand"]
class System:
"""Main pricing system implementing robust Stackelberg objective.
Manages the alternating loop: set prices p_t -> observe demand Q_hat(p_t) ->
estimate contamination alpha from behavioral signals -> compute next prices.
"""
def __init__(self, n_products: int = 10, costs: np.ndarray | None = None, lambda_coi: float = 0.5, seed: int | None = 42):
self.n = n_products
self.rng = np.random.default_rng(seed)
self.costs = costs if costs is not None else self.rng.uniform(10, 50, n_products)
self.refs = self.costs * (1 + self.rng.uniform(0.2, 0.5, n_products))
self.lambda_coi = lambda_coi
self.limbo = Limbo()
self._alpha_est = 0.2
self._sessions: List[Session] = []
self._last_sessions: List[Session] = []
self._last_coi: COIWindow | None = None
@property
def alpha(self) -> float:
return self._alpha_est
def _estimate_alpha_from_sessions(self) -> float:
if not self._sessions:
return self._alpha_est
return float(np.mean([estimate_alpha(s) for s in self._sessions[-50:]]))
def _revenue_under_demand(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
agg = np.zeros(self.n)
for sid, q in demand.items():
sess = next((s for s in self._sessions if s.sid == sid), None)
if sess and sess.events:
agg[sess.events[0].product_idx] += q
return float(np.dot(prices, agg))
def _compute_coi_window(self, demand: Dict[str, float]) -> COIWindow:
if not self._last_sessions:
zeros = np.zeros(self.n, dtype=float)
return COIWindow(policy=0.0, agent=0.0, leak=0.0, survival_ratio=0.0,
policy_by_product=zeros, agent_by_product=zeros, demand_weights=zeros)
return compute_coi_window(self._last_sessions, self.costs, demand_mapping=demand)
def _objective(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
"""Robust objective: R(p,d) - lambda * COI_leak."""
profit = self._revenue_under_demand(prices, demand) - float(np.sum(self.costs))
self._last_coi = self._compute_coi_window(demand)
return profit - self.lambda_coi * self._last_coi.leak
def compute_prices(self, demand: Dict[str, float] | None = None) -> np.ndarray:
"""Compute next prices via heuristic margin adjustment based on alpha estimate."""
self._alpha_est = self._estimate_alpha_from_sessions()
margin_scale = 1.0 - 0.5 * self._alpha_est # defensive pricing under high contamination
margins = (self.refs - self.costs) * margin_scale
noise = self.rng.normal(0, 0.02, self.n) * self.costs
prices = np.clip(self.costs + margins + noise, self.costs * 1.02, self.refs * 1.3)
self.limbo.add_update("prices", prices)
return prices
def observe_demand(self, prices: np.ndarray, alpha_true: float = 0.2, n_sessions: int = 50) -> Dict[str, float]:
sessions, demand_map = put_prices_to_market(prices, costs=self.costs, alpha=alpha_true,
n_sessions=n_sessions, seed=int(self.rng.integers(0, 10000)))
self._last_sessions = sessions
self._sessions.extend(sessions)
self.limbo.add_update("demand", demand_map)
return demand_map
def step(self, alpha_true: float = 0.2, n_sessions: int = 50) -> Tuple[np.ndarray, Dict[str, float], float, COIWindow]:
demand_hist = self.limbo.get_demand_history()
prices = self.compute_prices(demand_hist[-1] if demand_hist else None)
demand = self.observe_demand(prices, alpha_true, n_sessions)
reward = self._objective(prices, demand)
return prices, demand, reward, self._last_coi or self._compute_coi_window(demand)
def run(self, n_steps: int = 100, alpha_true: float = 0.2) -> Dict:
traj = {"prices": [], "demand": [], "rewards": [], "alpha_est": [], "alpha_true": alpha_true,
"coi_policy": [], "coi_agent": [], "coi_leak": [], "coi_survival": []}
for _ in range(n_steps):
p, d, r, coi = self.step(alpha_true)
traj["prices"].append(p); traj["demand"].append(d); traj["rewards"].append(r)
traj["alpha_est"].append(self._alpha_est)
traj["coi_policy"].append(coi.policy); traj["coi_agent"].append(coi.agent)
traj["coi_leak"].append(coi.leak); traj["coi_survival"].append(coi.survival_ratio)
return traj
if __name__ == "__main__":
sys = System(n_products=5, seed=42)
traj = sys.run(n_steps=20, alpha_true=0.25)
print(f"avg reward: {np.mean(traj['rewards']):.2f}, final alpha_hat: {traj['alpha_est'][-1]:.3f}, "
f"COI_policy: {np.mean(traj['coi_policy']):.3f}, COI_agent: {np.mean(traj['coi_agent']):.3f}, leak: {np.mean(traj['coi_leak']):.3f}")
prices = np.array([20.0, 35.0, 50.0, 25.0, 40.0])
costs = np.array([15.0, 28.0, 40.0, 18.0, 30.0])
sessions, demand = put_prices_to_market(prices, costs=costs, alpha=0.3, n_sessions=20, seed=123)
print(f'sessions: {len(sessions)}, agents: {sum(1 for s in sessions if s.actor=="A")}')
for n in [1, 5, 10, 50, 100]:
# theoretical: erosion = 1 - 2/(N+1) for uniform order statistic
print(f'N={n:3d} agents -> COI erosion: {1.0 - 2.0/(n+1):.3f}')
events = [Event('view', 0, 20.0, 0.1), Event('detail', 0, 20.0, 0.5), Event('cart', 0, 20.0, 1.0), Event('purchase', 0, 20.0, 2.0)]
print(f'human-like session alpha_hat: {estimate_alpha(Session(sid="test", events=events, actor="H")):.3f}')
events_a = [Event('view', 0, 20.0, 0.1), Event('detail', 0, 20.0, 0.2), Event('view', 0, 20.0, 0.3), Event('detail', 0, 20.0, 0.4)]
print(f'agent-like session alpha_hat: {estimate_alpha(Session(sid="test2", events=events_a, actor="A")):.3f}')

View File

@@ -0,0 +1,249 @@
"""Gymnasium-compatible RL environment for thesis pricing system.
Wraps simplified.System with standard Gym interface for training pricing policies.
Supports multiple reward modes and contamination scenarios.
Action: price multipliers [0.5, 1.5] applied to reference prices
Observation: [prices, demand_agg, alpha_est, margins, position_proxy]
Reward: configurable objective (revenue, profit, robust, coi-aware)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Tuple
import numpy as np
try:
import gymnasium as gym
from gymnasium import spaces
HAS_GYM = True
except ImportError:
HAS_GYM = False
from .simplified import System, Session, Event, Limbo, put_prices_to_market, compute_demand, estimate_alpha
from .coi import COIWindow, compute_coi_window, coi_erosion
@dataclass
class EnvConfig:
n_products: int = 5
max_steps: int = 200
sessions_per_step: int = 30
alpha_true: float = 0.2
alpha_drift: float = 0.0
alpha_bounds: Tuple[float, float] = (0.0, 0.6)
lambda_coi: float = 0.5
lambda_vol: float = 0.1
reward_mode: str = "robust" # revenue | profit | robust | coi_aware
normalize_reward: bool = True
seed: int | None = 42
def aggregate_purchases(sessions: list[Session], n_products: int, costs: np.ndarray) -> Tuple[np.ndarray, float, float]:
"""Aggregate purchases from sessions, returns (counts, revenue, cost)."""
purchases = np.zeros(n_products, dtype=float)
revenue, cost = 0.0, 0.0
for sess in sessions:
for e in sess.events:
if e.action == "purchase" and 0 <= e.product_idx < n_products:
purchases[e.product_idx] += 1.0
revenue += float(e.price_seen)
cost += float(costs[e.product_idx])
return purchases, revenue, cost
class PricingEnv(gym.Env if HAS_GYM else object):
"""RL environment for dynamic pricing under agent contamination.
Platform sets prices p_t, market responds with mixture demand Q(p) = (1-alpha)*D_H + alpha*D_A.
Agent estimates contamination alpha_hat from behavioral signals.
Reward balances profit vs COI leakage.
"""
metadata = {"render_modes": ["human", "ansi"]}
def __init__(self, cfg: EnvConfig | None = None):
if not HAS_GYM:
raise ImportError("gymnasium required")
self.cfg = cfg or EnvConfig()
self.n = self.cfg.n_products
self._sys: System | None = None
self._t = 0
self._alpha = self.cfg.alpha_true
self._last_prices: np.ndarray | None = None
self._last_demand: Dict[str, float] | None = None
self._episode_rewards: list[float] = []
self._demand_agg = np.zeros(self.n)
self.action_space = spaces.Box(low=0.5, high=1.5, shape=(self.n,), dtype=np.float32)
obs_dim = self.n + self.n + 1 + 1 + self.n + 1 # prices + demand + alpha_hat + alpha + margins + t
self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32)
def _build_obs(self) -> np.ndarray:
if self._sys is None:
return np.zeros(self.observation_space.shape[0], dtype=np.float32)
prices = self._last_prices if self._last_prices is not None else self._sys.refs
return np.concatenate([
prices / (self._sys.refs + 1e-6),
self._demand_agg / (np.sum(self._demand_agg) + 1e-6),
[self._sys.alpha, self._alpha],
(prices - self._sys.costs) / (self._sys.costs + 1e-6),
[self._t / self.cfg.max_steps],
]).astype(np.float32)
def _compute_reward(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
cfg, sys = self.cfg, self._sys
if sys is None:
return 0.0
# aggregate demand per product
agg = np.zeros(self.n)
for sid, q in demand.items():
sess = next((s for s in sys._sessions if s.sid == sid), None)
if sess and sess.events:
agg[sess.events[0].product_idx] += q
self._demand_agg = agg
_, revenue, cost = aggregate_purchases(sys._last_sessions, self.n, sys.costs)
profit = revenue - cost
vol_penalty = 0.0
if self._last_prices is not None:
vol_penalty = cfg.lambda_vol * float(np.mean(np.abs(prices - self._last_prices) / (sys.refs + 1e-6)))
coi = compute_coi_window(sys._last_sessions, sys.costs, demand_mapping=demand)
leak = float(coi.leak)
reward_fns = {
"revenue": lambda: revenue,
"profit": lambda: profit,
"robust": lambda: profit - cfg.lambda_coi * leak - vol_penalty,
"coi_aware": lambda: profit - cfg.lambda_coi * (1 + 2 * sys.alpha) * leak - vol_penalty,
}
r = reward_fns.get(cfg.reward_mode, lambda: profit)()
return float(r / (float(np.sum(sys.refs)) + 1e-6)) if cfg.normalize_reward else float(r)
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
seed = seed if seed is not None else self.cfg.seed
self._sys = System(n_products=self.n, lambda_coi=self.cfg.lambda_coi, seed=seed)
self._t, self._alpha = 0, self.cfg.alpha_true
self._last_prices, self._last_demand = None, None
self._episode_rewards, self._demand_agg = [], np.zeros(self.n)
return self._build_obs(), {"alpha_true": self._alpha, "alpha_est": self._sys.alpha,
"costs": self._sys.costs.copy(), "refs": self._sys.refs.copy()}
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
if self._sys is None:
raise RuntimeError("call reset() first")
action = np.clip(action, 0.5, 1.5)
prices = np.clip(self._sys.refs * action.astype(np.float64), self._sys.costs * 1.01, self._sys.refs * 2.0)
demand = self._sys.observe_demand(prices, alpha_true=self._alpha, n_sessions=self.cfg.sessions_per_step)
self._sys.limbo.add_update("prices", prices)
self._sys._alpha_est = self._sys._estimate_alpha_from_sessions()
reward = self._compute_reward(prices, demand)
self._episode_rewards.append(reward)
self._last_prices, self._last_demand = prices.copy(), demand
self._t += 1
# compute info metrics using shared helper
purchases, revenue, cost = aggregate_purchases(self._sys._last_sessions, self.n, self._sys.costs)
n_agents = int(self._alpha * self.cfg.sessions_per_step)
coi = compute_coi_window(self._sys._last_sessions, self._sys.costs, demand_mapping=demand)
info = {
"alpha_true": self._alpha, "alpha_est": self._sys.alpha,
"alpha_error": abs(self._alpha - self._sys.alpha),
"revenue": float(revenue), "profit": float(revenue - cost), "cost": float(cost),
"n_purchases": int(np.sum(purchases)),
"avg_margin": float(np.mean((prices - self._sys.costs) / self._sys.costs)),
"n_sessions": len(demand), "n_agents": n_agents, "price_std": float(np.std(prices)),
"coi_erosion": coi_erosion(coi.policy, coi.agent),
"coi_policy": float(coi.policy), "coi_agent": float(coi.agent),
"coi_leakage": float(coi.leak), "coi_survival": float(coi.survival_ratio),
"cumulative_reward": sum(self._episode_rewards), "step": self._t,
}
return self._build_obs(), reward, self._t >= self.cfg.max_steps, False, info
def render(self, mode: str = "human") -> str | None:
if self._sys is None or self._last_prices is None:
return None
out = f"t={self._t}/{self.cfg.max_steps} | alpha_true={self._alpha:.3f} alpha_hat={self._sys.alpha:.3f} | " \
f"prices: {self._last_prices.round(1)} | demand: {self._demand_agg.round(2)} | " \
f"reward: {self._episode_rewards[-1] if self._episode_rewards else 0:.3f}"
if mode == "human":
print(out)
return out
def close(self) -> None:
pass
class ContaminationSweepEnv(PricingEnv):
"""Environment that sweeps through contamination levels during training."""
def __init__(self, cfg: EnvConfig | None = None, alpha_schedule: list[float] | None = None):
super().__init__(cfg)
self._schedule = alpha_schedule or [0.1, 0.2, 0.3, 0.4, 0.5]
self._schedule_idx = 0
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
if options and options.get("advance_schedule", False):
self._schedule_idx = (self._schedule_idx + 1) % len(self._schedule)
self.cfg.alpha_true = self._schedule[self._schedule_idx]
return super().reset(seed, options)
class AdversarialEnv(PricingEnv):
"""Environment with adversarial contamination dynamics.
Contamination increases when prices are predictable (agents exploit).
"""
def __init__(self, cfg: EnvConfig | None = None, exploitation_rate: float = 0.02):
super().__init__(cfg)
self._exploit_rate = exploitation_rate
self._price_history: list[np.ndarray] = []
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
obs, reward, term, trunc, info = super().step(action)
if self._last_prices is not None:
self._price_history.append(self._last_prices.copy())
predictability = 0.0
if len(self._price_history) > 10:
predictability = 1.0 / (float(np.std(self._price_history[-10:])) + 0.1)
self._alpha = np.clip(self._alpha + self._exploit_rate * predictability * self._sys.rng.random(), *self.cfg.alpha_bounds)
info["predictability"] = predictability
return obs, reward, term, trunc, info
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
self._price_history = []
return super().reset(seed, options)
def make_env(cfg: EnvConfig | None = None, env_type: str = "standard") -> PricingEnv:
return {"sweep": ContaminationSweepEnv, "adversarial": AdversarialEnv}.get(env_type, PricingEnv)(cfg)
# baseline policies
fixed_price_policy = lambda refs, margin=0.0: np.ones(len(refs), dtype=np.float32) * (1.0 + margin)
random_policy = lambda n, rng=None: (rng or np.random.default_rng()).uniform(0.7, 1.3, n).astype(np.float32)
adaptive_policy = lambda obs, n, base=0.1: np.ones(n, dtype=np.float32) * (1.0 + base * (1.0 - 0.4 * obs[2 * n]))
if __name__ == "__main__":
cfg = EnvConfig(n_products=100, max_steps=100, alpha_true=0.25, reward_mode="robust")
env = make_env(cfg)
obs, info = env.reset()
print(f"initial: alpha={info['alpha_true']:.2f}")
total_reward = 0.0
for t in range(cfg.max_steps):
action = adaptive_policy(obs, cfg.n_products)
obs, reward, done, _, info = env.step(action)
total_reward += reward
if t % 10 == 0:
env.render()
if done:
break
print(f"\ntotal reward: {total_reward:.2f}, final alpha_hat: {info['alpha_est']:.3f}")

View File

@@ -0,0 +1,168 @@
"""Summarize TensorBoard logs into comparison tables."""
from __future__ import annotations
import json
import re
from pathlib import Path
from collections import defaultdict
from dataclasses import dataclass
import pandas as pd
try:
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
HAS_TB = True
except ImportError:
HAS_TB = False
@dataclass
class RunInfo:
algo: str
alpha: float
reward_mode: str
path: Path
def parse_run_name(name: str) -> RunInfo | None:
"""Extract algo, alpha, reward_mode from run directory name."""
# patterns: ppo_a0.20_robust, cmp_fixed_a0.20, sac_a0.90_robust
m = re.match(r'(cmp_)?(\w+)_a([\d.]+)_?(\w+)?', name)
if not m:
return None
prefix, algo, alpha, mode = m.groups()
return RunInfo(algo=algo, alpha=float(alpha), reward_mode=mode or 'robust', path=Path())
def load_tb_scalars(log_dir: Path, tags: list[str], reduce: str = 'last') -> dict[str, float]:
"""Load scalar values from TensorBoard event files."""
if not HAS_TB:
return {}
ea = EventAccumulator(str(log_dir))
ea.Reload()
results = {}
for tag in tags:
if tag in ea.Tags().get('scalars', []):
events = ea.Scalars(tag)
if not events:
continue
vals = [e.value for e in events]
if reduce == 'last':
results[tag] = vals[-1]
elif reduce == 'mean':
results[tag] = sum(vals) / len(vals)
elif reduce == 'max':
results[tag] = max(vals)
elif reduce == 'min':
results[tag] = min(vals)
return results
def load_json_results(log_dir: Path) -> dict[str, float]:
"""Load metrics from results.json if available."""
results_file = log_dir / 'results.json'
if results_file.exists():
with open(results_file) as f:
return json.load(f)
return {}
def discover_runs(base_dir: Path) -> list[RunInfo]:
"""Find all experiment runs in base directory."""
runs = []
for d in base_dir.iterdir():
if not d.is_dir():
continue
info = parse_run_name(d.name)
if info:
info.path = d
runs.append(info)
return runs
def build_tables(runs: list[RunInfo], metrics: list[str], reduce: str = 'last') -> dict[str, dict[str, pd.DataFrame]]:
"""Build pivot tables: reward_mode -> metric -> DataFrame[alpha x algo]."""
# collect data: {reward_mode: {metric: {(alpha, algo): value}}}
data = defaultdict(lambda: defaultdict(dict))
tb_tags = [f'economics/{m}' if m in ['revenue', 'profit', 'margin'] else f'coi/{m}' if m in ['erosion', 'leakage'] else f'alpha/{m}' for m in metrics]
tag_map = dict(zip(tb_tags, metrics))
for run in runs:
# try json first (final eval metrics)
jm = load_json_results(run.path)
tb = load_tb_scalars(run.path, tb_tags, reduce)
for tag, metric in tag_map.items():
val = None
json_key = f'{metric}_mean' if metric != 'reward' else 'reward_mean'
if json_key in jm:
val = jm[json_key]
elif tag in tb:
val = tb[tag]
if val is not None:
data[run.reward_mode][metric][(run.alpha, run.algo)] = val
# convert to DataFrames
tables = {}
for mode, metrics_data in data.items():
tables[mode] = {}
for metric, vals in metrics_data.items():
if not vals:
continue
alphas = sorted(set(a for a, _ in vals.keys()))
algos = sorted(set(al for _, al in vals.keys()))
df = pd.DataFrame(index=alphas, columns=algos, dtype=float)
for (a, al), v in vals.items():
df.loc[a, al] = v
df.index.name = 'alpha'
tables[mode][metric] = df
return tables
def format_table(df: pd.DataFrame, fmt: str = '.3f') -> str:
"""Format DataFrame as markdown table."""
return df.to_markdown(floatfmt=fmt)
def summarize(base_dir: str = 'sim/case/thesis_simplified/runs',
metrics: list[str] | None = None,
reduce: str = 'last',
output: str | None = None) -> dict:
"""Generate summary tables from experiment runs."""
base = Path(base_dir)
metrics = metrics or ['revenue', 'profit', 'margin', 'erosion', 'leakage']
runs = discover_runs(base)
if not runs:
print(f"No runs found in {base}")
return {}
print(f"Found {len(runs)} runs")
tables = build_tables(runs, metrics, reduce)
lines = []
for mode, metric_tables in sorted(tables.items()):
lines.append(f"\n# Reward Mode: {mode}\n")
for metric, df in sorted(metric_tables.items()):
lines.append(f"\n## {metric}\n")
lines.append(format_table(df))
lines.append("")
report = '\n'.join(lines)
print(report)
if output:
Path(output).write_text(report)
print(f"\nSaved to {output}")
return tables
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser()
p.add_argument('--dir', default='sim/case/thesis_simplified/runs')
p.add_argument('--metrics', nargs='+', default=['revenue', 'profit', 'margin', 'erosion', 'leakage'])
p.add_argument('--reduce', default='last', choices=['last', 'mean', 'max', 'min'])
p.add_argument('--output', '-o', help='save markdown to file')
args = p.parse_args()
summarize(args.dir, args.metrics, args.reduce, args.output)

View File

@@ -0,0 +1,336 @@
"""RL training for thesis pricing system with thesis-aligned metrics.
Trains pricing policies using stable-baselines3 with TensorBoard logging.
Tracks COI erosion, alpha estimation error, and economic KPIs per thesis formulation.
"""
from __future__ import annotations
import argparse
import json
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass, asdict, field
from pathlib import Path
from typing import Dict, List, Callable, Any
import numpy as np
try:
from stable_baselines3 import PPO, SAC, A2C
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
from stable_baselines3.common.vec_env import DummyVecEnv
from stable_baselines3.common.monitor import Monitor
HAS_SB3 = True
except ImportError:
HAS_SB3 = False
try:
from torch.utils.tensorboard import SummaryWriter
HAS_TB = True
except ImportError:
HAS_TB = False
from .simplified_env import PricingEnv, EnvConfig, make_env, adaptive_policy, fixed_price_policy, random_policy
@dataclass
class EpisodeMetrics:
reward: float = 0.0
revenue: float = 0.0
profit: float = 0.0
coi_erosion: float = 0.0
coi_leakage: float = 0.0
alpha_error: float = 0.0
avg_margin: float = 0.0
n_agents: int = 0
steps: int = 0
def accumulate(self, info: Dict[str, Any]) -> None:
self.steps += 1
self.reward += info.get('reward', 0)
self.revenue += info.get('revenue', 0)
self.profit += info.get('profit', 0)
self.coi_erosion += info.get('coi_erosion', 0)
self.coi_leakage += info.get('coi_leakage', 0)
self.alpha_error += abs(info.get('alpha_true', 0) - info.get('alpha_est', 0))
self.avg_margin += info.get('avg_margin', 0)
self.n_agents += info.get('n_agents', 0)
def normalized(self) -> Dict[str, float]:
s = max(self.steps, 1)
return {k: getattr(self, k) / s for k in ['revenue', 'profit', 'coi_erosion', 'coi_leakage', 'alpha_error', 'avg_margin', 'n_agents']}
@dataclass
class ExperimentConfig:
algo: str = "ppo"
total_timesteps: int = 100_000
n_envs: int = 4
eval_freq: int = 5000
n_eval_episodes: int = 10
log_dir: str = "sim/case/thesis_simplified/runs"
seed: int = 42
n_products: int = 10
max_steps: int = 200
alpha_true: float = 0.2
reward_mode: str = "robust"
experiment_name: str | None = None
def __post_init__(self):
if self.experiment_name is None:
self.experiment_name = f"{self.algo}_a{self.alpha_true:.2f}_{self.reward_mode}"
class Policy:
"""Unified policy interface for baselines and trained models."""
def __init__(self, policy_fn: Callable[[np.ndarray, int], np.ndarray], name: str):
self._fn, self.name = policy_fn, name
def predict(self, obs: np.ndarray, deterministic: bool = True) -> tuple[np.ndarray, None]:
return self._fn(obs, (len(obs) - 3) // 3), None
@staticmethod
def fixed(margin: float = 0.15) -> "Policy":
return Policy(lambda obs, n: fixed_price_policy(np.ones(n), margin), f"fixed_{margin:.2f}")
@staticmethod
def adaptive(base_margin: float = 0.15) -> "Policy":
return Policy(lambda obs, n: adaptive_policy(obs, n, base_margin), f"adaptive_{base_margin:.2f}")
@staticmethod
def random() -> "Policy":
return Policy(lambda obs, n: random_policy(n), "random")
@staticmethod
def myopic(greed: float = 0.3) -> "Policy":
def _fn(obs: np.ndarray, n: int) -> np.ndarray:
demand_norm = obs[n:2*n] if len(obs) > 2*n else np.ones(n) * 0.5
return np.ones(n, dtype=np.float32) * np.clip(1.0 + greed * (1 + np.mean(demand_norm)), 0.5, 1.5)
return Policy(_fn, f"myopic_{greed:.1f}")
def log_metrics(writer: SummaryWriter | None, metrics: Dict[str, float], prefix: str, step: int) -> None:
if writer is None:
return
for k, v in metrics.items():
writer.add_scalar(f'{prefix}/{k}', v, step)
class MetricsCallback(BaseCallback):
def __init__(self, writer: SummaryWriter | None, verbose: int = 0):
super().__init__(verbose)
self._writer = writer
def _on_step(self) -> bool:
if self._writer is None:
return True
for info in self.locals.get('infos', []):
t = self.num_timesteps
self._writer.add_scalar('economics/revenue', info.get('revenue', 0), t)
self._writer.add_scalar('economics/profit', info.get('profit', 0), t)
self._writer.add_scalar('economics/margin', info.get('avg_margin', 0), t)
self._writer.add_scalar('coi/erosion', info.get('coi_erosion', 0), t)
self._writer.add_scalar('coi/leakage', info.get('coi_leakage', 0), t)
self._writer.add_scalar('alpha/estimation_error', abs(info.get('alpha_true', 0) - info.get('alpha_est', 0)), t)
self._writer.add_scalar('agents/count', info.get('n_agents', 0), t)
return True
def make_vec_env(cfg: ExperimentConfig, n_envs: int = 1) -> DummyVecEnv:
def _make():
return Monitor(make_env(EnvConfig(n_products=cfg.n_products, max_steps=cfg.max_steps,
alpha_true=cfg.alpha_true, reward_mode=cfg.reward_mode, seed=cfg.seed)))
return DummyVecEnv([_make for _ in range(n_envs)])
def run_episodes(policy: Policy | Any, env: PricingEnv, n_episodes: int) -> List[EpisodeMetrics]:
"""Run policy for n episodes and collect metrics."""
metrics = []
for _ in range(n_episodes):
obs, _ = env.reset()
ep, done = EpisodeMetrics(), False
while not done:
action, _ = policy.predict(obs, deterministic=True)
obs, reward, term, trunc, info = env.step(action)
done = term or trunc
ep.accumulate(info)
ep.reward += reward
metrics.append(ep)
return metrics
def evaluate_policy(policy: Policy | Any, cfg: ExperimentConfig, n_episodes: int = 20) -> Dict[str, float]:
env = make_env(EnvConfig(n_products=cfg.n_products, max_steps=cfg.max_steps,
alpha_true=cfg.alpha_true, reward_mode=cfg.reward_mode, seed=cfg.seed + 999))
metrics = run_episodes(policy, env, n_episodes)
return {
'reward_mean': np.mean([m.reward for m in metrics]), 'reward_std': np.std([m.reward for m in metrics]),
**{f'{k}_mean': np.mean([m.normalized()[k] for m in metrics])
for k in ['revenue', 'profit', 'coi_erosion', 'coi_leakage', 'alpha_error', 'avg_margin']},
}
def run_baseline(policy: Policy, vec_env: DummyVecEnv, total_steps: int, writer: SummaryWriter | None):
obs, n_envs = vec_env.reset(), vec_env.num_envs
ep_rewards = np.zeros(n_envs)
for step in range(0, total_steps, n_envs):
actions = np.array([policy.predict(obs[i])[0] for i in range(n_envs)])
obs, rewards, dones, infos = vec_env.step(actions)
ep_rewards += rewards
for i, info in enumerate(infos):
if writer:
writer.add_scalar('economics/revenue', info.get('revenue', 0), step)
writer.add_scalar('economics/profit', info.get('profit', 0), step)
writer.add_scalar('economics/margin', info.get('avg_margin', 0), step)
writer.add_scalar('coi/erosion', info.get('coi_erosion', 0), step)
writer.add_scalar('coi/leakage', info.get('coi_leakage', 0), step)
writer.add_scalar('alpha/estimation_error', abs(info.get('alpha_true', 0) - info.get('alpha_est', 0)), step)
writer.add_scalar('agents/count', info.get('n_agents', 0), step)
if dones[i]:
if writer:
writer.add_scalar('rollout/ep_reward', ep_rewards[i], step)
ep_rewards[i] = 0
def train(cfg: ExperimentConfig) -> Dict[str, Any]:
is_baseline = cfg.algo.lower() in ["fixed", "adaptive", "random", "myopic"]
if not HAS_SB3 and not is_baseline:
raise ImportError("stable-baselines3 required: pip install stable-baselines3[extra]")
log_path = Path(cfg.log_dir) / cfg.experiment_name
log_path.mkdir(parents=True, exist_ok=True)
with open(log_path / "config.json", "w") as f:
json.dump(asdict(cfg), f, indent=2)
writer = SummaryWriter(log_path) if HAS_TB else None
train_env, eval_env = make_vec_env(cfg, cfg.n_envs), make_vec_env(cfg, 1)
if is_baseline:
policy = {"fixed": Policy.fixed, "adaptive": Policy.adaptive, "random": Policy.random, "myopic": Policy.myopic}[cfg.algo.lower()]()
run_baseline(policy, train_env, cfg.total_timesteps, writer)
final_metrics = evaluate_policy(policy, cfg)
else:
algo_cls = {"ppo": PPO, "sac": SAC, "a2c": A2C}[cfg.algo.lower()]
common = dict(verbose=1, seed=cfg.seed, tensorboard_log=str(log_path), device="auto")
model = {
"ppo": lambda: PPO("MlpPolicy", train_env, learning_rate=3e-4, n_steps=2048, batch_size=64, n_epochs=10, gamma=0.99, gae_lambda=0.95, clip_range=0.2, ent_coef=0.01, **common),
"sac": lambda: SAC("MlpPolicy", train_env, learning_rate=1e-4, buffer_size=50_000, batch_size=512, tau=0.02, gamma=0.99, learning_starts=1000, ent_coef="auto_0.1", train_freq=4, **common),
"a2c": lambda: A2C("MlpPolicy", train_env, learning_rate=7e-4, n_steps=5, gamma=0.99, **common),
}[cfg.algo.lower()]()
cb = MetricsCallback(writer)
eval_cb = EvalCallback(eval_env, best_model_save_path=str(log_path / "best"), log_path=str(log_path),
eval_freq=cfg.eval_freq, n_eval_episodes=cfg.n_eval_episodes, deterministic=True)
model.learn(cfg.total_timesteps, callback=[cb, eval_cb], progress_bar=True)
model.save(log_path / "final_model")
policy = model
final_metrics = evaluate_policy(model, cfg)
if writer:
log_metrics(writer, final_metrics, 'final', cfg.total_timesteps)
writer.close()
train_env.close(); eval_env.close()
with open(log_path / "results.json", "w") as f:
json.dump(final_metrics, f, indent=2)
return {"path": str(log_path), "metrics": final_metrics}
def _train_alpha(args: tuple) -> tuple[str, Dict]:
"""Worker for parallel sweep - must be top-level for pickling."""
cfg_dict, alpha = args
cfg_dict["alpha_true"] = alpha
cfg_dict["experiment_name"] = f"{cfg_dict['algo']}_a{alpha:.2f}_{cfg_dict['reward_mode']}"
sweep_cfg = ExperimentConfig(**cfg_dict)
print(f"[alpha={alpha:.2f}] starting")
metrics = train(sweep_cfg)["metrics"]
print(f"[alpha={alpha:.2f}] done")
return f"alpha_{alpha:.2f}", metrics
def run_sweep(cfg: ExperimentConfig, alphas: List[float] | None = None, max_workers: int | None = None) -> Dict[str, Dict]:
alphas = alphas or [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
cfg_dict = asdict(cfg)
if max_workers == 1: # sequential fallback
results = dict(_train_alpha((cfg_dict.copy(), a)) for a in alphas)
else:
with ProcessPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_train_alpha, (cfg_dict.copy(), a)): a for a in alphas}
results = {}
for fut in as_completed(futures):
key, metrics = fut.result()
results[key] = metrics
summary_path = Path(cfg.log_dir) / f"sweep_{cfg.algo}_{cfg.reward_mode}.json"
with open(summary_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nSweep results saved to {summary_path}")
return results
def _train_policy(args: tuple) -> tuple[str, Dict]:
"""Worker for parallel policy comparison."""
cfg_dict, algo = args
cfg_dict["algo"] = algo
cfg_dict["experiment_name"] = f"cmp_{algo}_a{cfg_dict['alpha_true']:.2f}"
cmp_cfg = ExperimentConfig(**cfg_dict)
print(f"[{algo}] starting")
metrics = train(cmp_cfg)["metrics"]
print(f"[{algo}] done")
return algo, metrics
def compare_policies(cfg: ExperimentConfig, policies: List[str] | None = None, max_workers: int | None = None) -> Dict[str, Dict]:
policies = policies or ["fixed", "adaptive", "myopic", "random"]
cfg_dict = asdict(cfg)
if max_workers == 1:
results = dict(_train_policy((cfg_dict.copy(), p)) for p in policies)
else:
with ProcessPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_train_policy, (cfg_dict.copy(), p)): p for p in policies}
results = {}
for fut in as_completed(futures):
algo, metrics = fut.result()
results[algo] = metrics
cmp_path = Path(cfg.log_dir) / f"compare_a{cfg.alpha_true:.2f}.json"
with open(cmp_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nComparison saved to {cmp_path}")
for algo, m in results.items():
print(f" {algo:12s}: reward={m['reward_mean']:.2f} coi_erosion={m['coi_erosion_mean']:.4f} alpha_err={m['alpha_error_mean']:.4f}")
return results
def main():
parser = argparse.ArgumentParser(description="Train RL pricing policies")
parser.add_argument("--algo", default="ppo", choices=["ppo", "sac", "a2c", "fixed", "adaptive", "random", "myopic"])
parser.add_argument("--steps", type=int, default=100_000)
parser.add_argument("--alpha", type=float, default=0.2)
parser.add_argument("--reward-mode", default="robust", choices=["revenue", "profit", "robust", "coi_aware"])
parser.add_argument("--n-products", type=int, default=10)
parser.add_argument("--n-envs", type=int, default=4)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--log-dir", default="sim/case/thesis_simplified/runs")
parser.add_argument("--sweep", action="store_true", help="run contamination sweep")
parser.add_argument("--compare", action="store_true", help="compare all baselines")
parser.add_argument("--workers", type=int, default=None, help="max parallel workers for sweep (None=auto, 1=sequential)")
args = parser.parse_args()
cfg = ExperimentConfig(algo=args.algo, total_timesteps=args.steps, alpha_true=args.alpha,
reward_mode=args.reward_mode, n_products=args.n_products,
n_envs=args.n_envs, seed=args.seed, log_dir=args.log_dir)
if args.sweep:
run_sweep(cfg, max_workers=args.workers)
elif args.compare:
compare_policies(cfg, max_workers=args.workers)
else:
result = train(cfg)
print(f"\nTraining complete: {result['path']}")
print(f"Metrics: {json.dumps(result['metrics'], indent=2)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,97 @@
import os
import json
from pydantic import BaseModel as Base
class PayloadModel(Base):
sessionId: str
experimentId: str | None
eventName: str
page: str | None
productId: str | None
metadata: dict
storeMode: str
userAgent: str
ts: str
class ValueModel(Base):
payload: PayloadModel
encoding: str
isPayloadNull: bool
schemaId: int
size: int
class InteractionModel(Base):
partitionID: int
offset: int
timestamp: int
compression: str
isTransactional: bool
headers: list
key: dict
value: ValueModel
def _is_admin(page: str | None) -> bool:
return page is not None and page.startswith("/admin/")
class Loader:
def __init__(self, src_dir: str):
self.src_dir = src_dir
self.entries = os.listdir(src_dir)
if not self.entries: raise ValueError("empty directory")
self.data = self._load_sessions()
def _load_sessions(self) -> dict:
sessions = {}
for entry in self.entries:
with open(f"{self.src_dir}/{entry}/int.json") as f:
raw = json.load(f)
ints = [InteractionModel(**i) for i in raw]
sessions[entry] = [i for i in ints if not _is_admin(i.value.payload.page)]
return sessions
def get_data(self) -> dict:
return self.data
def get_entries(self) -> tuple[list[str], int]:
return self.entries, len(self.entries)
class AgentLoader(Loader):
def _load_sessions(self) -> dict:
sessions = {}
for entry in self.entries:
with open(f"{self.src_dir}/{entry}/int.json") as f:
raw = json.load(f)
ints = [PayloadModel(**i) for i in raw]
sessions[entry] = [i for i in ints if not _is_admin(i.page)]
return sessions
class JointLoader:
def __init__(self, human_dir: str, agent_dir: str):
self.human_loader = Loader(human_dir)
self.agent_loader = AgentLoader(agent_dir)
self.data = self._merge()
self.entries = list(self.data.keys())
def _merge(self) -> dict:
return {
**{f"human_{sid}": [e.value.payload for e in evts]
for sid, evts in self.human_loader.get_data().items()},
**{f"agent_{sid}": evts
for sid, evts in self.agent_loader.get_data().items()}
}
def get_data(self) -> dict:
return self.data
def get_entries(self) -> tuple[list[str], int]:
return self.entries, len(self.entries)
if __name__ == "__main__":
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
for name, cls, path in [("agent", AgentLoader, agent_dir),
("human", Loader, human_dir),
("joint", lambda d: JointLoader(human_dir, d), agent_dir)]:
ldr = cls(path) if name != "joint" else cls(agent_dir)
print(f"Loaded {len(ldr.get_entries()[0])} {name} sessions")

View File

@@ -0,0 +1,256 @@
try:
from loader import Loader, AgentLoader, JointLoader
except ImportError:
from sim.rl.behavior_loader.loader import Loader, AgentLoader, JointLoader
from collections import defaultdict
from typing import Dict, List, Tuple, Set
import numpy as np
import graphviz
import sys
from pathlib import Path
# import lib utilities for optional use - models keep their own _state_repr for backwards compat
# with the specific event structure (evt.value.payload)
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'lib'))
try:
from lib.state import make_state_repr as lib_make_state_repr
from lib.features import transition_histogram as lib_transition_histogram
except ImportError:
lib_make_state_repr = None
lib_transition_histogram = None
class BehaviorModel:
def __init__(self, src_dir: str, loader_cls=Loader):
self.loader = loader_cls(src_dir)
self.data = self.loader.get_data()
self.entries, self.num_entries = self.loader.get_entries()
self.mdp = None
def _state_repr(self, evt) -> str:
p = evt.value.payload
return f"{p.page or 'unk'}|{p.productId or 'none'}|{p.eventName}"
def _sort_key(self, evt):
return evt.timestamp
def _extract_sessions(self) -> List[List[str]]:
trajs = []
for evts in self.data.values():
if len(evts) < 2: continue
states = [self._state_repr(e) for e in sorted(evts, key=self._sort_key)]
trajs.append(states)
return trajs
def _calc_transitions(self, trajs: List[List[str]]) -> Tuple[Dict, Set]:
trans, states = defaultdict(lambda: defaultdict(int)), set()
for traj in trajs:
for s, s_next in zip(traj, traj[1:]):
trans[s][s_next] += 1
states.update([s, s_next])
return trans, states
def _calc_rewards(self, trajs: List[List[str]]) -> Dict:
rwd = defaultdict(list)
for traj in trajs:
n = len(traj)
for i, s in enumerate(traj):
rwd[s].append(i / n)
return rwd
def _normalize_trans(self, cnts: Dict) -> Dict:
return {s: {s_n: cnt/sum(nxt.values()) for s_n, cnt in nxt.items()}
for s, nxt in cnts.items()}
def build_MDP(self) -> Dict:
trajs = self._extract_sessions()
trans_cnt, states = self._calc_transitions(trajs)
trans_prob = self._normalize_trans(trans_cnt)
state_rwd = self._calc_rewards(trajs)
self.mdp = {
'states': sorted(states),
'num_states': len(states),
'transitions': trans_prob,
'state_values': {s: np.mean(r) for s, r in state_rwd.items()},
'state_rewards': state_rwd,
'trans_counts': trans_cnt,
}
return self.mdp
def transition_prob(self, s: str, s_next: str) -> float:
if not self.mdp: raise ValueError("build MDP first")
return self.mdp['transitions'].get(s, {}).get(s_next, 0.0)
def state_value(self, s: str) -> float:
if not self.mdp: raise ValueError("build MDP first")
return self.mdp['state_values'].get(s, 0.0)
def sample_traj(self, start: str, max_len: int = 50) -> List[str]:
if not self.mdp: raise ValueError("build MDP first")
path, curr = [start], start
for _ in range(max_len):
nxt = self.mdp['transitions'].get(curr, {})
if not nxt: break
curr = np.random.choice(list(nxt.keys()), p=list(nxt.values()))
path.append(curr)
return path
def extract_trajectory_features(self, events: List, max_trans_dim: int = 50) -> np.ndarray:
"""Convert trajectory to feature vector using MDP structure for contrastive learning"""
if not self.mdp:
self.build_MDP()
states = [self._state_repr(e) for e in sorted(events, key=self._sort_key)]
features = []
# transition histogram over MDP state space
trans_counts = defaultdict(int)
for s, s_next in zip(states, states[1:]):
trans_counts[(s, s_next)] += 1
all_trans = [(s, t) for s in self.mdp['states'] for t in self.mdp['transitions'].get(s, {}).keys()]
trans_vec = [trans_counts.get(tr, 0) for tr in all_trans[:max_trans_dim]]
trans_vec = trans_vec + [0] * (max_trans_dim - len(trans_vec)) # pad
total_trans = sum(trans_counts.values()) or 1
features.extend([v / total_trans for v in trans_vec])
# state coverage ratio
visited = set(states)
features.append(len(visited) / max(self.mdp['num_states'], 1))
# temporal entropy of transitions
if len(states) > 1:
trans_probs = [self.transition_prob(s, s_n) for s, s_n in zip(states, states[1:])]
entropy = -sum(p * np.log(p + 1e-10) for p in trans_probs if p > 0)
features.append(entropy / max(len(states), 1))
else:
features.append(0.0)
# trajectory length and unique state count
features.append(len(states))
features.append(len(visited))
# state value statistics along trajectory
vals = [self.state_value(s) for s in states]
if vals:
features.extend([np.mean(vals), np.std(vals), np.min(vals), np.max(vals)])
else:
features.extend([0.0, 0.0, 0.0, 0.0])
return np.array(features, dtype=np.float32)
class AgentBehaviorModel(BehaviorModel):
def __init__(self, src_dir: str):
super().__init__(src_dir, AgentLoader)
def _state_repr(self, evt) -> str:
return f"{evt.page or 'unk'}|{evt.productId or 'none'}|{evt.eventName}"
def _sort_key(self, evt):
return evt.ts
class JointBehaviorModel(BehaviorModel):
def __init__(self, human_dir: str, agent_dir: str):
self.loader = JointLoader(human_dir, agent_dir)
self.data = self.loader.get_data()
self.entries, self.num_entries = self.loader.get_entries()
self.mdp = None
def _state_repr(self, evt) -> str:
return f"{evt.page or 'unk'}|{evt.productId or 'none'}|{evt.eventName}"
def _sort_key(self, evt):
return evt.ts
def aggregate_event_transitions(mdp: Dict) -> Dict[str, Dict[str, float]]:
evt_trans = defaultdict(lambda: defaultdict(float))
for s, trans in mdp['transitions'].items():
src = s.split('|')[2]
for s_next, prob in trans.items():
dst = s_next.split('|')[2]
evt_trans[src][dst] += prob
for src in evt_trans:
total = sum(evt_trans[src].values())
if total > 0:
evt_trans[src] = {dst: p/total for dst, p in evt_trans[src].items()}
return dict(evt_trans)
def visualize_mdp(model: BehaviorModel, threshold: float = 0.05, output: str = "mdp_graph",
fmt: str = "svg", view: bool = False, export_dot: bool = False):
if not model.mdp: raise ValueError("build MDP first")
evt_trans = aggregate_event_transitions(model.mdp)
g = graphviz.Digraph(format=fmt)
g.attr(rankdir='LR', size='30')
g.attr('node', shape='circle', width='1', height='1')
events = set(evt_trans.keys()) | {e for trans in evt_trans.values() for e in trans.keys()}
for evt in events:
g.node(evt)
for src, dsts in evt_trans.items():
for dst, prob in dsts.items():
if prob > threshold:
g.edge(src, dst, label=f'{prob:.2f}')
g.render(output, view=view, cleanup=True)
print(f"Saved MDP graph to {output}.{fmt}")
if export_dot:
with open(f"{output}.dot", 'w') as f:
f.write(g.source)
print(f"Exported DOT source to {output}.dot")
return g
def kl_divergence(p: Dict[str, float], q: Dict[str, float]) -> float:
eps = 1e-10
# p + log(p / q) summed over all keys in P
return sum((p[k] + eps) * np.log((p[k] + eps) / (q.get(k, 0.0) + eps)) for k in p)
if __name__ == "__main__":
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
human_model = BehaviorModel(human_dir)
human_mdp = human_model.build_MDP()
print(f"Built MDP: {human_mdp['num_states']} states, "
f"{sum(len(t) for t in human_mdp['transitions'].values())} transitions")
if not human_mdp['states']:
exit("No states found")
visualize_mdp(human_model, threshold=0.05, output="human_mdp_viz", fmt="pdf", export_dot=True)
agent_model = AgentBehaviorModel(agent_dir)
agent_mdp = agent_model.build_MDP()
print(f"AGENT... Built MDP: {agent_mdp['num_states']} states, "
f"{sum(len(t) for t in agent_mdp['transitions'].values())} transitions")
if not agent_mdp['states']:
exit("No states found")
visualize_mdp(agent_model, threshold=0.05, output="agent_mdp_viz", fmt="pdf", export_dot=True)
human_evt = aggregate_event_transitions(human_mdp)
agent_evt = aggregate_event_transitions(agent_mdp)
common = set(human_evt.keys()) & set(agent_evt.keys())
if not common:
exit("No common event types for KL divergence analysis")
kl_divs = sorted([(e, kl_divergence(human_evt[e], agent_evt[e])) for e in common],
key=lambda x: x[1], reverse=True)
print(f"Average KL divergence: {np.mean([kl for _, kl in kl_divs]):.4f}")
print("\nMost divergent event types:")
for evt, kl in kl_divs:
print(f" {evt}: {kl:.4f}")
print("\n=== Joint Model (Human + Agent Combined) ===")
joint_model = JointBehaviorModel(human_dir, agent_dir)
joint_mdp = joint_model.build_MDP()
print(f"Built joint MDP: {joint_mdp['num_states']} states, "
f"{sum(len(t) for t in joint_mdp['transitions'].values())} transitions")
if joint_mdp['states']:
visualize_mdp(joint_model, threshold=0.05, output="joint_mdp_viz", fmt="pdf", export_dot=True)

240
sim/rl/engine.py Normal file
View File

@@ -0,0 +1,240 @@
from os import kill
import numpy as np
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any
from sim.rl.environment import BusinessLogicConstraints
"""
An angine by default should have its own demand estimation mechanism from the observed observations whihc are the computer feature.
From these features we then follow the researc hstructure of q -> p with a testable and must be updatable mechanism.
"""
class BasePricingEngine(ABC):
"""base interface for all pricing engines"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
self.c = constraints
self.rng = np.random.default_rng(seed)
self.step_count = 0
@abstractmethod
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
"""compute new prices given current state and observation from environment
args:
current_prices: current price vector [N]
observation: dict containing 'price', 'demand', and possibly interaction data
returns:
new_prices: updated price vector [N]
"""
pass
def update(self, observation: Dict[str, Any], reward: float, done: bool, info: Dict[str, Any]) -> None:
"""Default no-op update. Engines can override as needed."""
self.last_observation = observation
self.last_reward = reward
self.last_info = info
def reset(self):
"""reset engine state for new episode"""
self.step_count = 0
class WildPricingEngine(BasePricingEngine):
"""production-like pricing using online elasticity estimation via EWMA regression"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
super().__init__(constraints, seed)
# per-product unit costs (unknown to customers; known to platform)
self.unit_cost = self.rng.uniform(8.0, 40.0, size=self.c.product_catalogue_size).astype(np.float32)
# online elasticity estimate (start moderately elastic)
self.e_hat = np.full((self.c.product_catalogue_size,), -1.3, dtype=np.float32)
# EWMA state for log-log regression
self.mu_logp = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
self.mu_logq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
self.cov_pq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
self.var_p = np.ones(self.c.product_catalogue_size, dtype=np.float32)
# knobs typical in production
self.lr = 0.08
self.ewma = 0.05
self.eps_explore = 0.03
self.explore_scale = 0.03
def _safe_elasticity(self, e: np.ndarray) -> np.ndarray:
return np.clip(e, -5.0, -1.05)
def reset(self):
super().reset()
self.e_hat = np.full((self.c.product_catelogue_size,), -1.3, dtype=np.float32)
self.mu_logp = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
self.mu_logq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
self.cov_pq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
self.var_p = np.ones(self.c.product_catelogue_size, dtype=np.float32)
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
self.step_count += 1
# extract demand signal (from env observation) as proxy for sales
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
return self._update_from_demand(current_prices, demand)
def _update_from_demand(self, prices: np.ndarray, sold: np.ndarray) -> np.ndarray:
# log transforms (add 1 to handle zeros)
logp = np.log(np.clip(prices, 1e-3, None)).astype(np.float32)
logq = np.log(sold + 1.0).astype(np.float32)
# EWMA moments for per-product regression: logq ≈ a + e*logp
a = self.ewma
dp = logp - self.mu_logp
dq = logq - self.mu_logq
self.mu_logp = (1 - a) * self.mu_logp + a * logp
self.mu_logq = (1 - a) * self.mu_logq + a * logq
self.cov_pq = (1 - a) * self.cov_pq + a * (dp * dq)
self.var_p = (1 - a) * self.var_p + a * (dp * dp + 1e-6)
e_new = self.cov_pq / (self.var_p + 1e-6)
self.e_hat = self._safe_elasticity(0.9 * self.e_hat + 0.1 * e_new)
# profit-optimal price for isoelastic demand (if e < -1)
e = self.e_hat
p_star = self.unit_cost * (e / (e + 1.0))
# smooth toward p_star
new_prices = (1 - self.lr) * prices + self.lr * p_star
# exploration (small random perturbations)
if self.rng.random() < self.eps_explore:
noise = self.rng.normal(0.0, self.explore_scale, size=new_prices.shape).astype(np.float32)
new_prices = new_prices * (1.0 + noise)
# apply business guardrails (max change + bounds)
max_adj = self.c.max_price_adjustment
ratio = np.clip(new_prices / (prices + 1e-6), 1 - max_adj, 1 + max_adj)
new_prices = prices * ratio
new_prices = np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
return new_prices
class StaticPricingEngine(BasePricingEngine):
"""baseline: fixed prices throughout episode"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
super().__init__(constraints, seed)
self.fixed_prices = None
def reset(self):
super().reset()
self.fixed_prices = None
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
self.step_count += 1
if self.fixed_prices is None:
self.fixed_prices = current_prices.copy()
return self.fixed_prices.copy()
class SimpleDemandEngine(BasePricingEngine):
"""demand-driven pricing: increase price when demand rises, decrease when it falls"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
super().__init__(constraints, seed)
self.prev_demand = None
self.lr = 0.05
def reset(self):
super().reset()
self.prev_demand = None
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
self.step_count += 1
demand = _extract_demand(observation, self.c.product_catalogue_size)
if self.prev_demand is None:
self.prev_demand = demand.copy()
return current_prices.copy()
# simple rule: if demand increases, raise price; if decreases, lower price
delta_d = demand - self.prev_demand
price_adj = self.lr * np.sign(delta_d) * np.abs(delta_d) / (np.abs(self.prev_demand) + 1.0)
new_prices = current_prices * (1.0 + price_adj)
self.prev_demand = demand.copy()
# apply constraints
max_adj = self.c.max_price_adjustment
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
new_prices = current_prices * ratio
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
class RandomWalkEngine(BasePricingEngine):
"""random walk pricing with mean reversion"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
super().__init__(constraints, seed)
self.target_price = None
self.volatility = 0.02
def reset(self):
super().reset()
self.target_price = None
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
self.step_count += 1
if self.target_price is None:
self.target_price = current_prices.copy()
# random walk with mean reversion toward target
noise = self.rng.normal(0.0, self.volatility, size=current_prices.shape).astype(np.float32)
reversion = 0.01 * (self.target_price - current_prices)
new_prices = current_prices * (1.0 + noise) + reversion
# apply constraints
max_adj = self.c.max_price_adjustment
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
new_prices = current_prices * ratio
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
class ThompsonSamplingEngine(BasePricingEngine):
"""bayesian bandit approach per product treating price as discrete action"""
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
super().__init__(constraints, seed)
self.n_price_levels = 5
self.alpha = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
self.beta = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
self.price_grid = None
self.last_actions = None
def reset(self):
super().reset()
self.alpha = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
self.beta = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
self.price_grid = None
self.last_actions = None
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
self.step_count += 1
if self.price_grid is None:
# define price grid per product
lo = current_prices * 0.7
hi = current_prices * 1.3
self.price_grid = np.linspace(lo, hi, self.n_price_levels).T
demand = _extract_demand(observation, self.c.product_catalogue_size)
# update beliefs based on last action
if self.last_actions is not None:
for i in range(self.c.product_catalogue_size):
a = self.last_actions[i]
reward = demand[i]
if reward > 0.5:
self.alpha[i, a] += reward
else:
self.beta[i, a] += 1.0
# thompson sampling: sample from posterior, pick best
new_prices = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
actions = np.zeros(self.c.product_catalogue_size, dtype=int)
for i in range(self.c.product_catalogue_size):
theta = self.rng.beta(self.alpha[i], self.beta[i]).astype(np.float32)
actions[i] = int(np.argmax(theta))
new_prices[i] = self.price_grid[i, actions[i]]
self.last_actions = actions
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
def _extract_demand(observation: Dict[str, Any], n: int) -> np.ndarray:
if "elasticity" in observation and isinstance(observation["elasticity"], dict):
d = observation["elasticity"].get("demand")
if d is not None:
return np.asarray(d, dtype=np.float32)
d = observation.get("demand")
if d is not None:
return np.asarray(d, dtype=np.float32)
return np.zeros(n, dtype=np.float32)

View File

@@ -1,451 +1,244 @@
import gymnasium as gym from __future__ import annotations
from gymnasium import spaces
import numpy as np
from dataclasses import dataclass from dataclasses import dataclass
import pandas as pd from typing import Any, Dict, Optional, Tuple
from typing import Callable, Optional, Dict, Any, List
# "learner" agent learning to optimize pricing import numpy as np
# "agent" part of environment creating demand signals that learner processes
try:
import gymnasium as gym
from gymnasium import spaces
except ImportError as e:
raise ImportError("sim.rl.environment requires gymnasium") from e
from sim.case.thesis_simplified.coi import COIWindow, coi_erosion, compute_coi_window
from sim.case.thesis_simplified.separability import estimate_alpha as estimate_session_alpha
from sim.case.thesis_simplified.simplified import Limbo, Session, put_prices_to_market
from sim.rl.thesis_core import aggregate_demand_by_product, aggregate_purchases, constrain_prices
@dataclass(frozen=True)
class BusinessLogicConstraints:
product_catalogue_size: int = 100
max_steps: int = 2000
sessions_per_step: int = 250
@dataclass
class BusinessLogicConstraints():
max_price_adjustment: float = 0.30
system_max_price: float = 500.0 system_max_price: float = 500.0
system_min_price: float = 1.0 system_min_price: float = 1.0
product_catelogue_size: int = 100 max_price_adjustment: float = 0.30
episode_length: int = 200 min_margin_pct: float = 0.05
sessions_per_step: int = 250
agent_share: float = 0.25 agent_share: float = 0.2
agent_recon_multiplier: float = 6.0 alpha_drift: float = 0.0
agent_purchase_probability: float = 0.20 alpha_bounds: tuple[float, float] = (0.0, 0.8)
coi_strength: float = 0.25 coi_strength: float = 0.25
coi_threshold: float = 4.0
coi_sigmoid_temp: float = 1.25
base_human_demand: float = 0.08
base_agent_demand: float = 0.05
human_price_elasticity: float = -1.2
agent_price_elasticity: float = -0.6
w_agent_loss: float = 1.0
w_volatility: float = 5.0 w_volatility: float = 5.0
w_estimation_error: float = 0.25 w_estimation_error: float = 0.25
seed: int = 7 seed: int = 7
def _sigmoid(x: np.ndarray) -> np.ndarray: def make_env(constraints: Optional[BusinessLogicConstraints] = None) -> "PHANTOMEnv":
return 1.0 / (1.0 + np.exp(-x)) return PHANTOMEnv(constraints=constraints or BusinessLogicConstraints())
def simple_agent_detector(session_df: pd.DataFrame) -> pd.Series:
# baseline heuristic: high velocity + low conversion
v = session_df.get("interaction_velocity", pd.Series(0.0, index=session_df.index))
cr = session_df.get("conversion_rate", pd.Series(0.0, index=session_df.index))
total = session_df.get("total_interactions", pd.Series(0, index=session_df.index))
return (total >= 12) & (v >= 0.20) & (cr <= 0.01)
class CommercePlatform:
def __init__(self, product_catelogue_size: int, max_price: float, min_price: float,
constraints: BusinessLogicConstraints, agent_detector: Optional[Callable[[pd.DataFrame], pd.Series]] = None,
use_defense: bool = False):
self.product_catelogue_size = product_catelogue_size
self.max_price = max_price
self.min_price = min_price
self.constraints = constraints
self.use_defense = use_defense
self.agent_detector = agent_detector
self.simulation_history: List[Dict[str, Any]] = []
self._rng = np.random.default_rng(constraints.seed)
self._popularity = self._rng.lognormal(mean=0.0, sigma=0.6, size=self.product_catelogue_size)
self._popularity = self._popularity / (self._popularity.mean() + 1e-12)
self._last_interaction_df: pd.DataFrame = pd.DataFrame()
def setup_true_demand(self, prices: np.ndarray) -> Dict[str, np.ndarray]:
# ground truth purchase propensities
p = np.clip(prices, self.min_price, self.max_price)
pn = p / self.max_price
human_prob = self.constraints.base_human_demand * (pn ** self.constraints.human_price_elasticity)
agent_prob = self.constraints.base_agent_demand * (pn ** self.constraints.agent_price_elasticity)
return {
"human_purchase_prob": np.clip(human_prob * self._popularity, 0.0, 0.95),
"agent_purchase_prob": np.clip(agent_prob * self._popularity, 0.0, 0.95)
}
def _session_markup_multiplier(self, signal_score: float) -> float:
# session-based COI markup based on demand signal expression
x = (signal_score - self.constraints.coi_threshold) / max(self.constraints.coi_sigmoid_temp, 1e-6)
return 1.0 + self.constraints.coi_strength * float(_sigmoid(np.array([x]))[0])
def _simulate_sessions(self, base_prices: np.ndarray) -> pd.DataFrame:
demand = self.setup_true_demand(base_prices)
human_pprob = demand["human_purchase_prob"]
agent_pprob = demand["agent_purchase_prob"]
events: List[Dict[str, Any]] = []
T = self.constraints.sessions_per_step
n_agent_sessions = int(round(T * self.constraints.agent_share))
n_human_sessions = T - n_agent_sessions
# human sessions: normal browse with possible purchase
for s in range(n_human_sessions):
session_id = f"h_{len(events)}_{s}"
k = int(self._rng.integers(1, 4))
prod_ids = self._rng.choice(self.product_catelogue_size, size=k, replace=False)
t = 0.0
inter_times = self._rng.gamma(shape=2.0, scale=3.0, size=3 * k)
signal_score = 0.0
purchased_any = False
for i, pid in enumerate(prod_ids):
t += float(inter_times[i])
price_shown = float(base_prices[pid])
events.append({
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
"action": "view", "t": t, "price_shown": price_shown, "is_purchase": 0,
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
})
signal_score += 1.0
if self._rng.random() < 0.35:
t += float(inter_times[i + k])
events.append({
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
"action": "cart", "t": t, "price_shown": price_shown, "is_purchase": 0,
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
})
signal_score += 2.0
if (not purchased_any) and (self._rng.random() < float(human_pprob[pid])):
t += float(inter_times[i + 2 * k])
mult = self._session_markup_multiplier(signal_score)
price_paid = float(np.clip(base_prices[pid] * mult, self.min_price, self.max_price))
events.append({
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
"action": "purchase", "t": t, "price_shown": float(base_prices[pid]), "is_purchase": 1,
"price_paid": price_paid, "oracle_price_paid": price_paid, "signal_score": signal_score,
})
purchased_any = True
# agent sessions: split recon/purchase to circumvent COI
n_agent_ids = max(1, n_agent_sessions // 2)
for a in range(n_agent_ids):
agent_id = f"a_{a}"
recon_session_id = f"{agent_id}_recon"
t = 0.0
n_views = int(self._rng.poisson(lam=8) * self.constraints.agent_recon_multiplier) + 5
inter_times = self._rng.gamma(shape=2.0, scale=0.6, size=max(n_views, 1))
prod_ids = self._rng.integers(0, self.product_catelogue_size, size=n_views)
recon_signal = 0.0
for i, pid in enumerate(prod_ids):
t += float(inter_times[i])
events.append({
"session_id": recon_session_id, "actor": "agent", "agent_id": agent_id, "product_id": int(pid),
"action": "view", "t": t, "price_shown": float(base_prices[pid]), "is_purchase": 0,
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
})
recon_signal += 1.0
# clean purchase session with minimal interactions
if self._rng.random() < self.constraints.agent_purchase_probability:
purchase_session_id = f"{agent_id}_clean"
pid = int(self._rng.integers(0, self.product_catelogue_size))
t2 = 0.0
clean_signal = 0.0
t2 += float(self._rng.gamma(shape=2.0, scale=0.7))
events.append({
"session_id": purchase_session_id, "actor": "agent", "agent_id": agent_id, "product_id": pid,
"action": "view", "t": t2, "price_shown": float(base_prices[pid]), "is_purchase": 0,
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
})
clean_signal += 1.0
if self._rng.random() < float(agent_pprob[pid]):
t2 += float(self._rng.gamma(shape=2.0, scale=0.7))
obs_mult = self._session_markup_multiplier(clean_signal)
obs_paid = float(np.clip(base_prices[pid] * obs_mult, self.min_price, self.max_price))
oracle_mult = self._session_markup_multiplier(recon_signal) # oracle links recon->purchase
oracle_paid = float(np.clip(base_prices[pid] * oracle_mult, self.min_price, self.max_price))
events.append({
"session_id": purchase_session_id, "actor": "agent", "agent_id": agent_id, "product_id": pid,
"action": "purchase", "t": t2, "price_shown": float(base_prices[pid]), "is_purchase": 1,
"price_paid": obs_paid, "oracle_price_paid": oracle_paid, "signal_score": clean_signal,
})
return pd.DataFrame(events)
def compute_interaction_features(self, interaction_df: pd.DataFrame) -> Dict[str, float]:
if interaction_df.empty:
return {"mean_sale_price": 0.0, "look_to_book": 0.0}
purchases = interaction_df[interaction_df["action"] == "purchase"]
mean_sale_price = float(purchases["price_paid"].mean()) if not purchases.empty else 0.0
views = float((interaction_df["action"] == "view").sum())
buys = float((interaction_df["action"] == "purchase").sum())
return {"mean_sale_price": mean_sale_price, "look_to_book": float(views / (buys + 1e-6))}
def _session_feature_table(self, df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return pd.DataFrame()
g = df.groupby("session_id", sort=False)
session_duration = g["t"].max() - g["t"].min()
total_interactions = g.size()
avg_time_between = g["t"].apply(lambda x: float(np.diff(np.sort(x.to_numpy())).mean()) if len(x) > 1 else 0.0)
interaction_velocity = total_interactions / (session_duration + 1e-6)
views = g.apply(lambda x: int((x["action"] == "view").sum()), include_groups=False)
cart_adds = g.apply(lambda x: int((x["action"] == "cart").sum()), include_groups=False)
purchases = g.apply(lambda x: int((x["action"] == "purchase").sum()), include_groups=False)
conversion_rate = purchases / (views + 1e-6)
is_agent = g["actor"].apply(lambda s: bool((s == "agent").any()), include_groups=False)
return pd.DataFrame({
"session_duration_sec": session_duration.astype(float),
"avg_time_between_events": avg_time_between.astype(float),
"total_interactions": total_interactions.astype(int),
"interaction_velocity": interaction_velocity.astype(float),
"item_views": views.astype(int),
"cart_adds": cart_adds.astype(int),
"purchases": purchases.astype(int),
"conversion_rate": conversion_rate.astype(float),
"is_agent": is_agent.astype(bool),
}).reset_index()
def demand_estimate(self, interaction_df: pd.DataFrame, exclude_sessions: Optional[pd.Series] = None) -> np.ndarray:
# proxy demand from weighted interaction events
if interaction_df.empty:
return np.zeros(self.product_catelogue_size, dtype=np.float32)
df = interaction_df
if exclude_sessions is not None:
bad_sessions = set(exclude_sessions.loc[exclude_sessions].index)
df = df[~df["session_id"].isin(bad_sessions)]
weights = {"view": 0.15, "cart": 0.75, "purchase": 2.5}
w = df["action"].map(weights).fillna(0.0).to_numpy(dtype=float)
prod = df["product_id"].to_numpy(dtype=int)
q_hat = np.zeros(self.product_catelogue_size, dtype=float)
np.add.at(q_hat, prod, w)
return q_hat.astype(np.float32)
def run_pricing_simulation(self, prices: np.ndarray) -> Dict[str, Any]:
interaction_df = self._simulate_sessions(prices)
self._last_interaction_df = interaction_df
session_df = self._session_feature_table(interaction_df)
predicted_agent_sessions = None
if (self.use_defense and self.agent_detector is not None and not session_df.empty):
predicted_agent_sessions = self.agent_detector(session_df.set_index("session_id"))
q_hat_naive = self.demand_estimate(interaction_df, exclude_sessions=None)
q_hat_defended = self.demand_estimate(interaction_df, exclude_sessions=predicted_agent_sessions) \
if predicted_agent_sessions is not None else q_hat_naive.copy()
true_human = np.zeros(self.product_catelogue_size, dtype=float)
true_agent = np.zeros(self.product_catelogue_size, dtype=float)
if not interaction_df.empty:
purchases = interaction_df[interaction_df["action"] == "purchase"]
if not purchases.empty:
for _, r in purchases.iterrows():
if r["actor"] == "human":
true_human[int(r["product_id"])] += 1.0
else:
true_agent[int(r["product_id"])] += 1.0
revenue_observed = float(interaction_df["price_paid"].sum()) if not interaction_df.empty else 0.0
revenue_oracle = float(interaction_df["oracle_price_paid"].sum()) if not interaction_df.empty else 0.0
agent_loss = max(0.0, revenue_oracle - revenue_observed)
eps = 1e-6
internal_error_naive = np.abs(true_human - q_hat_naive) / (true_human + eps)
internal_error_def = np.abs(true_human - q_hat_defended) / (true_human + eps)
interaction_features = self.compute_interaction_features(interaction_df)
summary = {
"prices": prices.copy(),
"interaction_df": interaction_df,
"session_df": session_df,
"q_hat_naive": q_hat_naive,
"q_hat_defended": q_hat_defended,
"true_human_demand": true_human.astype(np.float32),
"true_agent_purchases": true_agent.astype(np.float32),
"internal_error_naive": internal_error_naive.astype(np.float32),
"internal_error_defended": internal_error_def.astype(np.float32),
"interaction_features": interaction_features,
"revenue_observed": revenue_observed,
"revenue_oracle": revenue_oracle,
"agent_loss": agent_loss,
"predicted_agent_sessions": predicted_agent_sessions,
}
self.simulation_history.append(summary)
return summary
def get_interaction_data(self) -> np.ndarray:
if self._last_interaction_df.empty:
return np.array([], dtype=object)
return self._last_interaction_df.to_dict(orient="records")
class PHANTOMEnv(gym.Env): class PHANTOMEnv(gym.Env):
metadata = {"render_modes": []} metadata = {"render_modes": ["human", "ansi"]}
def __init__(self, use_defense: bool = False): def __init__(self, constraints: Optional[BusinessLogicConstraints] = None):
super().__init__() super().__init__()
self.constraints = BusinessLogicConstraints() self.c = constraints or BusinessLogicConstraints()
self.action_space = spaces.Box(low=-self.constraints.max_price_adjustment, self.n = int(self.c.product_catalogue_size)
high=self.constraints.max_price_adjustment,
shape=(self.constraints.product_catelogue_size,), dtype=np.float32) self._rng = np.random.default_rng(self.c.seed)
self.observation_space = spaces.Dict({ self._t = 0
"elasticity": spaces.Dict({ self._alpha_true = float(self.c.agent_share)
"price": spaces.Box( self._alpha_hat = float(self.c.agent_share)
low=np.full((self.constraints.product_catelogue_size,), self.constraints.system_min_price, dtype=np.float32), self._costs = np.zeros(self.n, dtype=np.float32)
high=np.full((self.constraints.product_catelogue_size,), self.constraints.system_max_price, dtype=np.float32), self._refs = np.zeros(self.n, dtype=np.float32)
dtype=np.float32), self._prices: Optional[np.ndarray] = None
"demand": spaces.Box( self._last_sessions: list[Session] = []
low=np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32), self._last_coi: COIWindow | None = None
high=np.full((self.constraints.product_catelogue_size,), 1e6, dtype=np.float32), self._limbo = Limbo()
dtype=np.float32),
}) self.action_space = spaces.Box(
}) low=np.full((self.n,), self.c.system_min_price, dtype=np.float32),
self.commerce_platform = CommercePlatform( high=np.full((self.n,), self.c.system_max_price, dtype=np.float32),
product_catelogue_size=self.constraints.product_catelogue_size, dtype=np.float32,
max_price=self.constraints.system_max_price, )
min_price=self.constraints.system_min_price, self.observation_space = spaces.Dict(
constraints=self.constraints, {
agent_detector=simple_agent_detector, "elasticity": spaces.Dict(
use_defense=use_defense) {
self._rng = np.random.default_rng(self.constraints.seed) "price": spaces.Box(
self.t = 0 low=np.full((self.n,), self.c.system_min_price, dtype=np.float32),
self._prev_prices: Optional[np.ndarray] = None high=np.full((self.n,), self.c.system_max_price, dtype=np.float32),
self.state: Dict[str, Any] = {} dtype=np.float32,
),
"demand": spaces.Box(
low=np.zeros((self.n,), dtype=np.float32),
high=np.full((self.n,), 1e9, dtype=np.float32),
dtype=np.float32,
),
}
),
"market": spaces.Dict(
{
"alpha_hat": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
"revenue_rate": spaces.Box(low=0.0, high=1e12, shape=(1,), dtype=np.float32),
"conversion_rate": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
"price_volatility": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
}
),
"cost": spaces.Box(
low=np.zeros((self.n,), dtype=np.float32),
high=np.full((self.n,), self.c.system_max_price, dtype=np.float32),
dtype=np.float32,
),
}
)
def _reset_catalogue(self) -> None:
self._costs = self._rng.uniform(15.0, 60.0, size=self.n).astype(np.float32)
margins = self._rng.uniform(0.2, 0.6, size=self.n).astype(np.float32)
self._refs = (self._costs * (1.0 + margins)).astype(np.float32)
self._prices = self._refs.copy()
def _observe_market(
self, prices: np.ndarray
) -> tuple[list[Session], Dict[str, float], np.ndarray, np.ndarray, float, float, int]:
sessions, demand_map = put_prices_to_market(
prices,
costs=self._costs,
alpha=self._alpha_true,
n_sessions=int(self.c.sessions_per_step),
seed=int(self._rng.integers(0, 2**31 - 1)),
)
demand_by_product = aggregate_demand_by_product(sessions, demand_map, self.n)
purchases, revenue, cost, n_agents = aggregate_purchases(sessions, self._costs, self.n)
conversion = float(np.sum(purchases) / max(len(sessions), 1))
return sessions, demand_map, demand_by_product, purchases, revenue, cost, n_agents
def _update_alpha_hat(self, sessions: list[Session]) -> float:
scores = [estimate_session_alpha(s) for s in sessions if s.events]
if not scores:
return self._alpha_hat
alpha_step = float(np.mean(scores))
self._alpha_hat = 0.8 * self._alpha_hat + 0.2 * alpha_step
self._alpha_hat = float(np.clip(self._alpha_hat, 0.0, 1.0))
return self._alpha_hat
def _reward(self, prices: np.ndarray, revenue: float, cost: float, volatility: float) -> float:
profit = float(revenue - cost)
coi_leak = float(self._last_coi.leak) if self._last_coi else 0.0
alpha_err = abs(self._alpha_hat - self._alpha_true)
return profit - self.c.coi_strength * coi_leak - self.c.w_volatility * volatility - self.c.w_estimation_error * alpha_err
def _build_obs(
self,
prices: np.ndarray,
demand_by_product: np.ndarray,
revenue: float,
conversion: float,
volatility: float,
) -> Dict[str, Any]:
return {
"elasticity": {"price": prices.astype(np.float32), "demand": demand_by_product.astype(np.float32)},
"market": {
"alpha_hat": np.array([self._alpha_hat], dtype=np.float32),
"revenue_rate": np.array([revenue], dtype=np.float32),
"conversion_rate": np.array([conversion], dtype=np.float32),
"price_volatility": np.array([volatility], dtype=np.float32),
},
"cost": self._costs.astype(np.float32),
}
def reset(self, seed: Optional[int] = None, options: Optional[dict] = None): def reset(self, seed: Optional[int] = None, options: Optional[dict] = None):
super().reset(seed=seed) super().reset(seed=seed)
if seed is not None: if seed is not None:
self._rng = np.random.default_rng(seed) self._rng = np.random.default_rng(seed)
self.commerce_platform._rng = np.random.default_rng(seed) self._t = 0
self.t = 0 self._alpha_true = float(np.clip(self.c.agent_share, *self.c.alpha_bounds))
init_prices = self._rng.uniform(low=60.0, high=140.0, size=(self.constraints.product_catelogue_size,)).astype(np.float32) self._alpha_hat = float(self.c.agent_share)
self._prev_prices = init_prices.copy() self._reset_catalogue()
self.state = { self._limbo = Limbo()
"elasticity": { self._last_sessions = []
"price": init_prices, self._last_coi = None
"demand": np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32),
}
}
return self.state, {}
def step(self, action: np.ndarray): prices = self._prices if self._prices is not None else np.zeros(self.n, dtype=np.float32)
self.t += 1 obs = self._build_obs(prices, np.zeros(self.n, dtype=np.float32), 0.0, 0.0, 0.0)
base_prices = self.state["elasticity"]["price"].astype(np.float32) return obs, {"alpha_true": self._alpha_true}
new_prices = np.clip(base_prices * (1.0 + action.astype(np.float32)),
self.constraints.system_min_price,
self.constraints.system_max_price).astype(np.float32)
result = self.commerce_platform.run_pricing_simulation(new_prices)
if self.commerce_platform.use_defense: def step(self, action: np.ndarray) -> Tuple[Dict[str, Any], float, bool, bool, Dict[str, Any]]:
demand_est = result["q_hat_defended"] if self._prices is None:
internal_err = result["internal_error_defended"] raise RuntimeError("reset() must be called before step()")
else:
demand_est = result["q_hat_naive"]
internal_err = result["internal_error_naive"]
self.state["elasticity"]["price"] = new_prices prev = self._prices
self.state["elasticity"]["demand"] = demand_est prices = constrain_prices(
prev,
np.asarray(action, dtype=np.float32),
costs=self._costs,
min_price=float(self.c.system_min_price),
max_price=float(self.c.system_max_price),
max_adjustment=float(self.c.max_price_adjustment),
min_margin_pct=float(self.c.min_margin_pct),
)
self._prices = prices
self._limbo.add_update("prices", prices)
volatility = 0.0 if self._prev_prices is None else \ sessions, demand_map, demand_by_product, purchases, revenue, cost, n_agents = self._observe_market(prices)
float(np.mean(np.abs((new_prices - self._prev_prices) / (self._prev_prices + 1e-6)))) self._last_sessions = sessions
self._prev_prices = new_prices.copy() self._limbo.add_update("demand", demand_map)
revenue_observed = float(result["revenue_observed"]) self._update_alpha_hat(self._last_sessions)
agent_loss = float(result["agent_loss"]) self._last_coi = compute_coi_window(self._last_sessions, self._costs, demand_mapping=demand_map)
err_mean = float(np.mean(internal_err))
reward = (revenue_observed self._alpha_true = float(np.clip(self._alpha_true + self.c.alpha_drift, *self.c.alpha_bounds))
- self.constraints.w_agent_loss * agent_loss volatility = float(np.std((prices - prev) / (prev + 1e-6)))
- self.constraints.w_volatility * volatility reward = float(self._reward(prices, revenue, cost, volatility))
- self.constraints.w_estimation_error * err_mean) conversion = float(np.sum(purchases) / max(len(self._last_sessions), 1))
terminated = self.t >= self.constraints.episode_length self._t += 1
terminated = self._t >= int(self.c.max_steps)
obs = self._build_obs(prices, demand_by_product, revenue, conversion, min(volatility, 1.0))
info = { info = {
"t": self.t, "step": self._t,
"revenue_observed": revenue_observed, "reward": reward,
"revenue_oracle": float(result["revenue_oracle"]), "revenue": float(revenue),
"agent_loss": agent_loss, "profit": float(revenue - cost),
"ux_volatility": volatility, "n_sessions": int(self.c.sessions_per_step),
"mean_internal_error": err_mean, "n_agents": int(n_agents),
"look_to_book": float(result["interaction_features"].get("look_to_book", 0.0)), "alpha_true": float(self._alpha_true),
"mean_sale_price": float(result["interaction_features"].get("mean_sale_price", 0.0)), "alpha_hat": float(self._alpha_hat),
"true_human_purchases_total": float(np.sum(result["true_human_demand"])), "alpha_error": float(abs(self._alpha_hat - self._alpha_true)),
"true_agent_purchases_total": float(np.sum(result["true_agent_purchases"])), "price_std": float(np.std(prices)),
"price_volatility": float(volatility),
} }
return self.state, float(reward), terminated, False, info if self._last_coi is not None:
info.update(
{
"coi_policy": float(self._last_coi.policy),
"coi_agent": float(self._last_coi.agent),
"coi_leakage": float(self._last_coi.leak),
"coi_survival": float(self._last_coi.survival_ratio),
"coi_erosion": float(coi_erosion(self._last_coi.policy, self._last_coi.agent)),
}
)
return obs, reward, terminated, False, info
def render(self, mode: str = "human") -> str | None:
if self._prices is None:
return None
out = (
f"t={self._t}/{self.c.max_steps} "
f"alpha_true={self._alpha_true:.3f} alpha_hat={self._alpha_hat:.3f} "
f"price_std={float(np.std(self._prices)):.2f}"
)
if mode == "human":
print(out)
return out
if __name__ == "__main__": def close(self) -> None:
import matplotlib.pyplot as plt return
from collections import defaultdict
runs = {}
for use_defense in (False, True):
env = PHANTOMEnv(use_defense=use_defense)
obs, _ = env.reset(seed=42)
metrics = defaultdict(list)
total_reward = 0.0
done = False
while not done:
action = env.action_space.sample()
obs, reward, done, _, info = env.step(action)
total_reward += reward
p_mean = float(np.mean(obs["elasticity"]["price"]))
q_mean = float(np.mean(obs["elasticity"]["demand"]))
p_std = float(np.std(obs["elasticity"]["price"]))
metrics['t'].append(info['t'])
metrics['price_mean'].append(p_mean)
metrics['price_std'].append(p_std)
metrics['demand_mean'].append(q_mean)
metrics['revenue_observed'].append(info['revenue_observed'])
metrics['revenue_oracle'].append(info['revenue_oracle'])
metrics['agent_loss'].append(info['agent_loss'])
metrics['ux_volatility'].append(info['ux_volatility'])
metrics['look_to_book'].append(info['look_to_book'])
metrics['reward'].append(reward)
metrics['human_purchases'].append(info['true_human_purchases_total'])
metrics['agent_purchases'].append(info['true_agent_purchases_total'])
if info['t'] % 20 == 0 or done:
print(f"defense={'ON ' if use_defense else 'OFF'} t={info['t']:03d} p={p_mean:6.2f}±{p_std:4.2f} "
f"q={q_mean:6.2f} rev={info['revenue_observed']:7.2f} oracle={info['revenue_oracle']:7.2f} "
f"loss={info['agent_loss']:6.2f} ux={info['ux_volatility']:.3f} "
f"ltb={info['look_to_book']:5.2f} r={reward:7.2f}")
runs[use_defense] = metrics
print(f"defense={'ON ' if use_defense else 'OFF'} total_reward={total_reward:.2f}\n")
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
fig.suptitle('PHANTOM Environment: Defense OFF vs ON', fontsize=14, fontweight='bold')
plot_configs = [
('price_mean', 'Mean Price', 'Price'),
('demand_mean', 'Mean Demand Estimate', 'Demand'),
('revenue_observed', 'Revenue (Observed)', 'Revenue'),
('agent_loss', 'Agent Loss (Oracle - Observed)', 'Loss'),
('ux_volatility', 'UX Volatility (Price Change)', 'Volatility'),
('look_to_book', 'Look-to-Book Ratio', 'Ratio'),
('reward', 'Step Reward', 'Reward'),
('human_purchases', 'Human Purchases', 'Count'),
('agent_purchases', 'Agent Purchases', 'Count'),
]
for idx, (key, title, ylabel) in enumerate(plot_configs):
ax = axes[idx // 3, idx % 3]
for use_defense, label, color in [(False, 'No Defense', 'red'), (True, 'With Defense', 'blue')]:
m = runs[use_defense]
ax.plot(m['t'], m[key], label=label, color=color, alpha=0.7, linewidth=1.5)
ax.set_xlabel('Step')
ax.set_ylabel(ylabel)
ax.set_title(title, fontsize=10, fontweight='bold')
ax.legend(loc='best', fontsize=8)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('phantom_env_comparison.png', dpi=150, bbox_inches='tight')
print("Plot saved to phantom_env_comparison.png")
plt.show()

View File

@@ -0,0 +1,11 @@
"""JAX-accelerated simulation core for PHANTOM environment."""
from .transitions import TransitionData, compile_transitions, fallback_transitions, JAX_AVAILABLE
from .simulation import SessionBatch, SimResult, sample_sessions, compute_metrics
from .features import session_features, compute_session_transitions
from .separability import compute_divergences, estimate_alpha_batch
__all__ = [
"JAX_AVAILABLE", "TransitionData", "compile_transitions", "fallback_transitions",
"SessionBatch", "SimResult", "sample_sessions", "compute_metrics",
"session_features", "compute_session_transitions", "compute_divergences", "estimate_alpha_batch",
]

View File

@@ -0,0 +1,69 @@
"""Vectorized session feature extraction."""
import numpy as np
from .transitions import N_STATES, PURCHASE_IDX, CART_IDX
from .simulation import SessionBatch
try:
import jax.numpy as jnp
from jax import jit
JAX_AVAILABLE = True
except ImportError:
jnp, JAX_AVAILABLE = np, False
def jit(f): return f
@jit
def extract_features(states, dwells, lengths):
"""Extract per-session features. Returns (n_sess, 9) array."""
n, max_len = states.shape
mask = jnp.arange(max_len)[None,:] < lengths[:,None]
duration = jnp.sum(dwells * mask, axis=1)
total = lengths.astype(jnp.float32)
count = lambda idx: jnp.sum((states == idx) & mask, axis=1).astype(jnp.float32)
views, learn, carts, purchases = count(1), count(2), count(3), count(4)
velocity = total / (duration + 1e-6)
conversion = purchases / (views + 1e-6)
avg_dwell = duration / (total + 1e-6)
return jnp.stack([duration, avg_dwell, total, velocity, views, carts, purchases, learn, conversion], axis=1)
def session_features(batch: SessionBatch) -> np.ndarray:
if JAX_AVAILABLE:
return np.asarray(extract_features(jnp.array(batch.states), jnp.array(batch.dwells), jnp.array(batch.lengths)))
# numpy fallback
n, max_len = batch.states.shape
mask = np.arange(max_len)[None,:] < batch.lengths[:,None]
duration = np.sum(batch.dwells * mask, axis=1)
total = batch.lengths.astype(np.float32)
count = lambda idx: np.sum((batch.states == idx) & mask, axis=1).astype(np.float32)
views, learn, carts, purchases = count(1), count(2), count(3), count(4)
return np.stack([duration, duration/(total+1e-6), total, total/(duration+1e-6), views, carts, purchases, learn, purchases/(views+1e-6)], axis=1)
@jit
def session_transitions(states, lengths, n_states=N_STATES):
"""Compute empirical transition counts per session. Returns (n_sess, n_states, n_states)."""
n, max_len = states.shape
mask = jnp.arange(max_len - 1)[None,:] < (lengths[:,None] - 1)
src, dst = states[:, :-1], states[:, 1:]
# handle -1 padding by clamping to valid range
src_c, dst_c = jnp.clip(src, 0, n_states-1), jnp.clip(dst, 0, n_states-1)
valid = mask & (src >= 0) & (dst >= 0)
def per_session(i):
s, d, v = src_c[i], dst_c[i], valid[i]
trans = (jnp.eye(n_states)[s,:,None] * jnp.eye(n_states)[d,None,:]).sum(0) * v[:,None,None]
return trans.sum(0)
# vmap not ideal here, use manual loop for clarity
trans = jnp.stack([per_session(i) for i in range(n)])
row_sums = trans.sum(axis=-1, keepdims=True)
return trans / (row_sums + 1e-10)
def compute_session_transitions(batch: SessionBatch) -> np.ndarray:
if JAX_AVAILABLE:
return np.asarray(session_transitions(jnp.array(batch.states), jnp.array(batch.lengths)))
# numpy fallback
n, max_len = batch.states.shape
trans = np.zeros((n, N_STATES, N_STATES), dtype=np.float32)
for i in range(n):
for t in range(batch.lengths[i] - 1):
s, d = batch.states[i, t], batch.states[i, t+1]
if s >= 0 and d >= 0: trans[i, s, d] += 1
row_sums = trans.sum(axis=-1, keepdims=True)
return trans / (row_sums + 1e-10)

View File

@@ -0,0 +1,43 @@
"""Vectorized KL divergence for separability scoring."""
import numpy as np
from typing import Tuple
try:
import jax.numpy as jnp
from jax import jit
JAX_AVAILABLE = True
except ImportError:
jnp, JAX_AVAILABLE = np, False
def jit(f): return f
@jit
def batch_kl(P, Q_human, Q_agent, eps=1e-10):
"""Compute KL(P||Q) for batched P. P:(n,s,s), Q:(s,s). Returns (delta_h, delta_a) each (n,)."""
p = P + eps
p = p / p.sum(axis=-1, keepdims=True)
qh, qa = Q_human[None] + eps, Q_agent[None] + eps
delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2))
delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2))
return delta_h, delta_a
def compute_divergences(session_trans: np.ndarray, ref_human: np.ndarray, ref_agent: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Compute KL divergence of each session from human/agent prototypes."""
if JAX_AVAILABLE:
dh, da = batch_kl(jnp.array(session_trans), jnp.array(ref_human), jnp.array(ref_agent))
return np.asarray(dh), np.asarray(da)
# numpy fallback
eps = 1e-10
p = session_trans + eps
p = p / p.sum(axis=-1, keepdims=True)
qh, qa = ref_human[None] + eps, ref_agent[None] + eps
delta_h = np.sum(p * np.log(p / qh), axis=(1, 2))
delta_a = np.sum(p * np.log(p / qa), axis=(1, 2))
return delta_h, delta_a
def estimate_alpha_batch(prob_agent: np.ndarray, delta_h: np.ndarray, delta_a: np.ndarray, temp: float = 1.0) -> np.ndarray:
"""Vectorized alpha estimation from classifier probs and divergences."""
mass = delta_h + delta_a
ratio = np.where(mass > 1e-8, delta_a / mass, 0.5)
blended = 0.5 * prob_agent + 0.5 * ratio
if temp <= 0: return np.clip(blended, 0.0, 1.0)
return np.clip(1.0 / (1.0 + np.exp(-temp * (blended - 0.5))), 0.0, 1.0)

View File

@@ -0,0 +1,116 @@
"""Vectorized Markov chain session sampling with JAX."""
from typing import NamedTuple, Tuple
import numpy as np
from functools import partial
try:
import jax, jax.numpy as jnp
from jax import lax
JAX_AVAILABLE = True
except ImportError:
JAX_AVAILABLE = False
from .transitions import TransitionData, N_STATES, TERM_IDX, PURCHASE_IDX, CART_IDX
class SessionBatch(NamedTuple):
states: np.ndarray # (n_sess, max_len) state indices, -1=padding
dwells: np.ndarray # (n_sess, max_len) dwell times
products: np.ndarray # (n_sess,) product index per session
actors: np.ndarray # (n_sess,) 0=human, 1=agent
lengths: np.ndarray # (n_sess,) actual session length
class SimResult(NamedTuple):
demand_human: np.ndarray
demand_agent: np.ndarray
revenue: float
revenue_oracle: float
agent_loss: float
coi: float
look_to_book: float
mean_sale_price: float
n_human_purchases: int
n_agent_purchases: int
sessions: SessionBatch
if JAX_AVAILABLE:
@partial(jax.jit, static_argnums=(5,6,7))
def _sample_sessions_jax(key, T_human, T_agent, dwell_human, dwell_agent, n_human, n_agent, max_steps):
n = n_human + n_agent
k1, k2, k3, k4 = jax.random.split(key, 4)
actors = jnp.concatenate([jnp.zeros(n_human, dtype=jnp.int32), jnp.ones(n_agent, dtype=jnp.int32)])
T = jnp.where(actors[:,None,None]==0, T_human[None], T_agent[None]) # (n,6,6)
dwell_p = jnp.where(actors[:,None,None]==0, dwell_human[None], dwell_agent[None]) # (n,6,2)
def step(carry, _):
s, active, k = carry
k, k1, k2 = jax.random.split(k, 3)
probs = T[jnp.arange(n), s] # (n,6)
nxt = jax.random.categorical(k1, jnp.log(probs + 1e-10))
nxt = jnp.where(active, nxt, -1)
shape = dwell_p[jnp.arange(n), s, 0]
scale = dwell_p[jnp.arange(n), s, 1]
dwell = jnp.maximum(0.3, jax.random.gamma(k2, shape) * scale)
still = active & (nxt != TERM_IDX) & (nxt >= 0)
return (nxt, still, k), (nxt, dwell)
init = (jnp.zeros(n, dtype=jnp.int32), jnp.ones(n, dtype=jnp.bool_), k3)
_, (states, dwells) = lax.scan(step, init, None, length=max_steps)
states, dwells = states.T, dwells.T # (n, max_steps)
is_term = (states == -1) | (states == TERM_IDX)
lengths = jnp.argmax(is_term, axis=1) + 1
lengths = jnp.where(jnp.any(is_term, axis=1), lengths, max_steps)
return states, dwells, actors, lengths
def sample_sessions(key, trans: TransitionData, n_human: int, n_agent: int, n_products: int, max_steps: int = 40) -> SessionBatch:
if JAX_AVAILABLE:
k1, k2 = jax.random.split(key)
states, dwells, actors, lengths = _sample_sessions_jax(k1, trans.human_T, trans.agent_T, trans.human_dwell, trans.agent_dwell, n_human, n_agent, max_steps)
products = jax.random.randint(k2, (n_human + n_agent,), 0, n_products)
return SessionBatch(np.asarray(states), np.asarray(dwells), np.asarray(products), np.asarray(actors), np.asarray(lengths))
# numpy fallback
rng = np.random.default_rng(int(key[0]) if hasattr(key, '__getitem__') else 42)
n = n_human + n_agent
actors = np.concatenate([np.zeros(n_human, dtype=np.int32), np.ones(n_agent, dtype=np.int32)])
products = rng.integers(0, n_products, size=n)
states, dwells = np.full((n, max_steps), -1, dtype=np.int32), np.zeros((n, max_steps), dtype=np.float32)
lengths = np.zeros(n, dtype=np.int32)
for i in range(n):
T = trans.human_T if actors[i] == 0 else trans.agent_T
dp = trans.human_dwell if actors[i] == 0 else trans.agent_dwell
s, t = 0, 0
while t < max_steps and s != TERM_IDX:
states[i, t] = s
dwells[i, t] = max(0.3, rng.gamma(dp[s, 0], dp[s, 1]))
s = rng.choice(N_STATES, p=T[s])
t += 1
lengths[i] = t
return SessionBatch(states, dwells, products, actors, lengths)
def compute_metrics(batch: SessionBatch, prices: np.ndarray, unit_cost: np.ndarray, base_price: np.ndarray) -> SimResult:
purchased = np.any(batch.states == PURCHASE_IDX, axis=1)
human_mask, agent_mask = batch.actors == 0, batch.actors == 1
human_purch, agent_purch = purchased & human_mask, purchased & agent_mask
demand_h = np.bincount(batch.products[human_purch], minlength=len(prices)).astype(np.float32)
demand_a = np.bincount(batch.products[agent_purch], minlength=len(prices)).astype(np.float32)
# revenue and oracle
purch_products = batch.products[purchased]
revenue = float(np.sum(prices[purch_products]))
revenue_oracle = float(np.sum(base_price[purch_products]))
# agent loss: base_price - price_paid for agent purchases (agents gaming the system)
agent_products = batch.products[agent_purch]
agent_loss = float(np.sum(base_price[agent_products] - prices[agent_products]))
# COI: margin - expected_premium*0.5 for human purchases
human_products = batch.products[human_purch]
if len(human_products) > 0:
margin = float(np.mean(prices[human_products] - unit_cost[human_products]))
premium = float(np.mean(base_price[human_products] - prices[human_products]))
coi = max(0.0, margin - premium * 0.5)
else:
coi = 0.0
# look to book: views / purchases
views = float(np.sum(batch.states == 1)) # view_item_page = index 1
n_purch = int(purchased.sum())
look_to_book = views / (n_purch + 1e-6)
mean_sale = float(np.mean(prices[purch_products])) if n_purch > 0 else 0.0
return SimResult(demand_h, demand_a, revenue, revenue_oracle, agent_loss, coi, look_to_book, mean_sale,
int(human_purch.sum()), int(agent_purch.sum()), batch)

View File

@@ -0,0 +1,47 @@
"""Dense transition matrices for JAX Markov chain sampling."""
from dataclasses import dataclass
import numpy as np
try:
import jax.numpy as jnp
JAX_AVAILABLE = True
except ImportError:
jnp, JAX_AVAILABLE = np, False
STATES = ["session_start", "view_item_page", "learn_more_about_item", "add_item_to_cart", "purchase_complete", "session_end"]
S2I = {s: i for i, s in enumerate(STATES)}
N_STATES, TERM_IDX, PURCHASE_IDX, CART_IDX = len(STATES), 5, 4, 3
@dataclass
class TransitionData:
human_T: np.ndarray # (6,6) transition probs
agent_T: np.ndarray # (6,6)
human_dwell: np.ndarray # (6,2) shape,scale
agent_dwell: np.ndarray # (6,2)
def to_jax(self):
if not JAX_AVAILABLE: return self
return TransitionData(*[jnp.array(x) for x in [self.human_T, self.agent_T, self.human_dwell, self.agent_dwell]])
def dict_to_dense(d):
m = np.zeros((N_STATES, N_STATES), dtype=np.float32)
for src, dsts in d.items():
if (i := S2I.get(src)) is not None:
for dst, p in dsts.items():
if (j := S2I.get(dst)) is not None: m[i,j] = p
m /= np.maximum(m.sum(1, keepdims=True), 1e-8)
m[TERM_IDX] = 0; m[TERM_IDX, TERM_IDX] = 1.0
return m
def compile_transitions(human_profile, agent_profile):
def dwell_arr(params): return np.array([[params.get(s, (2.0, 1.0)) for s in STATES]], dtype=np.float32).reshape(N_STATES, 2)
return TransitionData(dict_to_dense(human_profile.transitions), dict_to_dense(agent_profile.transitions),
dwell_arr(human_profile.dwell_params), dwell_arr(agent_profile.dwell_params))
def fallback_transitions():
H = {"session_start": {"view_item_page": .85, "session_end": .15}, "view_item_page": {"learn_more_about_item": .4, "add_item_to_cart": .3, "view_item_page": .2, "session_end": .1},
"learn_more_about_item": {"add_item_to_cart": .5, "view_item_page": .3, "session_end": .2}, "add_item_to_cart": {"purchase_complete": .6, "view_item_page": .25, "session_end": .15}, "purchase_complete": {"session_end": 1.0}}
A = {"session_start": {"view_item_page": .9, "session_end": .1}, "view_item_page": {"learn_more_about_item": .5, "add_item_to_cart": .25, "view_item_page": .15, "session_end": .1},
"learn_more_about_item": {"add_item_to_cart": .4, "view_item_page": .4, "session_end": .2}, "add_item_to_cart": {"purchase_complete": .5, "view_item_page": .3, "session_end": .2}, "purchase_complete": {"session_end": 1.0}}
dwell = np.full((N_STATES, 2), [2.0, 1.0], dtype=np.float32)
return TransitionData(dict_to_dense(H), dict_to_dense(A), dwell.copy(), dwell.copy())

175
sim/rl/train.py Normal file
View File

@@ -0,0 +1,175 @@
import numpy as np
import logging
from pathlib import Path
from typing import Dict, Type, Optional
import pickle
from torch.utils.tensorboard import SummaryWriter
from sim.rl.environment import PHANTOMEnv, BusinessLogicConstraints
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
try:
from sim.rl.engine import (BasePricingEngine, WildPricingEngine, StaticPricingEngine,
SimpleDemandEngine, RandomWalkEngine, ThompsonSamplingEngine)
except ImportError as e:
BasePricingEngine = None # engines not required for basic usage
print(e)
"""
Target training loop:
have base prices p0 from env reset and run the env step, collect reward and metrics
pass this to the pricing engine which computes the price action to take based on previous reward by learning
the new action gets passed to the step
so we alternate, step -> reward -> engine (produces price delta) -> step with price delta -> reward
to make sure the reinforcement learning inside the engine can learn we need to have trajectory of prices
CURRENT SOLUTION BELOW does not implement correct learning or updates.
"""
class EngineTrainer:
"""wrapper to run pricing engines through episodes and collect metrics"""
def __init__(self, engine, env: PHANTOMEnv, tb_writer: Optional[SummaryWriter] = None):
self.engine = engine
self.env = env
self.episode_metrics = []
self.tb_writer = tb_writer
self.global_step = 0
def train(self, n_episodes: int, seed: int = 42):
for ep in range(n_episodes):
obs, _ = self.env.reset(seed=seed + ep)
self.engine.reset()
done = False
prev_prices = obs["elasticity"]["price"]
episode_reward = 0.0
last_info: Dict[str, float] = {}
while not done:
action_prices = self.engine.compute_prices(prev_prices, obs)
obs, reward, done, _, info = self.env.step(action_prices)
self.engine.update(obs, reward, done, info)
episode_reward += reward
prev_prices = obs["elasticity"]["price"]
last_info = info
if self.tb_writer:
self.tb_writer.add_scalar("reward/step", reward, self.global_step)
if "coi" in info:
self.tb_writer.add_scalar("diagnostics/coi", info["coi"], self.global_step)
if "alpha_hat" in info:
self.tb_writer.add_scalar("diagnostics/alpha_hat", info["alpha_hat"], self.global_step)
self.global_step += 1
last_info = dict(last_info)
last_info.update({"episode_reward": episode_reward, "episode": ep})
self.episode_metrics.append(last_info)
if self.tb_writer:
self.tb_writer.add_scalar("reward/episode", episode_reward, ep)
return self
def run_episode(self, seed: int = 42) -> Dict:
"""run single evaluation episode and return metrics"""
obs, _ = self.env.reset(seed=seed)
self.engine.reset()
total_reward = 0.0
prev_prices = obs["elasticity"]["price"]
ep_metrics = {'total_reward': 0.0}
done = False
while not done:
action_prices = self.engine.compute_prices(prev_prices, obs)
obs, reward, done, _, info = self.env.step(action_prices)
total_reward += reward
for k, v in info.items():
ep_metrics[k] = v
prev_prices = obs["elasticity"]["price"]
ep_metrics['total_reward'] = total_reward
return ep_metrics
def evaluate(self, n_episodes: int = 10, seed: int = 100) -> Dict:
"""evaluate trained engine"""
results = {k: [] for k in ['total_reward', 'revenue_observed', 'revenue_oracle',
'agent_loss', 'ux_volatility', 'look_to_book']}
for ep in range(n_episodes):
metrics = self.run_episode(seed=seed + ep)
for k in results:
results[k].append(metrics.get(k, 0.0))
return {k: (np.mean(v), np.std(v)) for k, v in results.items()}
def make_env():
return PHANTOMEnv(constraints=BusinessLogicConstraints())
def train_engine(engine_cls, env: PHANTOMEnv, n_episodes: int, seed: int = 42,
tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
constraints = env.constraints
engine = engine_cls(constraints=constraints, seed=seed)
trainer = EngineTrainer(engine, env, tb_writer=tb_writer)
trainer.train(n_episodes, seed=seed)
return trainer
def save_trainer(trainer: EngineTrainer, path: Path):
"""save engine state and metrics"""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'wb') as f:
pickle.dump({'engine': trainer.engine, 'metrics': trainer.episode_metrics}, f)
logger.info(f"Saved trainer to {path}")
def load_trainer(path: Path, env: PHANTOMEnv, tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
"""load saved engine"""
with open(path, 'rb') as f:
data = pickle.load(f)
trainer = EngineTrainer(data['engine'], env, tb_writer=tb_writer)
trainer.episode_metrics = data['metrics']
return trainer
if __name__ == "__main__":
if BasePricingEngine is None:
logger.error("Engines not available, cannot run training")
exit(1)
base_dir = Path("./sim/rl/runs")
base_dir.mkdir(exist_ok=True)
engines = {
"Wild": WildPricingEngine,
"Static": StaticPricingEngine,
"RandomWalk": RandomWalkEngine,
"ThompsonSampling": ThompsonSamplingEngine,
}
n_train_episodes = 50
n_eval_episodes = 10
seed = 42
logger.info(f"Training config: {n_train_episodes} episodes per engine")
trained_trainers = {}
for engine_name, engine_cls in engines.items():
run_name = engine_name
log_dir = base_dir / run_name
log_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Training {engine_name}")
logger.info(f"Log directory: {log_dir}")
env = make_env()
tb_writer = SummaryWriter(log_dir=str(log_dir))
trainer = train_engine(engine_cls, env, n_train_episodes, seed, tb_writer=tb_writer)
tb_writer.close()
save_path = log_dir / "trainer.pkl"
save_trainer(trainer, save_path)
trained_trainers[run_name] = (trainer, env)
logger.info("Starting evaluation")
for run_name, (trainer, env) in trained_trainers.items():
logger.info(f"Evaluating {run_name}")
results = trainer.evaluate(n_episodes=n_eval_episodes, seed=seed + 1000)
for metric, (mean, std) in results.items():
logger.info(f" {metric:20s}: {mean:10.2f} ± {std:6.2f}")
logger.info(f"Results saved to: {base_dir}")

108
sim/strong_learner/data.py Normal file
View File

@@ -0,0 +1,108 @@
import os
import requests
try:
import py7zr # type: ignore
except ImportError: # pragma: no cover - optional dependency
py7zr = None
import pandas as pd
from typing import Generator
try:
from sim.rl.behavior_loader.loader import PayloadModel, ValueModel, InteractionModel, Loader
except ImportError:
from loader import PayloadModel, ValueModel, InteractionModel, Loader
class YooChooseLoader(Loader):
URL = "https://s3-eu-west-1.amazonaws.com/yc-rdata/yoochoose-data.7z"
CLICK_COLS = ['session_id', 'ts', 'item_id', 'category']
BUY_COLS = ['session_id', 'ts', 'item_id', 'price', 'quantity']
def __init__(self, root_dir: str = "data/yoochoose", chunk_size: int = 500_000, max_sessions: int = 1000):
self.root = root_dir
self.chunk_size = chunk_size
self.max_sessions = max_sessions
self.click_path = f"{root_dir}/yoochoose-clicks.dat"
self.buy_path = f"{root_dir}/yoochoose-buys.dat"
if not os.path.exists(self.click_path): self._setup()
self.data = self._load_sessions(max_sessions)
self.entries = list(self.data.keys())
def _setup(self):
if py7zr is None:
raise RuntimeError("py7zr is required to unpack YooChoose dataset. Install py7zr first.")
os.makedirs(self.root, exist_ok=True)
zip_path = f"{self.root}/temp.7z"
with requests.get(self.URL, stream=True) as r:
with open(zip_path, 'wb') as f:
for chunk in r.iter_content(8192):
f.write(chunk)
with py7zr.SevenZipFile(zip_path, 'r') as z:
z.extractall(self.root)
os.remove(zip_path)
def _make_interaction(self, sid: str, ts: str, item_id: str, event: str, page: str, meta: dict) -> InteractionModel:
payload = PayloadModel(
sessionId=sid, experimentId=None, eventName=event,
page=page, productId=item_id, metadata=meta,
storeMode="yoochoose", userAgent="dataset", ts=ts
)
return InteractionModel(
partitionID=0, offset=0, timestamp=0, compression="",
isTransactional=False, headers=[], key={},
value=ValueModel(payload=payload, encoding="json", isPayloadNull=False, schemaId=1, size=0)
)
def _parse_category(self, cat) -> str:
if pd.isna(cat) or cat == "0": return "unknown"
if cat == "S": return "special_offer"
try:
n = int(cat)
return f"category_{n}" if 1 <= n <= 12 else f"brand_{n}"
except: return str(cat)
def stream_clicks(self) -> Generator[InteractionModel, None, None]:
with pd.read_csv(self.click_path, names=self.CLICK_COLS, chunksize=self.chunk_size, header=None) as reader:
for chunk in reader:
for r in chunk.itertuples(index=False):
yield self._make_interaction(
str(r.session_id), r.ts, str(r.item_id),
"view_item_page", self._parse_category(r.category), {}
)
def stream_buys(self) -> Generator[InteractionModel, None, None]:
with pd.read_csv(self.buy_path, names=self.BUY_COLS, chunksize=self.chunk_size, header=None) as reader:
for chunk in reader:
for r in chunk.itertuples(index=False):
yield self._make_interaction(
str(r.session_id), r.ts, str(r.item_id),
"purchase_complete", "/checkout", {"price": r.price, "quantity": r.quantity}
)
def stream(self) -> Generator[InteractionModel, None, None]:
yield from self.stream_clicks()
yield from self.stream_buys()
def _load_sessions(self, max_sessions: int | None = None) -> dict:
sessions = {}
for interaction in self.stream():
sid = interaction.value.payload.sessionId
if sid not in sessions:
if max_sessions and len(sessions) >= max_sessions: continue
sessions[sid] = []
sessions[sid].append(interaction)
for sid in sessions: sessions[sid].sort(key=lambda x: x.value.payload.ts)
return sessions
def get_data(self) -> dict:
return self.data
def get_entries(self) -> tuple[list[str], int]:
return self.entries, len(self.entries)
if __name__ == "__main__":
loader = YooChooseLoader(max_sessions=100)
views, purchases = 0, 0
for sid, evts in loader.get_data().items():
for e in evts:
if e.value.payload.eventName == "view_item_page": views += 1
elif e.value.payload.eventName == "purchase_complete": purchases += 1
print(f"Loaded {len(loader.entries)} sessions: {views} view_item_page, {purchases} purchase_complete")

7
tests/e2e/.env.example Normal file
View File

@@ -0,0 +1,7 @@
WEB_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000
PRICING_PROVIDER_URL=http://localhost:5001
AIRFLOW_URL=http://localhost:8085
AIRFLOW_USER=admin
AIRFLOW_PASS=admin
HEADLESS=true

View File

@@ -0,0 +1,61 @@
const AIRFLOW_URL = process.env.AIRFLOW_URL || 'http://localhost:8085';
const AUTH = 'Basic ' + Buffer.from(`${process.env.AIRFLOW_USER || 'admin'}:${process.env.AIRFLOW_PASS || 'admin'}`).toString('base64');
const req = (path: string, opts: any = {}) => {
const headers = { Authorization: AUTH, ...opts.headers };
return fetch(`${AIRFLOW_URL}${path}`, { ...opts, headers });
};
export const triggerDag = async (dagId: string, conf = {}) => {
const r = await req(`/api/v1/dags/${dagId}/dagRuns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conf }),
});
if (!r.ok) throw new Error(`Trigger DAG failed: ${r.status}`);
return (await r.json()).dag_run_id;
};
export const getDagStatus = async (dagId: string, runId: string) => {
const r = await req(`/api/v1/dags/${dagId}/dagRuns/${runId}`);
if (!r.ok) throw new Error(`Get status failed: ${r.status}`);
return (await r.json()).state;
};
export const cancelDag = async (dagId: string, runId: string) => {
const r = await req(`/api/v1/dags/${dagId}/dagRuns/${runId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: 'failed' }),
});
if (!r.ok) console.warn(`Failed to cancel DAG ${runId}: ${r.status}`);
};
export const waitForDag = async (dagId: string, runId: string, maxMs = 30000, pollMs = 1000) => {
const t0 = Date.now();
while (Date.now() - t0 < maxMs) {
const state = await getDagStatus(dagId, runId);
if (state === 'success') return;
if (state === 'failed') throw new Error(`DAG ${runId} failed`);
await new Promise(r => setTimeout(r, pollMs));
}
await cancelDag(dagId, runId);
throw new Error(`DAG ${runId} timeout`);
};
export const runDag = async (dagId: string, conf = {}, maxMs = 60000) => {
const runId = await triggerDag(dagId, conf);
await waitForDag(dagId, runId, maxMs);
};
export const runSessionPricing = (mode = 'hotel') =>
runDag('session_pricing_pipeline', { store_mode: mode, session_limit: 10 }, 90000);
export const runSurgePricing = (mode = 'hotel', highThresh = 10, lowThresh = 2) =>
runDag('surge_pricing_pipeline', {
store_mode: mode,
high_threshold: highThresh,
low_threshold: lowThresh,
surge_multiplier: 1.2,
discount_multiplier: 0.9
}, 90000);

Some files were not shown because too many files have changed in this diff Show More