Compare commits

..

233 Commits

Author SHA1 Message Date
ae2860a0ee chore: adding the data and figure procssing 2026-03-23 15:04:46 +01:00
c87b800793 fixing build in prod 2026-03-23 14:52:02 +01:00
810d823710 update final sweep 2026-03-23 14:14:08 +01:00
8706072966 chore: updating datset card with releveant updates nad data 2026-03-23 14:14:08 +01:00
f70c51f223 chore: update datset link properly 2026-03-23 14:14:08 +01:00
8aa4db1c9e chor> competitive wrapping 2026-03-23 14:14:08 +01:00
ee26954fae finishing finish conclision 2026-03-23 14:14:08 +01:00
fb09ea2b68 chore: refactoring fitgures directory 2026-03-23 14:14:08 +01:00
3439775fbd fix: supra reward adjustment and sweep 2026-03-23 14:14:08 +01:00
43b952cf2b adding the markdown to auto 2026-03-23 14:14:08 +01:00
2adb4f07b4 feat: updating readme w badge for datset 2026-03-23 14:14:08 +01:00
e867c4d883 chore: updating ray 2026-03-23 14:14:08 +01:00
a3e2a337ed chore: bootstrap push 2026-03-23 14:14:08 +01:00
63f1aad0b9 chore: including new scritps for automation 2026-03-23 14:14:08 +01:00
253364acae chore: plotting things and setting up her o better 2026-03-23 14:14:08 +01:00
9642edd1b1 chore: rename to distinguishability 2026-03-23 14:14:08 +01:00
c8df2e9cbd chore: fixing refaormating 2026-03-23 14:14:08 +01:00
1393795359 chore: re referene new dataset 2026-03-23 14:14:08 +01:00
375445f260 chore: refactoring, proper citation and updating on data and refs and apendices 2026-03-23 14:14:08 +01:00
0521a63937 chore: updating make reference and linking of builds 2026-03-23 14:14:08 +01:00
a9c091050c chore: bulk tpu reorchestration 2026-03-23 14:14:08 +01:00
52b4dcdce3 updating engine training for training 2026-03-23 14:14:08 +01:00
19b47aa699 feat(paper): mentining how we using H/A and the finall outputs 2026-03-23 14:14:08 +01:00
88155d22a7 chore: refactor for sweeps and IP configs 2026-03-23 14:14:08 +01:00
b1f583be39 nightly benchmark run configureation 2026-03-23 14:14:08 +01:00
22e50aac4a cleaning manim and improving rtraining setup 2026-03-23 14:14:08 +01:00
d748733231 chore: fixing previous error of software version 2026-03-23 14:14:08 +01:00
9caad4de4e setup for tpu orchestarion properly 2026-03-23 14:14:08 +01:00
745792683e feat: data sync via HF 2026-03-23 14:14:08 +01:00
631b6d698c feat: tpu orchestrator 2026-03-23 14:14:01 +01:00
d3a4febfde tpu ready remodel 2026-03-11 20:49:28 +01:00
fa2dde8307 responsive and representative demand for COI erosion 2026-03-11 12:46:22 +01:00
0f708aab15 feat: simple margin proving study 2026-03-11 11:48:51 +01:00
974498dab2 chor: implementing prallelization across jax 2026-03-10 17:05:16 +01:00
6d9613c0b6 feat: talking about optimality 2026-03-10 17:04:23 +01:00
Daniel Alves Rösel
ae6cffe825 Merge pull request #54 from velocitatem/baseline-comparisons
Baseline comparisons
2026-03-10 15:16:30 +01:00
43bcad2a98 arxiv mirror 2026-03-10 15:03:57 +01:00
8404a88ef1 fix: logging into benchmark of wandb 2026-03-10 14:54:44 +01:00
1c2935dc87 feat: reference of blog series 2026-03-10 14:54:35 +01:00
14aae3dc9a improving understandable aspects of the abstract 2026-03-10 14:52:17 +01:00
a9f1e19488 feat: docs 2026-03-10 14:23:49 +01:00
4c7d911043 feature: telemetry logging 2026-03-10 14:23:17 +01:00
be03b2d4d5 updating docs and paper 2026-03-10 14:11:41 +01:00
ee32ab7d1d chore: change colors 2026-03-09 22:47:51 +01:00
969ef4c363 feat: working full flow of diff scenes 2026-03-09 20:59:22 +01:00
3cc2dc40d5 initaial defense scenes 2026-03-09 13:52:11 +01:00
529d00cb80 chore: proper naming of authoers 2026-03-09 12:18:34 +01:00
b62d29cfaf updating workflow 2026-03-09 11:45:49 +01:00
77f45ed0b3 initial results :/ 2026-03-09 11:37:22 +01:00
73a1dafc6e first meaningful runs 2026-03-08 21:37:13 +01:00
4c658a93a7 cleaning up jax bs 2026-03-08 19:15:58 +01:00
73246d7dd8 refactoring training spc setup and benchmarking 2026-03-08 18:30:53 +01:00
9fafb26ec8 mirror cloudflare sync 2026-03-08 16:04:49 +01:00
b7b871f9aa fixing line break 2026-03-08 15:52:30 +01:00
840a13ca4a fixing root file passsing 2026-03-08 15:46:21 +01:00
04fa7cbab5 workflow fix 2026-03-08 15:24:56 +01:00
ec7486ee85 genpop workflow 2026-03-08 15:22:09 +01:00
916e72f0ff helper scripts 2026-03-08 14:36:24 +01:00
69b2d5aceb adding task details 2026-03-08 14:32:16 +01:00
28dbcacd95 updating computation power graph 2026-03-08 14:22:54 +01:00
17c128cbc0 implementing nx management for the framework 2026-03-08 13:54:16 +01:00
cc24ac72f7 changed to new test method for singificance 2026-03-08 13:53:31 +01:00
4b89b64674 monestary updates 2026-03-08 13:27:17 +01:00
ec880db444 chore: cleaning the code 2026-02-28 23:38:38 +01:00
803e3a2972 chore: cleaning some code 2026-02-28 23:30:16 +01:00
233ce3be34 class separaiblity significance 2026-02-28 21:38:46 +01:00
Daniel Alves Rösel
8f20359c8c Merge pull request #53 from velocitatem/cleanup
Cleanup
2026-02-28 14:15:24 +01:00
56585b3de8 cleaning path for intergations 2026-02-28 14:11:39 +01:00
5444a4ea13 catchup: rogue scripts 2026-02-27 12:45:46 +01:00
e8a9716f69 hotfix of machines 2026-02-27 11:59:08 +01:00
e50d643fbf feat: training update 2026-02-27 09:33:04 +01:00
Daniel Alves Rösel
dac1e58a0d Merge pull request #52 from velocitatem/enriching-engine-simulation
Enriching engine simulation
2026-02-25 09:21:26 +01:00
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
29a13340b9 hotfix: updating pricing provider to better read data 2026-02-06 12:01:12 +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
Daniel Alves Rösel
221e71a503 E2e testing of pricing (#42)
* a simp0le scaffold

* feature: simple npm setup

* feature: testing setup and dummy scenarios

* chore: dumping kafak just via backend

* chore: dcleaning gitignore

* features: boilerplate fixtures and stuff

* test: extra tests

* chore: update the test suite to be callable via makefile

* chore: cleaning

* chore: updating interactions setup

* small cleaning

* chore: cleaning shitty code
2026-01-12 11:02:18 +01:00
Daniel Alves Rösel
f2271e368e 34 initial discriminator of interaction data (#38)
* feat: training pipeline + tensorboard

* tesnorboard forgot

* chore: ml basic boilerplate

* feat: naive architecture as start

* eval setup

* chore: parquet exporting of data

* chore: updating requirements necesary

* feat: separating modules and adding training logs paths

* Update experiments/ml/train.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix: new path for runs

* fix: undoing ai slop code

* chore: modules and reqs

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-14 18:58:42 +01:00
Daniel Alves Rösel
a1916c966c 32 refine data pipeline training data construction (#37)
* feature: modularized feature engineering for ml setup (new pipeline)

* chore: updating imports properly

* test: updating fixtures with ua and meta

* chore: migrating code ignore groups

* chore: syntax cleaning and code quality

* chore: fixing pipeline data compatability

* Update experiments/procesing/steps/session.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* chore: refactoring and dixing path joining

* chore: refactoring function definition to avoid reinit

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-12 12:15:15 +01:00
Daniel Alves Rösel
a2a443c027 Update README with project badges and links
Added badges for build status, TPU support, and Vercel deployments.
2025-12-12 10:21:11 +01:00
Daniel Alves Rösel
ef98141ca8 Catchup airline (#31)
* chore: update provider and pricing snitch with agnostic system

* cloning pipelines per mode instance

* updating airline hero section

* fix: must keep airflow secretkey

* fix: fixture update to hotel not shop

* chore: refactored to factory design pattern of pipelines

* chore: clean up definition of composite class of providers
2025-12-11 21:56:12 +01:00
d45b344264 fixing public routing for store modes 2025-12-08 15:00:37 +01:00
a0b956b242 chore: rewriting airflow for railway 2025-12-06 18:04:18 +01:00
Daniel Alves Rösel
8751583764 Improving interface after experiment01 (#30)
* fix: fixes of backwords

* fixing hotel information with image placeholders

* chore: clean up product display in hotel and cleaner interfacing

* adding loader with historical data loading

* feature: cleaning up pipeline

* chore: simple surge pricer

* created new pricing pipeline

* adding a checkout page to both sites

* fix: fixing stale pacakge

* test: we wont be using elasticity anymore so its okay

* chore: cleaning elasticity references

* chore: store sting

* feature: e2e intro pipline surge pricing

* fix: CVE vulnerability patching
2025-12-06 17:47:14 +01:00
59d4fb7891 fix: unified provider container for standalone 2025-12-04 17:03:39 +01:00
7c2a819122 removing module provider summoning for provider 2025-12-04 16:19:26 +01:00
5941ffd085 small provider updates 2025-12-04 16:07:18 +01:00
955102090d feat: introduced cumulative features step for state definition 2025-11-29 22:28:40 +01:00
d654bbf4b4 static price reading 2025-11-29 20:13:38 +01:00
Daniel Alves Rösel
ad9423bf59 Airflow addition (#28)
* introducing airflow to run pipeline

* chore: updating dag with upload to registry

* introducing complete provider (non refactored and noisy)

* chore: removing old shit

* generic pricing baselines

* feature: super simple model registry (to be updated maybe third party OS software)

* chore: refactoring the providers docker config and requirements

* chore: refactored and broke down components (braking

* exporting all

* local pipeline excution working

* fix: fixing import structures from nonrelativistic

* chore: enables cross comm pickling with fully e2e pipeline compilation

* docs: what the pipeline is like now

* pipelines local running and pipeline high level definition

* cleaning old pipeline and vectorization

* leaked but fixing, not so important

* test: started with pipeline step testing

* chore: cleaning up provider of prices

* test: extra tests wit hsemantic meaning checks

* migrating pricers

* feature: introducing pricing predictors (pricers)

* chore: e2e is done with new pipeline

* extra session feature extraction

* feature: experiemntal sessin pricer and metrics(vibe)

* chore: redefined and connected pricers (#29)
2025-11-29 17:50:16 +01:00
Daniel Alves Rösel
2a0e44ab24 Add image and update links in README.md 2025-11-29 14:19:22 +01:00
Daniel Alves Rösel
c432c45343 First pricing implementation (#27)
* first implementation of elasticity demand computation

* chor: fixing test :(

* feature: rudemantary defintition of pricing pipeline

* chor: fixing cross product missing data

* add warning

* feature: e2e pricing pipeline with inference
2025-11-27 18:25:27 +01:00
Daniel Alves Rösel
8b76d24ade 6 catalog data and mode mappers (#25)
* supabase product proxy and rendering

* minor pipeline refactor

* refactoring and demand estimation

* trackion of date index searching

* fixing changes of imports

* data seeding

* chore: airline basic refactor

* feat: huge push of product changes and item review with cart

* refactored design

* chore: moving route elsewhere and align

* fix: build of web/

* chore: fixing paper build

* fixing chars
2025-11-25 11:00:31 +01:00
Daniel Alves Rösel
894ce87a5d introduced supabase and experiment management UI (#23)
* introduced supabase and experiment management UI

* fixing cookie import
2025-11-18 20:45:11 +01:00
323 changed files with 37094 additions and 931 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.git
.venv
.venv-tpu
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.ipynb_checkpoints
wandb
build
paper/build
paper/build-cais
node_modules
**/node_modules
*.egg-info

24
.env.sweep.example Normal file
View File

@@ -0,0 +1,24 @@
# Copy this file to .env.sweep and fill in values.
# Required for wandb runs and sweep agent workers.
WANDB_API_KEY=
WANDB_ENTITY=
WANDB_PROJECT=capstone
# Required for private repo bootstrap workers.
GITHUB_TOKEN=
# Optional defaults for bootstrap mode.
# REPO_URL=https://github.com/org/repo.git
# BRANCH=main
# WORKDIR=$HOME/PHANTOM-agent
# SWEEP_ID=entity/project/id
# AGENT_COUNT=0
# AGENT_LOOP=1
# RETRY_SECONDS=20
# Optional local benchmark defaults.
# LOCAL_BENCHMARK_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
# SIMPLE_BENCHMARK_ARGS=--tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
# PHANTOM_BENCHMARK_COMPARE_ROBUST=1
# BENCHMARK_AGENT_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3,0.6 --episodes 5

View File

@@ -12,17 +12,168 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
steps:
- uses: actions/checkout@v4
- name: Compile LaTeX document
- name: Prepare appendix code snapshot
run: bash paper/concat_code.sh
- name: Generate mirrors with Codex
if: ${{ env.OPENAI_API_KEY != '' }}
uses: openai/codex-action@v1
with:
openai-api-key: ${{ env.OPENAI_API_KEY }}
sandbox: workspace-write
safety-strategy: drop-sudo
working-directory: .
prompt: |
Read and follow the mirror instructions in `paper/src/mirrors/genpop/INSTRUCTIONS.md`.
Source chapters are in `paper/src/chapters/`:
- 01-intro.tex
- 02-literature-review.tex
- 03-methodology.tex
- 04-results.tex
- 05-discussion.tex
- 06-conclusion.tex
Update `paper/src/mirrors/genpop/*.tex` so they mirror the thesis for a general audience according to the instruction file.
Keep LaTeX valid and preserve citation commands and section order.
Then create or update `paper/src/main-mirror-genpop.tex` by using `paper/src/main.tex` as the base and replacing chapter inputs from `chapters/...` to `mirrors/genpop/...`.
Do not change any other project files.
- name: Compute LaTeX roots
id: roots
run: |
{
echo "root_files<<EOF"
echo "main.tex"
for file in paper/src/main-mirror-*.tex; do
if [ -f "$file" ]; then
basename "$file"
fi
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Compiling roots:"
echo "main.tex"
for file in paper/src/main-mirror-*.tex; do
if [ -f "$file" ]; then
basename "$file"
fi
done
- name: Compile LaTeX documents
uses: xu-cheng/latex-action@v3
with:
root_file: main.tex
root_file: ${{ steps.roots.outputs.root_files }}
working_directory: paper/src
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
pre_compile: bash ../concat_code.sh
- name: Upload PDF
args: -pdf -f -interaction=nonstopmode -file-line-error -r ../.latexmkrc -outdir=../build
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4
with:
name: thesis-pdf
path: paper/build/main.pdf
path: |
paper/build/main.pdf
paper/build/main-mirror-*.pdf
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload to Cloudflare R2
if: ${{ env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' && env.R2_ENDPOINT != '' && env.R2_BUCKET_NAME != '' }}
env:
AWS_ACCESS_KEY_ID: ${{ env.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ env.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: ${{ env.R2_ENDPOINT }}
DATE: ${{ steps.date.outputs.date }}
BUCKET_NAME: ${{ env.R2_BUCKET_NAME }}
run: |
pip install boto3
python3 << 'EOF'
import boto3
import os
s3 = boto3.client('s3',
endpoint_url=os.environ['AWS_ENDPOINT_URL'],
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY']
)
date = os.environ['DATE']
bucket = os.environ['BUCKET_NAME']
# upload dated version
dated_filename = f"thesis-{date}.pdf"
s3.upload_file(
'paper/build/main.pdf',
bucket,
dated_filename,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {dated_filename}")
# upload latest version
s3.upload_file(
'paper/build/main.pdf',
bucket,
'thesis-latest.pdf',
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded thesis-latest.pdf")
# upload mirror versions (if generated)
build_dir = 'paper/build'
for filename in os.listdir(build_dir):
if not filename.startswith('main-mirror-') or not filename.endswith('.pdf'):
continue
mirror_name = filename[len('main-mirror-'):-4]
source_path = os.path.join(build_dir, filename)
dated_mirror = f"thesis-{mirror_name}-{date}.pdf"
latest_mirror = f"thesis-{mirror_name}-latest.pdf"
namespaced_dated = f"mirrors/{mirror_name}/thesis-{date}.pdf"
namespaced_latest = f"mirrors/{mirror_name}/thesis-latest.pdf"
s3.upload_file(
source_path,
bucket,
dated_mirror,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {dated_mirror}")
s3.upload_file(
source_path,
bucket,
latest_mirror,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {latest_mirror}")
s3.upload_file(
source_path,
bucket,
namespaced_dated,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {namespaced_dated}")
s3.upload_file(
source_path,
bucket,
namespaced_latest,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {namespaced_latest}")
EOF

84
.gitignore vendored
View File

@@ -1,8 +1,92 @@
# environment and secrets
**/.env
.env.*
!.env.*.example
**/.venv
**/.venv-ray
# python build/cache artifacts
**/__pycache__
phantom.egg-info/
*.egg-info/
# notebook artifacts
**/.ipynb_checkpoints/
**/.virtual_documents/
# editor/tool state
**/.pdf-view-restore
.nextstep
.ignore-gitlogue
.cloudflare
.nx/
node_modules/
dist/
# generated svg/graphics
**/session_*.svg
**/*graph.svg
**/auto/*.el
# misc generated
*.old
**/package-lock.json
**/*.parquet
**/_build/
# paper build artifacts
paper/src/bib/auto
paper/src/auto/*
paper/src/bib/auto
paper/template/*
paper/build-cais/
paper/defense/manim/media/
paper/defense/manim/.manim/
paper/src/main.pdf
paper/src/main-blx.bib
paper/src/svg-inkscape/
paper/variations/
paper/src/graphics/test_*.png
thesis-latest.pdf
# experiment run artifacts and logs
docs/goals/*.md
PHANTOM.wiki/
experiments/airflow/logs/*
experiments/airflow/logs/scheduler/
experiments/airflow/logs/dag_processor_manager/
experiments/collected_data/
experiments/agents/collected_data/
tests/e2e/test-results/
tests/e2e/node_modules/**
# rl/sim run outputs
sim/rl/behavior_loader/*.dot
sim/rl/behavior_loader/*.png
sim/rl/behavior_loader/*.svg
sim/rl/behavior_loader/*.pdf
sim/rl/runs/
lab/case/thesis/runs*/
sim/case/thesis_simplified/runs*/
# model binaries
engine/models/*.zip
engine/studies/results/*
*.zip
# wandb local state
wandb/
# data directory (large datasets)
data/
# ktem local app data
ktem_app_data/
# generated visualization pdfs
*_mdp_viz.pdf
phantom_env_comparison.png
sim/phantom_env_comparison.png
# web clone
PHANTOM_web/*

35
.rayignore Normal file
View File

@@ -0,0 +1,35 @@
# Virtual environments
.venv
.venv*
venv
venv*
**/.venv
**/venv
**/node_modules
node_modules/
# Python caches
__pycache__/
*.pyc
.ruff_cache/
.pytest_cache/
# Git
.git/
# Large data and logs
data/
experiments/
wandb/
dumplogs*
*.zip
*.pdf
*.log
*.dot
# Other large dirs
PHANTOM_web/
web/
docs/
paper/
.nx/

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

243
Makefile
View File

@@ -8,45 +8,236 @@ VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
NX := npx nx
SWEEP_ENV_FILE ?= .env.sweep
TPU_CONF ?= tpu_orchestration/configs/v4_spot_us.conf
WANDB_ENTITY ?=
WANDB_PROJECT ?= capstone
SWEEP_ID ?=
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
LOCAL_BENCHMARK_ARGS ?= --tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
SIMPLE_BENCHMARK_ARGS ?= --tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
BENCHMARK_AGENT_ARGS ?=
AGENT_COUNT ?= 0
WHOCLICKED_REPO ?= velocitatem/whoclickedit
WHOCLICKED_CSV ?= experiments/exports/whoclicked.csv
WHOCLICKED_CARD ?= experiments/exports/whoclicked_dataset_card.md
WHOCLICKED_CSV_PATH_IN_REPO ?= whoclicked.csv
WHOCLICKED_CARD_PATH_IN_REPO ?= README.md
WHOCLICKED_DATASET_MESSAGE ?= Update flattened whoclickedit dataset
WHOCLICKED_CARD_MESSAGE ?= Update dataset card for whoclickedit
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
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
.DEFAULT_GOAL := help
all: pdf
run.webapp:
@cd web && npm install && npm run dev
.PHONY: help
help:
@echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | manim.render manim.render.all"
@echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish"
@echo "data.pull data.push data.whoclicked.publish | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot"
@echo "tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown"
@echo ""
@echo "Build general public version:"
@echo " make pdf.genpop"
@echo ""
@echo "Local wandb run:"
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
@echo ""
@echo "Local benchmark run:"
@echo " make benchmark LOCAL_BENCHMARK_ARGS='--tiers static,surge,linear --alpha-values 0.0,0.3 --episodes 3 --no-wandb'"
@echo ""
@echo "Simple benchmark run (.env.sweep defaults, robust+no_robust compare by default):"
@echo " make benchmark.simple"
@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 "Bootstrap Ray on TPU slice from config:"
@echo " make tpu.ray.bootstrap TPU_CONF=tpu_orchestration/configs/v4_spot_us.conf"
@echo ""
@echo "Publish whoclickedit dataset + card:"
@echo " make data.whoclicked.publish HF_TOKEN=... WHOCLICKED_REPO=velocitatem/whoclickedit"
@echo ""
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
$(BUILDDIR):
mkdir -p paper/$(BUILDDIR)
pdf: $(BUILDDIR)
@echo "Concatenating source code..."
@bash paper/concat_code.sh
@cd $(SRCDIR) && \
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
-interaction=nonstopmode -file-line-error \
-outdir=../$(BUILDDIR) $(TEX)
.PHONY: pdf.build
pdf.build:
@$(NX) run paper:build
watch: $(BUILDDIR)
@cd $(SRCDIR) && \
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
-interaction=nonstopmode -file-line-error \
-r ../.latexmkrc \
-outdir=../$(BUILDDIR) $(TEX)
.PHONY: pdf.watch
pdf.watch:
@$(NX) run paper:watch
clean:
@cd $(SRCDIR) && \
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
rm -rf paper/$(BUILDDIR)/*
.PHONY: pdf.clean
pdf.clean:
@$(NX) run paper:clean
.PHONY: pdf.genpop
pdf.genpop:
@bash scripts/nx_paper.sh build-genpop
.PHONY: pdf.genpop.watch
pdf.genpop.watch:
@bash scripts/nx_paper.sh watch-genpop
.PHONY: pdf.arxiv
pdf.arxiv:
@bash scripts/nx_paper.sh build-arxiv
.PHONY: test.backend
test.backend:
@$(NX) run research:test
.PHONY: test.e2e
test.e2e:
@$(NX) run e2e:test
.PHONY: test.all
test.all:
@$(NX) run-many -t test --projects=research,e2e --parallel=1
.PHONY: web.dev
web.dev:
@$(NX) run web:dev
$(VENV):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
install: $(VENV)
$(PIP) install -r requirements.txt
.PHONY: install
install:
@$(NX) run research:install
test: $(VENV)
$(PYTEST) -v
.PHONY: train
train:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_TRAIN_ARGS="$(LOCAL_TRAIN_ARGS)" $(NX) run research:train
.PHONY: all pdf clean watch run.webapp install test
.PHONY: benchmark
benchmark:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_BENCHMARK_ARGS="$(LOCAL_BENCHMARK_ARGS)" $(NX) run research:benchmark
.PHONY: benchmark.simple
benchmark.simple:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SIMPLE_BENCHMARK_ARGS="$(SIMPLE_BENCHMARK_ARGS)" PHANTOM_BENCHMARK_COMPARE_ROBUST="$(PHANTOM_BENCHMARK_COMPARE_ROBUST)" $(NX) run research:benchmark-simple
.PHONY: benchmark.agent
benchmark.agent:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" BENCHMARK_AGENT_ARGS="$(BENCHMARK_AGENT_ARGS)" $(NX) run research:benchmark-agent
.PHONY: train.agent
train.agent:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" $(NX) run research:train-agent
.PHONY: train.bootstrap
train.bootstrap:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" REPO_URL="$(REPO_URL)" BRANCH="$(BRANCH)" WORKDIR="$(WORKDIR)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" AGENT_LOOP="$(AGENT_LOOP)" RETRY_SECONDS="$(RETRY_SECONDS)" $(NX) run research:train-bootstrap
.PHONY: tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown
tpu.ray.bootstrap:
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-bootstrap
tpu.ray.deps:
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-deps
tpu.ray.verify:
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-verify
tpu.ray.teardown:
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-teardown
.PHONY: data.pull data.push
data.pull:
python scripts/hf_data.py pull
data.push:
python scripts/hf_data.py push
.PHONY: data.whoclicked.publish
data.whoclicked.publish:
@HF_TOKEN="$(HF_TOKEN)" WHOCLICKED_REPO="$(WHOCLICKED_REPO)" WHOCLICKED_CSV="$(WHOCLICKED_CSV)" WHOCLICKED_CARD="$(WHOCLICKED_CARD)" WHOCLICKED_CSV_PATH_IN_REPO="$(WHOCLICKED_CSV_PATH_IN_REPO)" WHOCLICKED_CARD_PATH_IN_REPO="$(WHOCLICKED_CARD_PATH_IN_REPO)" WHOCLICKED_DATASET_MESSAGE="$(WHOCLICKED_DATASET_MESSAGE)" WHOCLICKED_CARD_MESSAGE="$(WHOCLICKED_CARD_MESSAGE)" $(NX) run research:whoclicked-publish
.PHONY: stats.lines
stats.lines:
@$(NX) run research:stats
.PHONY: study.margin-erosion
study.margin-erosion:
python -m engine.studies.margin_erosion_alpha
.PHONY: study.margin-erosion.quick
study.margin-erosion.quick:
python -m engine.studies.margin_erosion_alpha --quick
.PHONY: wordcount
wordcount:
@$(NX) run paper:wordcount
.PHONY: docker.train.publish
docker.train.publish:
@TRAIN_IMAGE_REF="$(TRAIN_IMAGE_REF)" $(NX) run research:docker-train-publish
.PHONY: backend.server backend.provider backend.worker platform.up platform.down platform.logs
backend.server:
@$(NX) run backend-server:dev
backend.provider:
@$(NX) run pricing-provider:dev
backend.worker:
@$(NX) run backend-worker:dev
platform.up:
@$(NX) run platform:up
platform.down:
@$(NX) run platform:down
platform.logs:
@$(NX) run platform:logs
.PHONY: pdf clean watch run.webapp test count-lines all
pdf:
@$(NX) run paper:build
clean:
@$(NX) run paper:clean
watch:
@$(NX) run paper:watch
run.webapp:
@$(NX) run web:dev
test:
@$(NX) run research:test
count-lines:
@$(NX) run research:stats
all:
@$(NX) run paper:build
.PHONY: manim.render manim.render.all
manim.render:
@$(NX) run manim:render
manim.render.all:
@$(NX) run manim:render-all

View File

@@ -1,5 +1,95 @@
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
### PHANTOM
[![Dataset on HF](https://huggingface.co/datasets/huggingface/badges/resolve/main/dataset-on-hf-sm.svg)](https://huggingface.co/datasets/velocitatem/whoclickedit)
[![Build PDF](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml/badge.svg)](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
[![Paper](https://img.shields.io/badge/Paper-PDF-red?logo=adobe-acrobat-reader)](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
[![TPU Research Cloud](https://img.shields.io/badge/TPU%20Research%20Cloud-TRC%20supported-4285F4?logo=googlecloud&logoColor=white)](https://sites.research.google/trc/faq/)
[![Vercel Deploy](https://deploy-badge.vercel.app/?url=https://phantom-hotel.vercel.app&name=Hotel)](https://phantom-hotel.vercel.app)
[![Vercel Deploy](https://deploy-badge.vercel.app/?url=https://phantom-airline.vercel.app&name=Airline)](https://phantom-airline.vercel.app)
- https://phantom-hotel.vercel.app/
- https://phantom-airline.vercel.app/
```mermaid
mindmap
PHANTOM((PHANTOM Project))
North Star
Study how automated actors change markets
Build an experimentation platform for real-world-like commerce
Two-loop learning system
Online observation loop
Offline "defense gym" loop
Core Economic Questions
Price Discovery
How prices respond to demand signals
How signal quality changes with bots/agents
Demand & Elasticity
Shifts in willingness-to-pay
Short-run vs long-run elasticity
Market Efficiency & Welfare
Consumer surplus vs producer surplus
Deadweight loss from frictions/manipulation
Price Discrimination & Segmentation
Behavioral feature-based segmentation
Fairness vs profitability tradeoffs
Information Asymmetry
Agents amplify search and arbitrage
Sellers infer more about buyers; buyers infer more about sellers
Strategic Interaction
Consumers vs firms vs agents
Feedback loops: policy ↔ behavior ↔ price
Market Power & Competition
Algorithmic pricing as competitive tool
Risks: tacit coordination / "algorithmic collusion"
Externalities
Congestion and attention costs
Spillovers: one segments behavior affects others prices
System-Level View
Participants
Humans
Agents (automated buyers/actors)
Firms (pricing decision-makers)
Platform (measurement + control layer)
Markets Simulated
Repeated transactions
Limited inventory / capacity constraints (conceptually)
Time dynamics (learning over time)
Interventions
Pricing policies
Experiment assignment / randomized exposure
Agent behavioral policies (task-driven)
Measurement & Causal Inference
What is observed
Actions (search, click, purchase intent)
Context (product attributes, time, exposure)
Outcomes (conversion, revenue, churn proxies)
Identification strategy
A/B tests and randomization
Counterfactual baselines
Robustness checks (offline replay)
Key metrics
Revenue / profit proxies
Conversion & bounce
Price volatility / stability
Welfare proxies (e.g., dispersion, access)
Risk, Governance, and Ethics
Manipulation & Integrity
Bot-driven demand distortion
Measurement contamination
Fairness & Transparency
Differential pricing concerns
Explainability and auditability
Safety Constraints
Guardrails on price moves
Monitoring for runaway feedback loops
Outputs
Insights
When do agents raise/lower prices via behavior shifts?
Which market designs are robust to automation?
Defenses
Agent-aware pricing policies (robust control)
Detection + mitigation strategies (feature-level separability)
Platform Value
Reusable testbed for market + AI-agent research
```

33
backend/project.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "../node_modules/nx/schemas/project-schema.json",
"name": "platform",
"projectType": "application",
"sourceRoot": "backend",
"targets": {
"up": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose up -d",
"cwd": "."
}
},
"down": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose down",
"cwd": "."
}
},
"logs": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose logs --tail=100 -f",
"cwd": "."
}
}
},
"tags": [
"scope:platform",
"type:infra"
]
}

112
backend/provider/app.py Normal file
View File

@@ -0,0 +1,112 @@
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Literal, Optional
import uvicorn, os, sys
from supabase import create_client, Client
from dotenv import load_dotenv
import numpy as np
import pandas as pd
load_dotenv()
# Local imports of registry and pricing function
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../experiments/")
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.pricers import (
StaticPricer,
RandomPricer,
ElasticityBasedPricer
)
from procesing.steps import (
PredictPricesStep
)
from procesing import PipelineContext
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
from lib.model_registry import ModelRegistry
# Config
app = FastAPI(title="PHANTOM Pricing Provider")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
registry = ModelRegistry()
class PriceResponse(BaseModel):
productId: str
price: float
base_price: float
markup: float
elasticity: Optional[float] = None
model_version: str = 'latest'
@app.get("/health")
def health() -> dict:
return {"status": "healthy", "redis": registry.health_check()}
@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)):
"""
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]
if not product: raise HTTPException(404, f"Product {productId} not found")
metadata = product['metadata']
base_price = metadata.get('base_price', 100.0)
# 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')
if prices_df is not None:
product_price_row = prices_df[prices_df['productId'] == productId]
if not product_price_row.empty:
optimal_price = float(product_price_row['optimal_price'].iloc[0])
return PriceResponse(
productId=productId,
price=optimal_price,
base_price=base_price,
markup=optimal_price/base_price,
elasticity=None,
model_version='surge'
)
# PRIORITY 3: fallback to base price
return PriceResponse(
productId=productId,
price=base_price,
base_price=base_price,
markup=1.0,
elasticity=None,
model_version='base'
)
@app.get("/models")
def list_models(): return registry.list_models()
@app.post("/models/reload")
def reload_models():
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
return {
"elasticity_loaded": bool(elasticity),
"n_products": len(elasticity) if elasticity is not None else 0,
"pricing_model_loaded": bool(pricing_model),
"model_class": pricing_model.__class__.__name__ if pricing_model else None
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "pricing-provider",
"projectType": "application",
"sourceRoot": "backend/provider",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/provider"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${PROVIDER_PORT:-5001} --reload",
"cwd": "backend/provider"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${PROVIDER_PORT:-5001}",
"cwd": "backend/provider"
}
}
},
"tags": [
"scope:backend",
"type:provider"
]
}

View File

@@ -0,0 +1,16 @@
fastapi
uvicorn[standard]
pydantic
numpy
pandas
scikit-learn
redis
supabase
confluent-kafka>=2.3.0
kafka-python
graphviz
python-dotenv>=1.0.0
requests>=2.31.0
typing-extensions>=4.8.0
pypickle
pymc

View File

@@ -11,6 +11,7 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
from kafka.admin import NewTopic
from kafka.errors import TopicAlreadyExistsError
from dotenv import load_dotenv
from supabase import create_client, Client
load_dotenv()
app = FastAPI()
@@ -18,6 +19,19 @@ app = FastAPI()
# kafka producer - lazy init
_producer: Optional[KafkaProducer] = None
# supabase client - lazy init
_supabase: Optional[Client] = None
def get_supabase() -> Client:
global _supabase
if _supabase is None:
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
if not url or not key:
raise ValueError("Supabase credentials not configured")
_supabase = create_client(url, key)
return _supabase
def get_producer() -> KafkaProducer:
global _producer
if _producer is None:
@@ -50,6 +64,14 @@ class EventPayload(BaseModel):
userAgent: Optional[str] = None
ts: Optional[str] = None
class PriceLogPayload(BaseModel):
productId: str
price: float
sessionId: str
experimentId: Optional[str] = None
storeMode: str
ts: Optional[str] = None
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -73,7 +95,8 @@ async def startup_event():
)
topics = [
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1)
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
]
admin.create_topics(new_topics=topics, validate_only=False)
@@ -125,42 +148,71 @@ async def ingest_logs(event: EventPayload):
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/kafka/price-log")
async def ingest_price_log(price_log: PriceLogPayload):
try:
if not price_log.ts:
price_log.ts = datetime.utcnow().isoformat() + 'Z'
producer = get_producer()
future = producer.send(
'price-logs',
key=price_log.productId,
value=price_log.model_dump()
)
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
return {"success": True}
except Exception as e:
import traceback
print(f"[PRICE_LOG_ERROR] {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/kafka/dump")
def dump_logs(
topic: str = 'user-interactions',
last_n: Optional[int] = None,
t_start: Optional[str] = None,
t_end: Optional[str] = None
):
"""dump all messages from user-interactions topic
"""dump all messages from specified kafka topic
params:
topic: kafka topic to dump (default: user-interactions)
last_n: return only last n messages (default: all)
t_start: filter by start timestamp iso format (future use)
t_end: filter by end timestamp iso format (future use)
t_start: filter by start timestamp iso format
t_end: filter by end timestamp iso format
"""
if topic not in ['user-interactions', 'price-logs']:
raise HTTPException(status_code=400, detail="Invalid topic")
host = os.getenv('KAFKA_HOST', 'localhost')
port = os.getenv('KAFKA_PORT', '9092')
broker = f'{host}:{port}'
try:
consumer = KafkaConsumer(
'user-interactions',
topic,
bootstrap_servers=[broker],
auto_offset_reset='earliest',
enable_auto_commit=False,
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 = []
for msg in consumer:
events.append(msg.value)
if last_n and len(events) >= last_n * 2:
break
consumer.close()
# apply filters
if t_start or t_end:
# filter by timestamp range if provided
filtered = []
for e in events:
ts = e.get('ts')
@@ -183,6 +235,131 @@ def dump_logs(
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/products/{product_id}")
async def get_product_by_id(product_id: str):
"""fetch single product by id from either hotel_products or airline_products"""
try:
supabase = get_supabase()
# try hotel_products first
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
if response.data and len(response.data) > 0:
return {"success": True, "data": response.data[0]}
# try airline_products
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
if response.data and len(response.data) > 0:
return {"success": True, "data": response.data[0]}
raise HTTPException(status_code=404, detail="Product not found")
except HTTPException:
raise
except Exception as e:
import traceback
print(f"[PRODUCT_BY_ID_ERROR] {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/products/type/{product_type}")
async def get_products(
product_type: str,
dateIndex: Optional[int] = None,
origin: Optional[str] = None,
destination: Optional[str] = None,
tripType: Optional[str] = None,
adults: Optional[int] = None,
children: Optional[int] = None,
infants: Optional[int] = None,
rooms: Optional[int] = None
):
"""fetch products from supabase based on type (hotel or airline)
params:
product_type: either 'hotel' or 'airline'
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
origin: (airline) departure airport code
destination: (airline/hotel) arrival airport or hotel location
tripType: (airline) roundtrip, oneway, multicity
adults, children, infants: passenger counts
rooms: (hotel) number of rooms
"""
if product_type not in ['hotel', 'airline']:
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
try:
supabase = get_supabase()
table = f'{product_type}_products'
query = supabase.table(table).select('*')
# filter by exact date_index if provided
# dateIndex from frontend is days from today, convert to days since epoch
if dateIndex is not None:
query = query.eq('date_index', dateIndex)
response = query.execute()
results = response.data
# apply in-memory filters based on metadata for airline products
if product_type == 'airline' and results:
filtered = []
for product in results:
metadata = product.get('metadata', {})
# filter by origin airport
if origin:
dep = metadata.get('departure', {})
if dep.get('airport') != origin:
continue
# filter by destination airport
if destination:
arr = metadata.get('arrival', {})
if arr.get('airport') != destination:
continue
# passenger count validation (ensure total capacity)
if adults is not None or children is not None or infants is not None:
total_pax = (adults or 0) + (children or 0) + (infants or 0)
avail = product.get('availability', 0)
if avail < total_pax:
continue
filtered.append(product)
results = filtered
# apply in-memory filters for hotel products
elif product_type == 'hotel' and results:
filtered = []
for product in results:
metadata = product.get('metadata', {})
# filter by occupancy capacity
if adults is not None:
max_occ = metadata.get('max_occupancy', 2)
if max_occ < adults:
continue
# filter by room availability
if rooms is not None:
avail = product.get('availability', 0)
if avail < rooms:
continue
filtered.append(product)
results = filtered
return {"success": True, "count": len(results), "data": results}
except Exception as e:
import traceback
print(f"[PRODUCTS_ERROR] {e}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "backend-server",
"projectType": "application",
"sourceRoot": "backend/server",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/server"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${BACKEND_PORT:-5000} --reload",
"cwd": "backend/server"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${BACKEND_PORT:-5000}",
"cwd": "backend/server"
}
}
},
"tags": [
"scope:backend",
"type:api"
]
}

View File

@@ -1,5 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
kafka-python==2.0.2
pydantic==2.5.0
python-dotenv==1.0.0
fastapi>=0.135,<0.136
uvicorn[standard]>=0.41,<0.42
kafka-python>=2.3,<2.4
pydantic>=2.12,<3
python-dotenv>=1.0,<2
supabase>=2.28,<3

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "backend-worker",
"projectType": "application",
"sourceRoot": "backend/worker",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/worker"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/celery -A main:app worker --loglevel=info",
"cwd": "backend/worker"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/python main.py",
"cwd": "backend/worker"
}
}
},
"tags": [
"scope:backend",
"type:worker"
]
}

View File

@@ -0,0 +1,3 @@
celery>=5.3,<6
python-dotenv>=1.0.0
redis>=5.0.0

View File

@@ -1,4 +1,43 @@
services:
tpu-watchdogs:
build:
context: .
dockerfile: docker/TPUWatchdog.dockerfile
container_name: "PHANTOM-tpu-watchdogs"
restart: unless-stopped
user: "${UID:-1000}:${GID:-1000}"
environment:
- HF_TOKEN=${HF_TOKEN}
- WANDB_API_KEY=${WANDB_API_KEY}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-sa.json
- GCP_ACCOUNT=${GCP_ACCOUNT:-}
- WATCHDOG_CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-v[46]*.conf}
- CLOUDSDK_CONFIG=/.config/gcloud
volumes:
- ~/.config/gcloud:/.config/gcloud:rw
- ./secrets/gcp-sa.json:/secrets/gcp-sa.json:ro
tensorboard-rl:
image: tensorflow/tensorflow:latest
container_name: "PHANTOM-tensorboard-rl"
ports:
- "6007:6006"
volumes:
- ./sim/rl/runs:/logs
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
restart: unless-stopped
tensorboard-ml:
image: tensorflow/tensorflow:latest
container_name: "PHANTOM-tensorboard-ml"
ports:
- "6006:6006"
volumes:
- ./experiments/ml/runs:/logs
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
restart: unless-stopped
backend:
container_name: "PHANTOM-backend"
build:
@@ -9,6 +48,9 @@ services:
environment:
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
- BACKEND_PORT=5000
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
depends_on:
- kafka
restart: unless-stopped
@@ -68,6 +110,149 @@ services:
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
restart: unless-stopped
postgres:
container_name: "PHANTOM-postgres"
image: postgres:13
environment:
- POSTGRES_USER=airflow
- POSTGRES_PASSWORD=airflow
- POSTGRES_DB=airflow
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
airflow-init:
container_name: "PHANTOM-airflow-init"
build:
context: .
dockerfile: docker/Airflow.dockerfile
depends_on:
- postgres
environment:
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__LOAD_EXAMPLES=false
- 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_WWW_USER_CREATE=true
- _AIRFLOW_WWW_USER_USERNAME=admin
- _AIRFLOW_WWW_USER_PASSWORD=admin
- REDIS_HOST=redis
- REDIS_PORT=6379
command: version
restart: "no"
airflow-webserver:
container_name: "PHANTOM-airflow-webserver"
build:
context: .
dockerfile: docker/Airflow.dockerfile
depends_on:
- postgres
- airflow-init
- redis
environment:
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
- AIRFLOW__CORE__LOAD_EXAMPLES=false
- 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__SECRET_KEY=${AIRFLOW_SECRET_KEY}
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
- BACKEND_URL=http://backend:5000
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- REDIS_HOST=redis
- REDIS_PORT=6379
ports:
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
command: webserver
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
airflow-scheduler:
container_name: "PHANTOM-airflow-scheduler"
build:
context: .
dockerfile: docker/Airflow.dockerfile
depends_on:
airflow-webserver:
condition: service_healthy
redis:
condition: service_started
environment:
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
- AIRFLOW__CORE__LOAD_EXAMPLES=false
- 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__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
- BACKEND_URL=http://backend:5000
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- REDIS_HOST=redis
- REDIS_PORT=6379
command: scheduler
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
pricing-provider:
container_name: "PHANTOM-pricing-provider"
build:
context: .
dockerfile: docker/Provider.dockerfile
depends_on:
- redis
- kafka
environment:
- PROVIDER_PORT=5001
- REDIS_HOST=redis
- REDIS_PORT=6379
- KAFKA_HOST=kafka
- KAFKA_PORT=29092
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- BACKEND_URL=http://localhost:5000
ports:
- "${PROVIDER_PORT:-5001}:5001"
restart: unless-stopped
volumes:
phantom_kafka_data:
phantom_redis_data:
postgres_data:

30
docker/Airflow.dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM apache/airflow:2.7.3-python3.11
USER root
# install system deps if needed
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
USER airflow
# copy requirements for pipeline dependencies
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
# install postgres driver and providers
RUN pip install --no-cache-dir \
psycopg2-binary \
apache-airflow-providers-postgres
# set airflow home
ENV AIRFLOW_HOME=/opt/airflow
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
# create logs and plugins dirs (airflow expects them)
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins

View File

@@ -0,0 +1,41 @@
FROM apache/airflow:2.7.3-python3.11
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
supervisor \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
USER airflow
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
RUN pip install --no-cache-dir \
psycopg2-binary \
apache-airflow-providers-postgres
ENV AIRFLOW_HOME=/opt/airflow
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
# copy all code into image (standalone - no volume mounts needed)
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
# copy entrypoint script
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
USER root
RUN chmod +x /entrypoint.sh
USER airflow
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies including graphviz
RUN apt-get update && apt-get install -y \
gcc \
g++ \
graphviz \
libgraphviz-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
COPY backend/provider/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code into image
COPY lib/ /app/lib/
COPY experiments/procesing/ /app/procesing/
COPY backend/provider/ /app/provider/
ENV PYTHONPATH=/app:/app/lib:/app/procesing
WORKDIR /app/provider
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]

View File

@@ -0,0 +1,112 @@
FROM google/cloud-sdk:slim
# Install tmux to manage multiple watchdogs and jq for json parsing
RUN apt-get update && \
apt-get install -y tmux jq && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the orchestration scripts and configs
COPY tpu_orchestration/ /app/tpu_orchestration/
# Make sure scripts are executable
RUN chmod +x /app/tpu_orchestration/watchdog.sh
RUN chmod +x /app/tpu_orchestration/tpu_startup.sh
# Create an entrypoint script that launches a watchdog for each config
COPY <<-'EOF' /app/entrypoint.sh
#!/bin/bash
set -e
# Make sure required variables are set
if [ -z "$HF_TOKEN" ]; then
echo "Error: HF_TOKEN environment variable is required."
exit 1
fi
if [ -z "$WANDB_API_KEY" ]; then
echo "Warning: WANDB_API_KEY environment variable is not set. Wandb logging may fail on TPUs."
fi
# Authenticate gcloud if credentials are provided
if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ]; then
CRED_TYPE=$(jq -r '.type' "$GOOGLE_APPLICATION_CREDENTIALS" 2>/dev/null || echo "unknown")
if [ "$CRED_TYPE" = "service_account" ]; then
echo "Authenticating gcloud using service account key..."
gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS"
if [ -z "$PROJECT_ID" ]; then
PROJECT_ID=$(jq -r '.project_id // empty' "$GOOGLE_APPLICATION_CREDENTIALS")
fi
elif [ "$CRED_TYPE" = "authorized_user" ]; then
echo "Using authorized_user credentials via credential file override..."
export CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE="$GOOGLE_APPLICATION_CREDENTIALS"
if gcloud auth print-access-token >/dev/null 2>&1; then
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true)
if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then
ACTIVE_ACCOUNT=$(jq -r '.account // empty' "$GOOGLE_APPLICATION_CREDENTIALS")
fi
if [ -n "$ACTIVE_ACCOUNT" ] && [ "$ACTIVE_ACCOUNT" != "(unset)" ]; then
echo "Using gcloud account: $ACTIVE_ACCOUNT"
else
echo "Using gcloud credential override from $GOOGLE_APPLICATION_CREDENTIALS"
fi
else
echo "Warning: credential file override token check failed. Falling back to mounted gcloud config."
unset CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE
if [ -n "$GCP_ACCOUNT" ]; then
gcloud config set account "$GCP_ACCOUNT" >/dev/null 2>&1 || true
fi
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true)
if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then
echo "Error: no active gcloud account available. Run 'gcloud auth login' on host and mount ~/.config/gcloud, or use a service account key."
exit 1
fi
echo "Using gcloud account: $ACTIVE_ACCOUNT"
fi
else
echo "Warning: unsupported credential file type '$CRED_TYPE'. Falling back to mounted gcloud config."
fi
else
echo "Note: Assuming gcloud config is mounted from host."
fi
if [ -n "$PROJECT_ID" ]; then
gcloud config set project "$PROJECT_ID"
echo "Set project to $PROJECT_ID"
fi
# Run the watchdogs in the background using bash instead of tmux
# Tmux needs a TTY to attach properly which we might not have in docker
# Stagger startups by 15s to prevent simultaneous TPU creation quota hits
CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-"*.conf"}
shopt -s nullglob
CONFIGS=(/app/tpu_orchestration/configs/$CONFIG_PATTERN)
if [ ${#CONFIGS[@]} -eq 0 ]; then
echo "Error: no watchdog configs matched pattern '$CONFIG_PATTERN'."
exit 1
fi
echo "Using watchdog config pattern: $CONFIG_PATTERN"
DELAY=0
for conf in "${CONFIGS[@]}"; do
echo "Starting watchdog for $(basename "$conf" .conf) (delay: ${DELAY}s)"
(sleep $DELAY && /app/tpu_orchestration/watchdog.sh "$conf") &
DELAY=$((DELAY + 15))
done
echo "All watchdogs queued with staggered startup."
# Keep the container running
wait
EOF
RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]

15
docker/Trainer.dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# 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
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
COPY engine /app/engine
ENV PYTHONPATH=/app
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
# init db and create admin user on first run
airflow db migrate
# create admin user if not exists
airflow users create \
--username "${AIRFLOW_ADMIN_USER:-admin}" \
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
--firstname Admin \
--lastname User \
--role Admin \
--email admin@example.com || true
# start scheduler in background
airflow scheduler &
# start webserver in foreground (Railway needs one foreground process)
exec airflow webserver --port ${PORT:-8080}

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,7 @@
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

21
docs/goals/goals.csv Normal file
View File

@@ -0,0 +1,21 @@
store_mode,task_name,task_description,definition_of_done
airline,The Indecisive Executive (SEA-LAX),"You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the ""Flexible"" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket.","Booking for SEA-LAX Business Class is completed."
airline,The Cross-Country Splurge (LAX-JFK),"You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class.","Booking for LAX-JFK First Class is completed."
airline,The Budget Student (DFW-ORD),"You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the ""Premium"" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket.","Booking for DFW-ORD Economy Class is completed."
airline,The Quick Hop Commuter (LAX-SFO),"You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it.","Booking for LAX-SFO is completed."
airline,The Status Chaser (SFO-SEA),"You are trying to earn airline points and need a ""Premium"" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the ""Premium"" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat.","Booking for SFO-SEA Premium Economy is completed."
airline,The Family Reunion (MIA-ATL),"You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option.","Booking for MIA-ATL (Premium) is completed."
airline,The Red Eye Skeptic (LAX-JFK),"You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time.","Booking for LAX-JFK is completed."
airline,The Refundable Requirement (ATL-DFW),"Your meeting in Dallas might get cancelled, so you strictly need a ""Refundable"" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists ""Refundable"". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind.","Booking for ATL-DFW First Class is completed."
airline,The Hub Connector (ORD-MIA),"You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the ""stops"" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy.","Booking for ORD-MIA Economy is completed."
airline,The West Coast Hopper (SEA-LAX Business),"You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat.","Booking for SEA-LAX Business is completed."
hotel,The Honeymoon Suite (Presidential),"It is your honeymoon. You want the best room available, specifically one with a ""jacuzzi"". 1) Search for a room for 2 people. 2) Identify the ""Presidential Suite"". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the ""Executive Suite"" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it.","Booking for the Presidential Suite is completed."
hotel,The Digital Nomad (Executive),"You are working remotely and strictly need a ""workspace"". 1) Search for a room. 2) Check the ""Executive Suite"" details for a workspace. 3) Check the ""Deluxe Room"" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it.","Booking for the Executive Suite is completed."
hotel,The Safety First (Superior),"You are traveling with valuables and need a ""safe"" in the room. 1) Search for a room. 2) Look at the ""Standard Room"" amenities. Does it have a safe? 3) Look at the ""Superior Room"". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room.","Booking for the Superior Room is completed."
hotel,The Bachelor Party (Max Occupancy),"You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a ""Standard Room"". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite.","Booking for the Presidential Suite is completed."
hotel,The Budget Refundable (Junior),"You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the ""Standard"" and ""Superior"" rooms. Notice they are likely Non-Refundable. 4) Find the ""Junior Suite"" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility.","Booking for the Junior Suite is completed."
hotel,The View Hunter (Executive),"You want a room with a ""city_view"" or balcony. 1) Search for a room. 2) Check the amenities of the ""Deluxe Room"". 3) Check the amenities of the ""Executive Suite"". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it.","Booking for the Executive Suite is completed."
hotel,The Just-A-Bed (Standard),"You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has ""wifi"". 4) Briefly glance at the ""Superior Room"" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately.","Booking for the Standard Room is completed."
hotel,The Family Vacation (Deluxe),"You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the ""Deluxe Room"". 3) Check the amenities for ""coffee_maker"" (parents need coffee). 4) Compare it with the ""Junior Suite"". 5) Decide the Deluxe Room is sufficient value and book it.","Booking for the Deluxe Room is completed."
hotel,The Long Stay (Junior),"You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the ""Junior Suite"". 3) Check the amenities for a ""mini_fridge"" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the ""Standard Room"" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it.","Booking for the Junior Suite is completed."
hotel,The Last Minute Panic (Superior),"It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the ""Superior Room"" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible.","Booking for the Superior Room is completed."
1 store_mode task_name task_description definition_of_done
2 airline The Indecisive Executive (SEA-LAX) You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the "Flexible" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket. Booking for SEA-LAX Business Class is completed.
3 airline The Cross-Country Splurge (LAX-JFK) You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class. Booking for LAX-JFK First Class is completed.
4 airline The Budget Student (DFW-ORD) You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the "Premium" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket. Booking for DFW-ORD Economy Class is completed.
5 airline The Quick Hop Commuter (LAX-SFO) You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it. Booking for LAX-SFO is completed.
6 airline The Status Chaser (SFO-SEA) You are trying to earn airline points and need a "Premium" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the "Premium" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat. Booking for SFO-SEA Premium Economy is completed.
7 airline The Family Reunion (MIA-ATL) You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option. Booking for MIA-ATL (Premium) is completed.
8 airline The Red Eye Skeptic (LAX-JFK) You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time. Booking for LAX-JFK is completed.
9 airline The Refundable Requirement (ATL-DFW) Your meeting in Dallas might get cancelled, so you strictly need a "Refundable" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists "Refundable". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind. Booking for ATL-DFW First Class is completed.
10 airline The Hub Connector (ORD-MIA) You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the "stops" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy. Booking for ORD-MIA Economy is completed.
11 airline The West Coast Hopper (SEA-LAX Business) You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat. Booking for SEA-LAX Business is completed.
12 hotel The Honeymoon Suite (Presidential) It is your honeymoon. You want the best room available, specifically one with a "jacuzzi". 1) Search for a room for 2 people. 2) Identify the "Presidential Suite". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the "Executive Suite" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it. Booking for the Presidential Suite is completed.
13 hotel The Digital Nomad (Executive) You are working remotely and strictly need a "workspace". 1) Search for a room. 2) Check the "Executive Suite" details for a workspace. 3) Check the "Deluxe Room" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it. Booking for the Executive Suite is completed.
14 hotel The Safety First (Superior) You are traveling with valuables and need a "safe" in the room. 1) Search for a room. 2) Look at the "Standard Room" amenities. Does it have a safe? 3) Look at the "Superior Room". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room. Booking for the Superior Room is completed.
15 hotel The Bachelor Party (Max Occupancy) You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a "Standard Room". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite. Booking for the Presidential Suite is completed.
16 hotel The Budget Refundable (Junior) You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the "Standard" and "Superior" rooms. Notice they are likely Non-Refundable. 4) Find the "Junior Suite" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility. Booking for the Junior Suite is completed.
17 hotel The View Hunter (Executive) You want a room with a "city_view" or balcony. 1) Search for a room. 2) Check the amenities of the "Deluxe Room". 3) Check the amenities of the "Executive Suite". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it. Booking for the Executive Suite is completed.
18 hotel The Just-A-Bed (Standard) You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has "wifi". 4) Briefly glance at the "Superior Room" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately. Booking for the Standard Room is completed.
19 hotel The Family Vacation (Deluxe) You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the "Deluxe Room". 3) Check the amenities for "coffee_maker" (parents need coffee). 4) Compare it with the "Junior Suite". 5) Decide the Deluxe Room is sufficient value and book it. Booking for the Deluxe Room is completed.
20 hotel The Long Stay (Junior) You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the "Junior Suite". 3) Check the amenities for a "mini_fridge" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the "Standard Room" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it. Booking for the Junior Suite is completed.
21 hotel The Last Minute Panic (Superior) It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the "Superior Room" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible. Booking for the Superior Room is completed.

View File

@@ -17,8 +17,8 @@
<meta property="og:site_name" content="PHANTOM Research">
<meta property="og:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta property="og:description" content="Developing pricing heuristics to protect e-commerce platforms from systematic exploitation by LLM agents in dynamic pricing environments through behavioral signature detection.">
<meta property="og:url" content="TODO">
<meta property="og:image" content="TODO">
<meta property="og:url" content="https://velocitatem.github.io/PHANTOM/">
<meta property="og:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PHANTOM Research Preview">
@@ -30,24 +30,19 @@
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<!-- TODO: Replace with your lab/institution Twitter handle -->
<meta name="twitter:site" content="@YOUR_TWITTER_HANDLE">
<!-- TODO: Replace with first author's Twitter handle -->
<meta name="twitter:creator" content="@AUTHOR_TWITTER_HANDLE">
<!-- TODO: Same as paper title above -->
<meta name="twitter:title" content="PAPER_TITLE">
<!-- TODO: Same as description above -->
<meta name="twitter:description" content="BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS">
<!-- TODO: Same as social preview image above -->
<meta name="twitter:image" content="https://YOUR_DOMAIN.com/static/images/social_preview.png">
<meta name="twitter:image:alt" content="PAPER_TITLE - Research Preview">
<meta name="twitter:site" content="@velocitatem">
<meta name="twitter:creator" content="@velocitatem">
<meta name="twitter:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta name="twitter:description" content="A thesis project on defending dynamic pricing against LLM-driven reconnaissance and transaction orchestration.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
<meta name="twitter:image:alt" content="PHANTOM research visual">
<!-- Academic/Research Specific -->
<meta name="citation_title" content="Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta name="citation_author" content="Rösel, Daniel">
<meta name="citation_publication_date" content="2025">
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
<meta name="citation_pdf_url" content="TODO">
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
<!-- Additional SEO -->
<meta name="theme-color" content="#2563eb">
@@ -103,50 +98,42 @@
{
"@context": "https://schema.org",
"@type": "ScholarlyArticle",
"headline": "PAPER_TITLE",
"description": "BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS",
"headline": "PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms",
"description": "Research on preserving dynamic pricing integrity under LLM-mediated reconnaissance and purchasing behavior.",
"author": [
{
"@type": "Person",
"name": "FIRST_AUTHOR_NAME",
"name": "Daniel Rösel",
"affiliation": {
"@type": "Organization",
"name": "INSTITUTION_NAME"
}
},
{
"@type": "Person",
"name": "SECOND_AUTHOR_NAME",
"affiliation": {
"@type": "Organization",
"name": "INSTITUTION_NAME"
"name": "IE University"
}
}
],
"datePublished": "2024-01-01",
"datePublished": "2025-01-01",
"publisher": {
"@type": "Organization",
"name": "CONFERENCE_OR_JOURNAL_NAME"
"name": "IE University"
},
"url": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE",
"image": "https://YOUR_DOMAIN.com/static/images/social_preview.png",
"keywords": ["KEYWORD1", "KEYWORD2", "KEYWORD3", "machine learning", "computer vision"],
"abstract": "FULL_ABSTRACT_TEXT_HERE",
"citation": "BIBTEX_CITATION_HERE",
"url": "https://velocitatem.github.io/PHANTOM/",
"image": "https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg",
"keywords": ["dynamic pricing", "llm agents", "e-commerce", "distributionally robust optimization", "reinforcement learning"],
"abstract": "This thesis formalizes Cost of Information erosion under agentic reconnaissance, learns separable human and agent behavior kernels, and trains contamination-aware robust pricing policies.",
"citation": "Rösel, Daniel. PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms. IE University, 2025.",
"isAccessibleForFree": true,
"license": "https://creativecommons.org/licenses/by/4.0/",
"mainEntity": {
"@type": "WebPage",
"@id": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE"
"@id": "https://velocitatem.github.io/PHANTOM/"
},
"about": [
{
"@type": "Thing",
"name": "RESEARCH_AREA_1"
"name": "Dynamic Pricing"
},
{
"@type": "Thing",
"name": "RESEARCH_AREA_2"
"name": "Agent Behavior Modeling"
}
]
}
@@ -158,8 +145,7 @@
"@context": "https://schema.org",
"@type": "Organization",
"name": "IE University",
"url": "https://www.ie.edu",
"logo": "TODO"
"url": "https://www.ie.edu"
}
</script>
</head>
@@ -173,45 +159,72 @@
<!-- More Works Dropdown -->
<div class="more-works-container">
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View More Works from Our Lab">
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View project links and artifacts">
<i class="fas fa-flask"></i>
More Works
Project Links
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="more-works-dropdown" id="moreWorksDropdown">
<div class="dropdown-header">
<h4>More Works from Our Lab</h4>
<h4>Project Links</h4>
<button class="close-btn" onclick="toggleMoreWorks()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="works-list">
<!-- TODO: Replace with your lab's related works -->
<a href="https://arxiv.org/abs/PAPER_ID_1" class="work-item" target="_blank">
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" class="work-item" target="_blank">
<div class="work-info">
<!-- TODO: Replace with actual paper title -->
<h5>Paper Title 1</h5>
<!-- TODO: Replace with brief description -->
<p>Brief description of the work and its main contribution.</p>
<!-- TODO: Replace with venue and year -->
<span class="work-venue">Conference/Journal 2024</span>
<h5>Thesis PDF</h5>
<p>Latest public build of the full thesis document.</p>
<span class="work-venue">IE University, 2025</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<!-- TODO: Add more related works or remove extra items -->
<a href="https://arxiv.org/abs/PAPER_ID_2" class="work-item" target="_blank">
<a href="https://github.com/velocitatem/PHANTOM" class="work-item" target="_blank">
<div class="work-info">
<h5>Paper Title 2</h5>
<p>Brief description of the work and its main contribution.</p>
<span class="work-venue">Conference/Journal 2023</span>
<h5>PHANTOM Repository</h5>
<p>Monorepo with paper source, platform code, and experiments.</p>
<span class="work-venue">Open Source</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://arxiv.org/abs/PAPER_ID_3" class="work-item" target="_blank">
<a href="https://github.com/velocitatem/p4p" class="work-item" target="_blank">
<div class="work-info">
<h5>Paper Title 3</h5>
<p>Brief description of the work and its main contribution.</p>
<span class="work-venue">Conference/Journal 2023</span>
<h5>P4P Interaction Layer</h5>
<p>Reusable storefront and logging layer released for replication.</p>
<span class="work-venue">Public Artifact</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://phantom-hotel.vercel.app" class="work-item" target="_blank">
<div class="work-info">
<h5>Hotel Mode Demo</h5>
<p>Public deployment of the hotel-style experiment interface.</p>
<span class="work-venue">Live Demo</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://phantom-airline.vercel.app" class="work-item" target="_blank">
<div class="work-info">
<h5>Airline Mode Demo</h5>
<p>Public deployment of the airline-style experiment interface.</p>
<span class="work-venue">Live Demo</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://blog.alves.world/series/phantom" class="work-item" target="_blank">
<div class="work-info">
<h5>Blog Series</h5>
<p>Behind-the-scenes posts covering thesis process, tooling, and insights.</p>
<span class="work-venue">To Boldly Code</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="goals/README.md" class="work-item" target="_blank">
<div class="work-info">
<h5>Goal Library</h5>
<p>Task definitions used to assign actor objectives in experiments.</p>
<span class="work-venue">Experiment Design</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
@@ -233,14 +246,23 @@
<div class="is-size-5 publication-authors">
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
</div>
<div class="column has-text-centered">
<div class="publication-links">
<!-- TODO: Update with your arXiv paper ID -->
<span class="link-block">
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
<a href="https://blog.alves.world/series/phantom" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-blog"></i>
</span>
<span>Blog Series</span>
</a>
</span>
<span class="link-block">
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-file-pdf"></i>
@@ -249,14 +271,13 @@
</a>
</span>
<!-- TODO: Add your supplementary material PDF or remove this section -->
<span class="link-block">
<a href="static/pdfs/supplementary_material.pdf" target="_blank"
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-file-pdf"></i>
<i class="fas fa-database"></i>
</span>
<span>Supplementary</span>
<span>Dataset</span>
</a>
</span>
@@ -270,42 +291,43 @@
</a>
</span>
<!-- TODO: Update with your arXiv paper ID -->
<span class="link-block">
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
<a href="https://phantom-hotel.vercel.app" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="ai ai-arxiv"></i>
<i class="fas fa-globe"></i>
</span>
<span>arXiv</span>
<span>Hotel Demo</span>
</a>
</span>
<span class="link-block">
<a href="https://phantom-airline.vercel.app" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-plane"></i>
</span>
<span>Airline Demo</span>
</a>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Teaser video-->
<section class="hero teaser">
<div class="container is-max-desktop">
<div class="hero-body">
<!-- TODO: Replace with your teaser video -->
<video poster="" id="tree" autoplay controls muted loop height="100%" preload="metadata">
<!-- TODO: Add your video file path here -->
<source src="static/videos/banner_video.mp4" type="video/mp4">
</video>
<!-- TODO: Replace with your video description -->
<h2 class="subtitle has-text-centered">
Aliquam vitae elit ullamcorper tellus egestas pellentesque. Ut lacus tellus, maximus vel lectus at, placerat pretium mi. Maecenas dignissim tincidunt vestibulum. Sed consequat hendrerit nisl ut maximus.
</h2>
<div class="publication-banner">
<img src="static/images/banner.svg" alt="PHANTOM teaser diagram connecting vulnerability, behavioral signal, and robust control" width="1920" height="1080" decoding="async" style="display:block; width:100%; height:auto;" onerror="this.onerror=null;this.src='static/images/carousel2.jpg';"/>
</div>
</div>
</div>
</section>
<!-- End teaser video -->
<!-- Paper abstract -->
<section class="section hero is-light">
@@ -315,7 +337,10 @@
<h2 class="title is-3">Abstract</h2>
<div class="content has-text-justified">
<p>
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 behaviour 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.
When you shop online, prices often change based on how much interest you show — the more you browse, the more the site learns about your intent and may raise prices accordingly. This works because stores assume that a curious, engaged shopper is more likely to buy. But AI assistants are now doing the shopping research on behalf of users: they browse in one session to gather price information and then let the user purchase in a fresh session at the lower, unadjusted price. The store never sees the connection between the two, so it never gets to factor in that genuine intent — and loses the revenue it would have earned.
</p>
<p>
PHANTOM studies this problem and builds defenses against it. We created a realistic fake store (in hotel and airline modes) where both real people and AI agents were given shopping tasks, and we recorded every click, scroll, and page visit. By comparing how humans and AI agents move through a site, we found clear patterns that tell them apart. We then used those patterns to build a smarter pricing system that can recognize when it is likely talking to an AI scout and adjust its strategy accordingly — protecting the store's margins without making things worse for genuine shoppers.
</p>
</div>
</div>
@@ -324,97 +349,90 @@
</section>
<!-- End paper abstract -->
<section class="section">
<div class="container is-max-desktop">
<div class="content has-text-justified">
<h2 class="title is-3 has-text-centered">Project Scope</h2>
<p>
The current thesis revision extends both theory and implementation. The main research question is how a pricing system can preserve margin integrity when browsing and purchasing are increasingly orchestrated by AI agents.
</p>
<ul>
<li>Formal contribution: a Cost of Information erosion theorem showing why price-query saturation can collapse dynamic pricing power.</li>
<li>System contribution: a hybrid online/offline stack (Next.js storefront, pricing provider, Kafka event streams, Airflow ETL, Redis serving layer).</li>
<li>Modeling contribution: class-specific transition kernels for human and agent behavior, with KL-divergence based separability scores.</li>
<li>Control contribution: a contamination-aware DR-RL pricing policy trained under distributional uncertainty using Wasserstein-style robustness.</li>
</ul>
<p>
Controlled trials currently include balanced human and agent sessions with goal-driven tasks across hotel and airline interfaces. Early separability results are strong (Mann-Whitney U=2.0, p=0.0006), while robust pricing gains remain regime-dependent and are being calibrated in larger sweeps.
</p>
</div>
</div>
</section>
<!-- Image carousel -->
<!--
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<div id="results-carousel" class="carousel results-carousel">
<div class="item">
<!-- TODO: Replace with your research result images -->
<img src="static/images/carousel1.jpg" alt="First research result visualization" loading="lazy"/>
<!-- TODO: Replace with description of this result -->
<h2 class="subtitle has-text-centered">
First image description.
Early simulator traces showing how policy choice can push prices toward aggressive high-end regimes.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel2.jpg" alt="Second research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Second image description.
Human and agent behavior diverge at the transition-kernel level, enabling usable session-level separability.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel3.jpg" alt="Third research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Third image description.
End-to-end architecture linking web interactions, pricing queries, event streams, and model updates.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel4.jpg" alt="Fourth research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Fourth image description.
Contamination-aware evaluation compares robust and non-robust pricing behavior across alpha sweeps.
</h2>
</div>
</div>
</div>
</div>
</section>
-->
<!-- End image carousel -->
<!-- Youtube video -->
<section class="hero is-small is-light">
<div class="hero-body">
<div class="container">
<!-- Paper video. -->
<h2 class="title is-3">Video Presentation</h2>
<div class="columns is-centered has-text-centered">
<div class="column is-four-fifths">
<div class="publication-video">
<!-- TODO: Replace with your YouTube video ID -->
<iframe src="https://www.youtube.com/embed/JkaxUblCGz0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- End youtube video -->
<!-- Video carousel -->
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<h2 class="title is-3">Another Carousel</h2>
<div id="results-carousel" class="carousel results-carousel">
<h2 class="title is-3">Defense Scenes</h2>
<div id="videos-carousel" class="carousel results-carousel">
<div class="item item-video1">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video1" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel1.mp4" type="video/mp4">
<source src="static/videos/COIFirstPrinciplesScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">COI from first principles.</h2>
</div>
<div class="item item-video2">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video2" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel2.mp4" type="video/mp4">
<source src="static/videos/BehaviorKernelConstructionScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">Behavioral kernel construction: learning how humans and agents differ.</h2>
</div>
<div class="item item-video3">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video3" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel3.mp4" type="video/mp4">
<source src="static/videos/RobustControlScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">Distributionally robust control loop.</h2>
</div>
</div>
</div>
@@ -430,11 +448,10 @@
<!-- Paper poster -->
<section class="hero is-small is-light">
<div class="hero-body">
<div class="container">
<h2 class="title">Poster</h2>
<div class="container">
<h2 class="title">Full Thesis</h2>
<!-- TODO: Replace with your poster PDF -->
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
<iframe title="PHANTOM thesis PDF" src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
</iframe>
</div>
@@ -456,7 +473,7 @@
</div>
<pre id="bibtex-code"><code>@thesis{Rosel2025PHANTOM,
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
author={R{\"o}sel, Daniel},
author={Rösel, Daniel},
school={IE University},
year={2025},
address={Madrid, Spain},

246
docs/static/images/banner.svg vendored Normal file
View File

@@ -0,0 +1,246 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080" style="background-color: #FAFAFA; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<defs>
<!-- Soft Drop Shadow for Panels -->
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%">
<feDropShadow dx="2" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.06"/>
</filter>
<filter id="light-shadow" x="-5%" y="-5%" width="110%" height="110%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.04"/>
</filter>
<!-- Arrowhead Marker -->
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888" />
</marker>
<marker id="arrow-dark" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555555" />
</marker>
</defs>
<!-- COLUMN DIVIDERS -->
<line x1="640" y1="60" x2="640" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
<line x1="1280" y1="60" x2="1280" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
<!-- ========================================================= -->
<!-- COLUMN 1: THE THREAT (COI & SATURATION) -->
<!-- ========================================================= -->
<text x="60" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">1. The Vulnerability</text>
<line x1="60" y1="100" x2="580" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: COI Bell Curve -->
<g transform="translate(60, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Cost of Information from First Principles</text>
<text x="0" y="70" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P ~ π(τ)</text>
<text x="0" y="105" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B"><tspan text-decoration="underline">p</tspan> = reservation price</text>
<text x="0" y="140" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">M = P - <tspan text-decoration="underline">p</tspan></text>
<!-- Bell Curve -->
<path d="M 40 340 C 140 340, 160 160, 260 160 C 360 160, 380 340, 480 340" stroke="#3AB09E" stroke-width="5" fill="none"/>
<line x1="40" y1="340" x2="500" y2="340" stroke="#333" stroke-width="2"/>
<!-- Markers p and E[P] -->
<line x1="150" y1="340" x2="150" y2="160" stroke="#E37862" stroke-width="2" stroke-dasharray="6,4"/>
<text x="150" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" text-anchor="middle">p</text>
<line x1="260" y1="340" x2="260" y2="160" stroke="#85B589" stroke-width="2" stroke-dasharray="6,4"/>
<text x="260" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#85B589" text-anchor="middle">E[P]</text>
<!-- COI Annotation -->
<line x1="150" y1="150" x2="260" y2="150" stroke="#E37862" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<text x="310" y="138" font-size="16" fill="#E37862" text-anchor="middle">average information rent</text>
<text x="310" y="118" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" font-weight="bold" text-anchor="middle">COI := E[P] - p</text>
</g>
<!-- Bottom: Agent Saturation -->
<g transform="translate(60, 580)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Why COI Erodes with Agent Saturation</text>
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> = min(p</tspan><tspan font-size="14" dy="5">1</tspan><tspan dy="-5">, ..., p</tspan><tspan font-size="14" dy="5">N</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P(p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> > t) = [1 - F(t)]</tspan><tspan font-size="14" dy="-10">N</tspan></text>
<!-- Erosion Graph -->
<rect x="120" y="150" width="280" height="230" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
<line x1="140" y1="350" x2="380" y2="350" stroke="#333" stroke-width="2"/>
<line x1="140" y1="350" x2="140" y2="170" stroke="#333" stroke-width="2"/>
<text x="260" y="375" font-size="16" font-style="italic" fill="#555" text-anchor="middle">F(t)</text>
<text x="120" y="260" font-size="16" font-style="italic" fill="#555" text-anchor="middle" transform="rotate(-90 120 260)">[1 - F(t)]^N</text>
<!-- Curves -->
<path d="M 140 170 C 220 250, 300 320, 380 350" stroke="#4EA5D9" stroke-width="3" fill="none"/>
<text x="390" y="220" font-size="16" fill="#4EA5D9" font-weight="bold">N=1</text>
<path d="M 140 170 C 180 260, 240 330, 380 350" stroke="#85B589" stroke-width="3" fill="none"/>
<text x="390" y="250" font-size="16" fill="#85B589" font-weight="bold">N=4</text>
<path d="M 140 170 C 150 290, 180 340, 380 350" stroke="#E37862" stroke-width="3" fill="none"/>
<text x="390" y="280" font-size="16" fill="#E37862" font-weight="bold">N=16</text>
<text x="260" y="420" font-size="20" fill="#555" text-anchor="middle">As independent query count grows,</text>
<text x="260" y="445" font-size="20" fill="#E37862" font-weight="bold" text-anchor="middle">realizable markup collapses.</text>
</g>
<!-- ========================================================= -->
<!-- COLUMN 2: THE BEHAVIORAL SIGNAL -->
<!-- ========================================================= -->
<text x="700" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">2. The Behavioral Signals</text>
<line x1="700" y1="100" x2="1220" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: Transition Kernels -->
<g transform="translate(700, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">From Session Paths to Transition Kernels</text>
<text x="0" y="75" font-size="20" fill="#85B589" font-weight="bold">human: start → view → detail → cart → purchase</text>
<text x="0" y="115" font-size="20" fill="#E37862" font-weight="bold">agent: start → view → detail → view → detail</text>
<text x="0" y="170" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">
P&#770;(s'|s) = <tspan font-size="18" dy="-12">N(s,s')</tspan> / <tspan font-size="18" dy="12">Σ N(s,k)</tspan>
</text>
<!-- Matrix Representation -->
<rect x="0" y="220" width="500" height="180" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
<text x="125" y="250" font-size="16" fill="#4EA5D9" text-anchor="middle">transition counts N(s,s')</text>
<text x="375" y="250" font-size="16" fill="#85B589" text-anchor="middle">normalized kernel T</text>
<!-- Matrix 1 -->
<g transform="translate(45, 270)">
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
<text x="80" y="20" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 8.00 0.00 0.00</text>
<text x="80" y="50" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 2.00 5.00 1.00</text>
<text x="80" y="80" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 3.00 2.00 4.00</text>
<text x="80" y="110" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 6.00</text>
</g>
<!-- Arrow -->
<line x1="225" y1="320" x2="265" y2="320" stroke="#999" stroke-width="3" marker-end="url(#arrow-dark)"/>
<!-- Matrix 2 -->
<g transform="translate(295, 270)">
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
<text x="80" y="20" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 0.00</text>
<text x="80" y="50" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.25 0.62 0.13</text>
<text x="80" y="80" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.33 0.22 0.45</text>
<text x="80" y="110" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.14 0.00 0.86</text>
</g>
<text x="250" y="440" font-size="18" fill="#777" text-anchor="middle">Kernel shape is the compact behavioral signature used downstream.</text>
</g>
<!-- Bottom: Separability Distributions -->
<g transform="translate(700, 600)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Separability into a Control Signal</text>
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T&#770;' || T&#772;</tspan><tspan font-size="16" dy="5">H</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">A</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T&#770;' || T&#772;</tspan><tspan font-size="16" dy="5">A</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="155" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
<!-- Curves -->
<g transform="translate(80, 160)">
<line x1="0" y1="200" x2="360" y2="200" stroke="#333" stroke-width="2"/>
<text x="180" y="235" font-family="Georgia, serif" font-style="italic" font-size="22" text-anchor="middle">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
<!-- Human Curve -->
<path d="M 0 200 C 50 200, 80 40, 130 40 C 180 40, 210 200, 260 200" stroke="#4EA5D9" stroke-width="5" fill="none"/>
<text x="70" y="110" font-size="22" fill="#4EA5D9" font-weight="bold">human</text>
<!-- Agent Curve -->
<path d="M 100 200 C 150 200, 180 40, 230 40 C 280 40, 310 200, 360 200" stroke="#E37862" stroke-width="5" fill="none"/>
<text x="290" y="110" font-size="22" fill="#E37862" font-weight="bold">agent</text>
<!-- Decision Boundary -->
<line x1="180" y1="200" x2="180" y2="10" stroke="#999" stroke-width="2" stroke-dasharray="8,5"/>
<text x="180" y="-5" font-size="16" fill="#777" text-anchor="middle">decision boundary</text>
<circle cx="210" cy="200" r="6" fill="#ECA233"/>
<text x="210" y="180" font-family="Georgia" font-style="italic" font-size="20" fill="#ECA233" text-anchor="middle">g_obs</text>
<text x="180" y="280" font-size="18" fill="#555" text-anchor="middle">Positive gap shifts score toward agent traffic.</text>
</g>
</g>
<!-- ========================================================= -->
<!-- COLUMN 3: THE SOLUTION (CONTAMINATION & DR-RL) -->
<!-- ========================================================= -->
<text x="1340" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">3. Robust Control &amp; Contamination</text>
<line x1="1340" y1="100" x2="1860" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: Contamination Generator -->
<g transform="translate(1340, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Contamination Generator G(α)</text>
<!-- Boxes -->
<rect x="20" y="70" width="200" height="50" fill="#D0E5E0" filter="url(#shadow)" rx="6"/>
<text x="120" y="100" font-size="18" fill="#222" text-anchor="middle">labeled human sessions</text>
<rect x="280" y="70" width="200" height="50" fill="#EAD0C8" filter="url(#shadow)" rx="6"/>
<text x="380" y="100" font-size="18" fill="#222" text-anchor="middle">synthetic agent sessions</text>
<!-- Arrows -->
<line x1="120" y1="130" x2="200" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
<line x1="380" y1="130" x2="300" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
<!-- Mixed Batch -->
<rect x="150" y="190" width="200" height="50" fill="#F4E9CD" filter="url(#shadow)" rx="6"/>
<text x="250" y="220" font-size="18" fill="#222" text-anchor="middle">mixed batch for training</text>
<!-- Alpha Bar -->
<text x="250" y="275" font-family="Georgia, serif" font-size="20" fill="#555" text-anchor="middle">alpha = 0.33</text>
<rect x="50" y="290" width="268" height="30" fill="#4EA5D9"/>
<rect x="318" y="290" width="132" height="30" fill="#E37862"/>
<text x="184" y="340" font-size="18" fill="#4EA5D9" text-anchor="middle">human share (1-α)</text>
<text x="384" y="340" font-size="18" fill="#E37862" text-anchor="middle">agent share (α)</text>
</g>
<!-- Bottom: Distributionally Robust Control -->
<g transform="translate(1340, 600)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Distributionally Robust Control Layer</text>
<text x="0" y="80" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">
π* = arg max<tspan font-size="16" dy="5">π</tspan> min<tspan font-size="16" dy="0">Q ∈ U<tspan font-size="12" dy="5">ε</tspan></tspan>
<tspan dy="-10"> E</tspan><tspan font-size="16" dy="5">d ~ Q</tspan>
<tspan dy="-5">[ R(p,d) - λ COI</tspan><tspan font-size="16" dy="5">leak</tspan><tspan dy="-5">(p,τ') ]</tspan>
</text>
<!-- Ambiguity Ball -->
<g transform="translate(140, 260)">
<line x1="-130" y1="0" x2="130" y2="0" stroke="#CCC" stroke-width="2"/>
<line x1="0" y1="-130" x2="0" y2="130" stroke="#CCC" stroke-width="2"/>
<circle cx="0" cy="0" r="110" stroke="#C4A45B" stroke-width="4" fill="rgba(196,164,91,0.06)"/>
<text x="-95" y="-120" font-family="Georgia" font-style="italic" font-size="24" fill="#C4A45B">U<tspan font-size="16" dy="5">ε</tspan></text>
<!-- Points -->
<circle cx="0" cy="0" r="7" fill="#4EA5D9"/>
<text x="12" y="24" font-family="Georgia" font-style="italic" font-size="22" fill="#4EA5D9">P&#770;<tspan font-size="14" dy="5">N</tspan></text>
<circle cx="-60" cy="-40" r="7" fill="#E37862"/>
<text x="-140" y="-50" font-family="Georgia" font-style="italic" font-size="18" fill="#E37862">worst-case Q*</text>
<circle cx="50" cy="-70" r="6" fill="#85B589"/>
<circle cx="70" cy="50" r="6" fill="#85B589"/>
<circle cx="-40" cy="80" r="6" fill="#85B589"/>
</g>
<!-- Process Steps -->
<g transform="translate(320, 140)">
<rect x="0" y="0" width="220" height="45" fill="#FDEFEF" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="28" font-size="16" fill="#E37862" font-weight="bold" text-anchor="middle">inner min picks Q*</text>
<line x1="110" y1="55" x2="110" y2="85" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
<rect x="0" y="95" width="220" height="45" fill="#F4E9CD" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="123" font-size="16" fill="#9E8033" font-weight="bold" text-anchor="middle">sample demand from Q*</text>
<line x1="110" y1="150" x2="110" y2="180" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
<rect x="0" y="190" width="220" height="45" fill="#E6F2ED" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="218" font-size="16" fill="#428062" font-weight="bold" text-anchor="middle">outer max updates policy</text>
</g>
<text x="250" y="440" font-size="18" fill="#555" text-anchor="middle">Reward is evaluated on demand drawn from Q*, then used for the policy step.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
docs/static/videos/DefenseOpening.mp4 vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
docs/static/videos/SystemLoopScene.mp4 vendored Normal file

Binary file not shown.

BIN
docs/static/videos/TakeawayScene.mp4 vendored Normal file

Binary file not shown.

0
engine/__init__.py Normal file
View File

View File

@@ -0,0 +1 @@
__all__ = ["evaluate", "make_env", "train_qtable", "train_sb3"]

181
engine/backends/common.py Normal file
View File

@@ -0,0 +1,181 @@
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
def make_env(cfg: Mapping[str, Any]):
from gymnasium.wrappers import FlattenObservation
from ..lib.wrappers import EconomicMetricsWrapper
from ..wrapper import PHANTOM
env = PHANTOM(
n_products=int(cfg["n_products"]),
alpha=float(cfg["alpha"]),
N=int(cfg["N"]),
agent_params=(
float(cfg.get("agent_mu", 45.0)),
float(cfg.get("agent_std", 15.0)),
),
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"]),
robust_rollouts=int(cfg.get("robust_rollouts", 1)),
info_value=float(cfg["info_value"]),
eta_ux=float(cfg.get("eta_ux", 0.5)),
reward_profit_weight=float(cfg.get("reward_profit_weight", 1.0)),
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)
return FlattenObservation(env)
def _action(agent: Any, obs: Any, deterministic: bool = True):
out = agent.predict(obs, deterministic=deterministic)
action = out[0] if isinstance(out, tuple) else out
if isinstance(action, np.ndarray) and action.size == 1:
return int(action.reshape(-1)[0])
return action
def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]:
rewards: list[float] = []
revenues: list[float] = []
margins: list[float] = []
coi_levels: list[float] = []
coi_leakages: list[float] = []
volatilities: list[float] = []
upward_volatilities: list[float] = []
supra_shares: list[float] = []
supra_penalties: list[float] = []
agent_probs: list[float] = []
for _ in range(int(episodes)):
obs, _ = env.reset()
done = False
ep_reward = 0.0
ep_revenue = 0.0
ep_margin = 0.0
ep_coi = 0.0
ep_coi_leakage = 0.0
ep_volatility = 0.0
ep_upward_volatility = 0.0
ep_supra_share = 0.0
ep_supra_penalty = 0.0
ep_agent_prob = 0.0
steps = 0
while not done:
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
done = bool(term or trunc)
econ = info.get("economics", {})
ep_reward += float(reward)
ep_revenue += float(econ.get("revenue", info.get("revenue", 0.0)))
ep_margin += float(econ.get("margin", 0.0))
ep_coi += float(econ.get("coi_level", 0.0))
ep_coi_leakage += float(econ.get("coi_leakage", 0.0))
ep_volatility += float(econ.get("volatility", 0.0))
ep_upward_volatility += float(
info.get("upward_volatility", econ.get("upward_volatility", 0.0))
)
ep_supra_share += float(
info.get("supra_share", econ.get("supra_share", 0.0))
)
ep_supra_penalty += float(
info.get("supra_penalty", econ.get("supra_penalty", 0.0))
)
ep_agent_prob += float(econ.get("agent_prob", info.get("agent_prob", 0.0)))
steps += 1
rewards.append(ep_reward)
revenues.append(ep_revenue)
denom = max(steps, 1)
margins.append(ep_margin / denom)
coi_levels.append(ep_coi / denom)
coi_leakages.append(ep_coi_leakage / denom)
volatilities.append(ep_volatility / denom)
upward_volatilities.append(ep_upward_volatility / denom)
supra_shares.append(ep_supra_share / denom)
supra_penalties.append(ep_supra_penalty / denom)
agent_probs.append(ep_agent_prob / denom)
return {
"eval/reward_mean": float(np.mean(rewards)) if rewards else 0.0,
"eval/reward_std": float(np.std(rewards)) if rewards else 0.0,
"eval/revenue_mean": float(np.mean(revenues)) if revenues else 0.0,
"eval/revenue_std": float(np.std(revenues)) if revenues else 0.0,
"eval/margin_mean": float(np.mean(margins)) if margins else 0.0,
"eval/coi_level_mean": float(np.mean(coi_levels)) if coi_levels else 0.0,
"eval/coi_leakage_mean": float(np.mean(coi_leakages)) if coi_leakages else 0.0,
"eval/volatility_mean": float(np.mean(volatilities)) if volatilities else 0.0,
"eval/upward_volatility_mean": (
float(np.mean(upward_volatilities)) if upward_volatilities else 0.0
),
"eval/supra_share_mean": float(np.mean(supra_shares)) if supra_shares else 0.0,
"eval/supra_penalty_mean": (
float(np.mean(supra_penalties)) if supra_penalties else 0.0
),
"eval/agent_prob_mean": float(np.mean(agent_probs)) if agent_probs else 0.0,
}
def evaluate(
agent: Any,
env: Any,
episodes: int,
cfg: Mapping[str, Any] | None = None,
) -> dict[str, float]:
metrics = _evaluate_env(agent, env, episodes)
if cfg is None or not bool(cfg.get("robust_eval_enabled", True)):
return metrics
nominal_alpha = float(cfg.get("alpha", 0.0))
eval_radius = max(float(cfg.get("robust_radius", 0.0)), 0.15)
low_alpha = float(np.clip(nominal_alpha - eval_radius, 0.0, 1.0))
high_alpha = float(np.clip(nominal_alpha + eval_radius, 0.0, 1.0))
shifted_episodes = max(1, int(np.ceil(int(episodes) / 2)))
shifted_rows = []
for tag, alpha in (
("low", low_alpha),
("nominal", nominal_alpha),
("high", high_alpha),
):
eval_cfg = dict(cfg)
eval_cfg["alpha"] = float(alpha)
shifted_env = make_env(eval_cfg)
shifted_metrics = _evaluate_env(agent, shifted_env, shifted_episodes)
shifted_env.close()
shifted_rows.append((tag, alpha, shifted_metrics))
metrics["eval/stress_alpha_low"] = low_alpha
metrics["eval/stress_alpha_high"] = high_alpha
metrics["eval/stress_reward_worst"] = float(
min(row[2]["eval/reward_mean"] for row in shifted_rows)
)
metrics["eval/stress_revenue_worst"] = float(
min(row[2]["eval/revenue_mean"] for row in shifted_rows)
)
metrics["eval/stress_coi_leakage_worst"] = float(
max(row[2]["eval/coi_leakage_mean"] for row in shifted_rows)
)
for tag, alpha, shifted_metrics in shifted_rows:
metrics[f"eval/{tag}_alpha"] = float(alpha)
metrics[f"eval/{tag}_reward_mean"] = float(shifted_metrics["eval/reward_mean"])
metrics[f"eval/{tag}_revenue_mean"] = float(
shifted_metrics["eval/revenue_mean"]
)
metrics[f"eval/{tag}_coi_leakage_mean"] = float(
shifted_metrics["eval/coi_leakage_mean"]
)
return metrics

139
engine/backends/qtable.py Normal file
View File

@@ -0,0 +1,139 @@
from __future__ import annotations
import logging
import time
from typing import Any, Mapping
import numpy as np
from .common import evaluate, make_env
from ..telemetry.wandb import get_wandb_module
logger = logging.getLogger(__name__)
def train_qtable(
cfg: Mapping[str, Any],
) -> tuple[object, dict[str, Any]]:
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"]),
)
total_reward = 0.0
total_revenue = 0.0
steps = 0
epsilon = float(cfg["eps_start"])
log_freq = max(1, int(cfg.get("log_freq", 100)))
console_progress = bool(cfg.get("console_progress", False))
obs, _ = env.reset(seed=int(cfg["seed"]))
started_at = time.perf_counter()
wandb = get_wandb_module()
wandb_live = bool(wandb is not None and wandb.run is not None)
step_offset = max(0, int(cfg.get("wandb_step_offset", 0)))
interval_sums = {
"reward": 0.0,
"revenue": 0.0,
"agent_prob": 0.0,
"alpha_adv": 0.0,
"coi_leakage": 0.0,
}
interval_count = 0
train_events: list[dict[str, float | int]] = []
for _ in range(int(cfg["total_timesteps"])):
action, state = agent.act(obs, epsilon)
nxt, reward, term, trunc, info = env.step(action)
done = bool(term or trunc)
agent.update(state, action, float(reward), agent.encode(nxt), done)
total_reward += float(reward)
revenue = float(info.get("economics", {}).get("revenue", 0.0))
total_revenue += revenue
steps += 1
interval_sums["reward"] += float(reward)
interval_sums["revenue"] += revenue
interval_sums["agent_prob"] += float(info.get("agent_prob", 0.0))
interval_sums["alpha_adv"] += float(info.get("alpha_adv", 0.0))
interval_sums["coi_leakage"] += float(info.get("coi_leakage", 0.0))
interval_count += 1
if steps % log_freq == 0 and interval_count > 0:
denom = float(interval_count)
event = {
"train/reward_mean": interval_sums["reward"] / denom,
"train/revenue_mean": interval_sums["revenue"] / denom,
"train/agent_prob": interval_sums["agent_prob"] / denom,
"train/alpha_adv": interval_sums["alpha_adv"] / denom,
"train/coi_leakage": interval_sums["coi_leakage"] / denom,
"train/epsilon": float(epsilon),
"train/global_step": int(steps),
}
if wandb_live:
try:
wandb.log(dict(event), step=step_offset + int(steps))
except Exception:
wandb_live = False
train_events.append(event)
else:
train_events.append(event)
if console_progress:
elapsed = max(time.perf_counter() - started_at, 1e-6)
speed = steps / elapsed
logger.info(
"step=%d/%d reward=%.3f revenue=%.3f eps=%.4f speed=%.1f steps/s",
steps,
int(cfg["total_timesteps"]),
event["train/reward_mean"],
event["train/revenue_mean"],
event["train/epsilon"],
speed,
)
interval_sums = {key: 0.0 for key in interval_sums}
interval_count = 0
epsilon = max(float(cfg["eps_end"]), epsilon * float(cfg["eps_decay"]))
obs = env.reset()[0] if done else nxt
if interval_count > 0:
denom = float(interval_count)
tail_event = {
"train/reward_mean": interval_sums["reward"] / denom,
"train/revenue_mean": interval_sums["revenue"] / denom,
"train/agent_prob": interval_sums["agent_prob"] / denom,
"train/alpha_adv": interval_sums["alpha_adv"] / denom,
"train/coi_leakage": interval_sums["coi_leakage"] / denom,
"train/epsilon": float(epsilon),
"train/global_step": int(steps),
}
if wandb_live:
try:
wandb.log(dict(tail_event), step=step_offset + int(steps))
except Exception:
wandb_live = False
train_events.append(tail_event)
else:
train_events.append(tail_event)
metrics: dict[str, Any] = {
"train/reward_mean": total_reward / max(steps, 1),
"train/revenue_mean": total_revenue / max(steps, 1),
"train/epsilon": float(epsilon),
"train/global_step": int(cfg["total_timesteps"]),
}
metrics.update(evaluate(agent, eval_env, int(cfg["eval_episodes"]), cfg=cfg))
metrics["_train_events"] = train_events
env.close()
eval_env.close()
return agent, metrics

217
engine/backends/sb3.py Normal file
View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Mapping
from ..lib.callbacks import EvalMetricsCallback, MetricsCallback
from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file
from .common import evaluate, make_env
def _net_arch(name: Any) -> 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]
raw = str(name).lower().strip()
if raw in presets:
return presets[raw]
if "x" in raw:
try:
parsed = [int(v) for v in raw.split("x") if v]
return parsed if parsed else presets["small"]
except ValueError:
return presets["small"]
return presets["small"]
def _activation(name: Any):
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: Mapping[str, Any]) -> dict[str, Any]:
kwargs: dict[str, Any] = {"net_arch": _net_arch(cfg.get("arch", "small"))}
activation = _activation(cfg.get("activation", "relu"))
if activation is not None:
kwargs["activation_fn"] = activation
return kwargs
def build_model(cfg: Mapping[str, Any], env: Any):
try:
from stable_baselines3 import A2C, DQN, PPO
except ImportError as exc:
raise ImportError("stable-baselines3 is required for SB3 algorithms") from exc
algo = str(cfg["algo"])
policy_kwargs = _policy_kwargs(cfg)
device = str(cfg.get("device", "auto"))
seed = int(cfg["seed"])
if algo == "sac":
raise ValueError("sac is not supported with the discrete core env")
if algo == "ppo":
return PPO(
"MlpPolicy",
env,
verbose=1,
device=device,
policy_kwargs=policy_kwargs,
seed=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,
device=device,
policy_kwargs=policy_kwargs,
seed=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,
device=device,
policy_kwargs=policy_kwargs,
seed=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 train_sb3(cfg: Mapping[str, Any]) -> tuple[object, dict[str, Any]]:
try:
from stable_baselines3.common.monitor import Monitor
except ImportError as exc:
raise ImportError("stable-baselines3 is required for SB3 models") from exc
env = Monitor(make_env(cfg))
eval_env = Monitor(make_env(cfg))
model = build_model(cfg, env)
try:
import torch
print(
"PHANTOM_DEVICE: "
+ json.dumps(
{
"requested": str(cfg.get("device", "auto")),
"torch_cuda_available": bool(torch.cuda.is_available()),
"torch_device_count": int(torch.cuda.device_count()),
"sb3_device": str(getattr(model, "device", "unknown")),
}
)
)
except Exception:
pass
metrics_callback = MetricsCallback(
log_histograms=True,
log_freq=int(cfg["log_freq"]),
hist_freq=int(cfg.get("hist_freq", 500)),
step_offset=int(cfg.get("wandb_step_offset", 0)),
)
eval_callback = EvalMetricsCallback(
eval_env,
eval_freq=int(cfg["eval_freq"]),
n_eval_episodes=int(cfg["eval_episodes"]),
step_offset=int(cfg.get("wandb_step_offset", 0)),
deterministic=True,
verbose=0,
)
callbacks = [metrics_callback, eval_callback]
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=callbacks,
reset_num_timesteps=False,
)
model_dir = Path(str(cfg["model_dir"]))
model_dir.mkdir(parents=True, exist_ok=True)
model_path = model_dir / f"phantom_{cfg['algo']}"
model.save(str(model_path))
artifact_name = checkpoint_artifact_name(
cfg,
backend="sb3",
sweep_id=os.getenv("WANDB_SWEEP_ID"),
)
artifact_logged = False
try:
artifact_logged = bool(
log_checkpoint_file(
artifact_name,
file_path=model_path.with_suffix(".zip"),
artifact_file_name="model.zip",
metadata={
"algo": str(cfg.get("algo", "ppo")),
"backend": "sb3",
"seed": int(cfg.get("seed", 0)),
"step": int(getattr(model, "num_timesteps", 0)),
},
)
)
except Exception:
artifact_logged = False
metrics: dict[str, Any] = evaluate(
model,
eval_env,
int(cfg["eval_episodes"]),
cfg=cfg,
)
metrics["train/global_step"] = int(model.num_timesteps)
metrics["model/path"] = str(model_path.with_suffix(".zip"))
metrics["model/artifact_name"] = str(artifact_name)
metrics["model/artifact_logged"] = float(artifact_logged)
metrics["_train_events"] = sorted(
[*metrics_callback.events, *eval_callback.events],
key=lambda event: int(event.get("train/global_step", 0)),
)
env.close()
eval_env.close()
return model, metrics

702
engine/benchmark.py Normal file
View File

@@ -0,0 +1,702 @@
from __future__ import annotations
import os
import subprocess
import sys
import argparse
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
# clear stale TPU locks on startup
if os.path.exists("/dev/accel0"):
try:
subprocess.run(
["rm", "-f", "/tmp/.libtpu_lockfile", "/tmp/libtpu_lockfile"],
stderr=subprocess.DEVNULL,
)
except:
pass
try:
import jax
jax.config.update("jax_threefry_partitionable", True)
except ImportError:
pass
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from .lib.tiers import LinearElasticityPolicy, StaticPolicy, SurgePolicy
from .logging_utils import configure_logging
from .spec import TrainSpec
from .telemetry.wandb import get_wandb_module
wandb = get_wandb_module()
HAS_WANDB = wandb is not None
logger = logging.getLogger(__name__)
def _log(message: str) -> None:
logger.info(message)
def _wandb_run_active() -> bool:
return bool(HAS_WANDB and getattr(wandb, "run", None) is not None)
def _parse_list(raw: str) -> list[str]:
return [x.strip().lower() for x in str(raw).split(",") if x.strip()]
def _parse_float_list(raw: str) -> list[float]:
return [float(x.strip()) for x in str(raw).split(",") if x.strip()]
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 _mode_label_from_baseline(is_baseline: bool) -> str:
return "baseline" if bool(is_baseline) else "defended"
def _action(policy, obs: np.ndarray):
out = policy.predict(obs, deterministic=True)
action = out[0] if isinstance(out, tuple) else out
if isinstance(action, np.ndarray) and action.size == 1:
return int(action.reshape(-1)[0])
return int(action)
def _run_eval_episode(env, policy) -> dict:
obs, _ = env.reset()
done = False
total_reward = 0.0
total_revenue = 0.0
total_margin = 0.0
total_coi = 0.0
price_trace: list[float] = []
step_count = 0
while not done:
action = _action(policy, obs)
obs, reward, term, trunc, info = env.step(action)
done = bool(term or trunc)
econ = info.get("economics", {})
total_reward += float(reward)
total_revenue += float(econ.get("revenue", 0.0))
total_margin += float(econ.get("margin", 0.0))
total_coi += float(econ.get("coi_level", 0.0))
prices = np.asarray(info.get("prices", []), dtype=np.float32)
if prices.size > 0:
price_trace.append(float(np.mean(prices)))
step_count += 1
denom = max(step_count, 1)
return {
"reward": total_reward,
"revenue": total_revenue,
"mean_margin": total_margin / denom,
"mean_coi": total_coi / denom,
"price_trace": price_trace,
}
def _build_tier(name: str, cfg: dict, alpha: float, *, step_offset: int = 0):
from .backends.common import make_env
tier = name.lower().strip()
run_cfg = dict(cfg)
run_cfg["alpha"] = float(alpha)
run_cfg["wandb_step_offset"] = int(step_offset)
if tier == "static":
return StaticPolicy(int(run_cfg["action_levels"])), []
if tier == "surge":
return (
SurgePolicy(
n_actions=int(run_cfg["action_levels"]),
n_products=int(run_cfg["n_products"]),
),
[],
)
if tier == "linear":
warmup_env = make_env(run_cfg)
policy = LinearElasticityPolicy(
n_actions=int(run_cfg["action_levels"]),
n_products=int(run_cfg["n_products"]),
price_low=float(run_cfg["price_low"]),
price_high=float(run_cfg["price_high"]),
)
policy.fit(
warmup_env,
warmup_steps=int(run_cfg.get("linear_warmup_steps", 800)),
seed=int(run_cfg["seed"]),
)
warmup_env.close()
return policy, []
if tier == "qtable":
from .backends.qtable import train_qtable
run_cfg["console_progress"] = True
agent, metrics = train_qtable(run_cfg)
events = metrics.get("_train_events", [])
return agent, events if isinstance(events, list) else []
if tier in {"ppo", "a2c", "dqn"}:
from .backends.sb3 import train_sb3
run_cfg["algo"] = tier
agent, metrics = train_sb3(run_cfg)
events = metrics.get("_train_events", [])
return agent, events if isinstance(events, list) else []
raise ValueError(f"unsupported tier '{name}'")
def _log_train_events(
events: list[dict],
*,
tier_name: str,
mode_label: str,
alpha: float,
step_offset: int,
) -> int:
if not _wandb_run_active():
return int(step_offset)
if not events:
return int(step_offset)
ordered = sorted(
[evt for evt in events if isinstance(evt, dict)],
key=lambda evt: int(evt.get("train/global_step", 0)),
)
if not ordered:
return int(step_offset)
cursor = int(step_offset)
for evt in ordered:
rel_step = max(1, int(evt.get("train/global_step", 0)))
payload = dict(evt)
payload.update(
{
"run.kind": "benchmark",
"runtime/backend": tier_name,
"study/mode": mode_label,
"study/baseline_mode": float(mode_label == "baseline"),
"study/alpha": float(alpha),
}
)
try:
wandb.log(payload, step=cursor + rel_step)
except Exception:
return int(step_offset)
max_rel = max(max(1, int(evt.get("train/global_step", 0))) for evt in ordered)
return cursor + max_rel + 1
def run_benchmark(
cfg: dict,
tiers: list[str],
alpha_values: list[float],
n_episodes: int,
mode_label: str,
step_cursor_start: int = 0,
eval_alpha_values: list[float] | None = None,
):
from .backends.common import make_env
rows: list[dict] = []
traces: list[dict] = []
total_runs = max(1, len(alpha_values) * len(tiers))
run_index = 0
wandb_step_cursor = int(step_cursor_start)
for alpha in alpha_values:
for tier_name in tiers:
run_index += 1
_log(
f"[{run_index}/{total_runs}] alpha={float(alpha):.2f} tier={tier_name}: training"
)
policy, train_events = _build_tier(
tier_name,
cfg,
alpha,
step_offset=wandb_step_cursor,
)
prev_cursor = int(wandb_step_cursor)
wandb_step_cursor = _log_train_events(
train_events,
tier_name=tier_name,
mode_label=mode_label,
alpha=float(alpha),
step_offset=wandb_step_cursor,
)
if wandb_step_cursor == prev_cursor and tier_name in {
"qtable",
"ppo",
"a2c",
"dqn",
}:
wandb_step_cursor += max(1, int(cfg.get("total_timesteps", 1))) + 1
eval_targets = (
[float(value) for value in eval_alpha_values]
if eval_alpha_values
else [float(alpha)]
)
for eval_alpha in eval_targets:
env = make_env({**cfg, "alpha": float(eval_alpha)})
eps = [_run_eval_episode(env, policy) for _ in range(int(n_episodes))]
env.close()
row = {
"tier": tier_name,
"mode": mode_label,
"alpha": float(eval_alpha),
"train_alpha": float(alpha),
"eval_alpha": float(eval_alpha),
"episodes": int(n_episodes),
"mean_reward": float(np.mean([e["reward"] for e in eps])),
"mean_revenue": float(np.mean([e["revenue"] for e in eps])),
"mean_margin": float(np.mean([e["mean_margin"] for e in eps])),
"mean_coi": float(np.mean([e["mean_coi"] for e in eps])),
"std_revenue": float(np.std([e["revenue"] for e in eps])),
}
row["objective_score"] = row["mean_reward"]
rows.append(row)
_log(
f"[{run_index}/{total_runs}] train_alpha={float(alpha):.2f} "
f"eval_alpha={float(eval_alpha):.2f} tier={tier_name}: "
f"reward={row['mean_reward']:.3f} revenue={row['mean_revenue']:.3f} "
f"coi={row['mean_coi']:.4f} score={row['objective_score']:.3f}"
)
max_len = max((len(e["price_trace"]) for e in eps), default=0)
step_means = []
for step in range(max_len):
vals = [
e["price_trace"][step]
for e in eps
if step < len(e["price_trace"])
]
step_means.append(float(np.mean(vals)) if vals else np.nan)
traces.append(
{
"tier": tier_name,
"alpha": float(eval_alpha),
"train_alpha": float(alpha),
"eval_alpha": float(eval_alpha),
"mean_price_trace": step_means,
}
)
if _wandb_run_active():
try:
wandb.log(
{
"run.kind": "benchmark",
"runtime/backend": tier_name,
"study/mode": mode_label,
"study/baseline_mode": float(mode_label == "baseline"),
"study/alpha": float(eval_alpha),
"study/train_alpha": float(alpha),
"study/eval_alpha": float(eval_alpha),
"eval/reward_mean": row["mean_reward"],
"eval/revenue_mean": row["mean_revenue"],
"eval/margin_mean": row["mean_margin"],
"eval/coi_level_mean": row["mean_coi"],
"objective/score": row["objective_score"],
"objective/coi_preserved": row["mean_coi"],
},
step=wandb_step_cursor,
)
except Exception:
pass
wandb_step_cursor += 1
return pd.DataFrame(rows), traces, int(wandb_step_cursor)
def _plot_outputs(df: pd.DataFrame, traces: list[dict], out_dir: Path, stamp: str):
fig1 = plt.figure(figsize=(11, 4.5))
if "mode" in df.columns:
groups = sorted(df[["tier", "mode"]].drop_duplicates().values.tolist())
for tier, mode in groups:
sub = df[(df["tier"] == tier) & (df["mode"] == mode)].sort_values("alpha")
plt.plot(
sub["alpha"],
sub["mean_revenue"],
marker="o",
label=f"{tier}:{mode}",
)
else:
for tier in sorted(df["tier"].unique()):
sub = df[df["tier"] == tier].sort_values("alpha")
plt.plot(sub["alpha"], sub["mean_revenue"], marker="o", label=tier)
plt.xlabel("contamination alpha")
plt.ylabel("mean episode revenue")
plt.title("Revenue under contamination")
plt.grid(alpha=0.3)
plt.legend()
fig1.tight_layout()
rev_path = out_dir / f"benchmark_revenue_{stamp}.png"
fig1.savefig(rev_path, dpi=220)
plt.close(fig1)
fig2 = plt.figure(figsize=(11, 4.5))
if "mode" in df.columns:
groups = sorted(df[["tier", "mode"]].drop_duplicates().values.tolist())
for tier, mode in groups:
sub = df[(df["tier"] == tier) & (df["mode"] == mode)].sort_values("alpha")
plt.plot(
sub["alpha"],
sub["mean_coi"],
marker="s",
label=f"{tier}:{mode}",
)
else:
for tier in sorted(df["tier"].unique()):
sub = df[df["tier"] == tier].sort_values("alpha")
plt.plot(sub["alpha"], sub["mean_coi"], marker="s", label=tier)
plt.xlabel("contamination alpha")
plt.ylabel("mean COI level")
plt.title("COI preservation")
plt.grid(alpha=0.3)
plt.legend()
fig2.tight_layout()
coi_path = out_dir / f"benchmark_coi_{stamp}.png"
fig2.savefig(coi_path, dpi=220)
plt.close(fig2)
focus_alpha = float(df["alpha"].min()) if not df.empty else 0.0
alpha_traces = [t for t in traces if abs(float(t["alpha"]) - focus_alpha) < 1e-9]
fig3 = plt.figure(figsize=(11, 4.5))
for item in alpha_traces:
xs = np.arange(len(item["mean_price_trace"]))
ys = np.asarray(item["mean_price_trace"], dtype=np.float32)
mode = item.get("mode")
label = f"{item['tier']}:{mode}" if mode is not None else str(item["tier"])
plt.plot(xs, ys, label=label)
plt.xlabel("step")
plt.ylabel("mean price")
plt.title(f"Price evolution (alpha={focus_alpha:.2f})")
plt.grid(alpha=0.3)
plt.legend()
fig3.tight_layout()
price_path = out_dir / f"benchmark_price_trace_{stamp}.png"
fig3.savefig(price_path, dpi=220)
plt.close(fig3)
return rev_path, coi_path, price_path
def _run_with_args(args, compare_robust_override: bool | None = None):
compare_robust = (
bool(compare_robust_override)
if compare_robust_override is not None
else _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST"))
)
baseline_modes = [False, True] if compare_robust else [bool(args.no_robust)]
base_overrides = {
"seed": args.seed,
"total_timesteps": args.total_timesteps,
"n_products": args.n_products,
"N": args.N,
"lambda_coi": args.lambda_coi,
"robust_radius": args.robust_radius,
"robust_points": args.robust_points,
"robust_rollouts": args.robust_rollouts,
"margin_floor": args.margin_floor,
"eta_ux": args.eta_ux,
"reward_profit_weight": args.reward_profit_weight,
"price_low": args.price_low,
"price_high": args.price_high,
"action_levels": args.action_levels,
"action_scale_low": args.action_scale_low,
"action_scale_high": args.action_scale_high,
"max_steps": args.max_steps,
"learning_rate": args.learning_rate,
"batch_size": args.batch_size,
"n_steps": args.n_steps,
"linear_warmup_steps": args.linear_warmup_steps,
"device": args.device,
}
tiers = _parse_list(args.tiers)
alpha_values = _parse_float_list(args.alpha_values)
eval_alpha_values = (
_parse_float_list(args.eval_alpha_values)
if str(getattr(args, "eval_alpha_values", "")).strip()
else []
)
_log(
"starting run "
+ json.dumps(
{
"tiers": tiers,
"alpha_values": alpha_values,
"eval_alpha_values": (
eval_alpha_values if eval_alpha_values else alpha_values
),
"episodes": int(args.episodes),
"total_timesteps": int(args.total_timesteps),
"device": str(args.device),
}
)
)
all_frames: list[pd.DataFrame] = []
all_traces: list[dict] = []
wandb_step_cursor = 0
for baseline_mode in baseline_modes:
overrides = dict(base_overrides)
overrides["baseline_mode"] = bool(baseline_mode)
cfg = TrainSpec.from_flat(
{k: v for k, v in overrides.items() if v is not None}
).to_flat_dict()
cfg["linear_warmup_steps"] = int(args.linear_warmup_steps)
mode_label = _mode_label_from_baseline(bool(baseline_mode))
_log(f"mode={mode_label}: begin")
df_mode, traces_mode, wandb_step_cursor = run_benchmark(
cfg,
tiers,
alpha_values,
args.episodes,
mode_label=mode_label,
step_cursor_start=wandb_step_cursor,
eval_alpha_values=eval_alpha_values,
)
_log(f"mode={mode_label}: complete ({len(df_mode)} rows)")
for trace in traces_mode:
trace["mode"] = mode_label
all_frames.append(df_mode)
all_traces.extend(traces_mode)
df = pd.concat(all_frames, ignore_index=True) if all_frames else pd.DataFrame()
traces = all_traces
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
csv_path = out_dir / f"benchmark_{stamp}.csv"
trace_path = out_dir / f"benchmark_traces_{stamp}.json"
df.to_csv(csv_path, index=False)
trace_path.write_text(json.dumps(traces, indent=2))
rev_path, coi_path, price_path = _plot_outputs(df, traces, out_dir, stamp)
_log(f"artifacts written in {out_dir}")
if not df.empty:
best_idx = int(df["objective_score"].idxmax())
best = df.iloc[best_idx]
_log(
"BEST_TIER="
+ json.dumps(
{
"tier": best["tier"],
"mode": best.get("mode", "defended"),
"alpha": float(best["alpha"]),
"objective_score": float(best["objective_score"]),
"mean_revenue": float(best["mean_revenue"]),
"mean_coi": float(best["mean_coi"]),
}
)
)
_log(f"BENCHMARK_CSV={csv_path}")
_log(f"BENCHMARK_TRACES={trace_path}")
_log(f"BENCHMARK_PLOT_REVENUE={rev_path}")
_log(f"BENCHMARK_PLOT_COI={coi_path}")
_log(f"BENCHMARK_PLOT_PRICE={price_path}")
def run_cli(raw_args: list[str] | None = None):
configure_logging()
parser = argparse.ArgumentParser(description="PHANTOM benchmark orchestrator")
parser.add_argument("--project", default="capstone")
parser.add_argument("--tiers", default="static,surge,linear,qtable,ppo")
parser.add_argument("--alpha-values", default="0.0,0.3,0.6")
parser.add_argument("--eval-alpha-values", default="")
parser.add_argument("--episodes", type=int, default=10)
parser.add_argument("--output-dir", default="engine/studies/results")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--total-timesteps", type=int, default=25_000)
parser.add_argument("--n-products", type=int, default=10)
parser.add_argument("--N", type=int, default=100)
parser.add_argument("--lambda-coi", type=float, default=0.2)
parser.add_argument("--robust-radius", type=float, default=0.15)
parser.add_argument("--robust-points", type=int, default=5)
parser.add_argument("--robust-rollouts", type=int, default=1)
parser.add_argument("--margin-floor", type=float, default=0.85)
parser.add_argument("--eta-ux", type=float, default=0.5)
parser.add_argument("--reward-profit-weight", type=float, default=1.0)
parser.add_argument("--price-low", type=float, default=10.0)
parser.add_argument("--price-high", type=float, default=150.0)
parser.add_argument("--action-levels", type=int, default=9)
parser.add_argument("--action-scale-low", type=float, default=0.8)
parser.add_argument("--action-scale-high", type=float, default=1.2)
parser.add_argument("--max-steps", type=int, default=100)
parser.add_argument("--learning-rate", type=float, default=3e-4)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--n-steps", type=int, default=2048)
parser.add_argument("--linear-warmup-steps", type=int, default=800)
parser.add_argument("--device", type=str, default="auto")
parser.add_argument("--no-robust", action="store_true")
parser.add_argument("--no-wandb", action="store_true")
parser.add_argument("--offline", action="store_true")
parser.add_argument("--sweep-agent", action="store_true")
parser.add_argument("--sweep-id", type=str)
parser.add_argument("--count", type=int, default=0)
args = parser.parse_args(raw_args)
if args.sweep_agent:
if args.no_wandb or not HAS_WANDB:
raise ValueError("sweep agent requires wandb")
if not args.sweep_id:
raise ValueError("--sweep-id is required with --sweep-agent")
def _sweep_run():
run = wandb.init(mode="offline" if args.offline else "online")
try:
key_to_attr = {
"tiers": "tiers",
"alpha_values": "alpha_values",
"eval_alpha_values": "eval_alpha_values",
"episodes": "episodes",
"total_timesteps": "total_timesteps",
"lambda_coi": "lambda_coi",
"robust_radius": "robust_radius",
"robust_points": "robust_points",
"robust_rollouts": "robust_rollouts",
"ambiguity_radius": "robust_radius",
"ambiguity_points": "robust_points",
"ambiguity_rollouts": "robust_rollouts",
"eta_ux": "eta_ux",
"reward_profit_weight": "reward_profit_weight",
"learning_rate": "learning_rate",
"batch_size": "batch_size",
"n_steps": "n_steps",
"baseline_mode": "no_robust",
"no_robust": "no_robust",
"margin_floor": "margin_floor",
"device": "device",
}
for key in (
"tiers",
"alpha_values",
"eval_alpha_values",
"episodes",
"total_timesteps",
"lambda_coi",
"robust_radius",
"robust_points",
"robust_rollouts",
"ambiguity_radius",
"ambiguity_points",
"ambiguity_rollouts",
"eta_ux",
"reward_profit_weight",
"learning_rate",
"batch_size",
"n_steps",
"baseline_mode",
"no_robust",
"margin_floor",
"device",
):
if key in wandb.config:
setattr(args, key_to_attr[key], wandb.config[key])
_run_with_args(args)
finally:
if run is not None:
wandb.finish()
wandb.agent(
args.sweep_id,
function=_sweep_run,
count=args.count if args.count > 0 else None,
)
return
if args.no_wandb or not HAS_WANDB:
_run_with_args(args)
return
tiers = _parse_list(args.tiers)
alpha_values = _parse_float_list(args.alpha_values)
run_stamp = datetime.now(timezone.utc).strftime("%m%d-%H%M%S")
compare_enabled = _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST"))
compare_tag = "defended-compare" if compare_enabled else "single-mode"
modes = (
[("baseline", True), ("defended", False)]
if compare_enabled
else [(_mode_label_from_baseline(bool(args.no_robust)), bool(args.no_robust))]
)
run_idx = 0
for tier in tiers:
for mode_label, baseline_mode in modes:
for alpha in alpha_values:
run_idx += 1
alpha_token = (
f"{float(alpha):.2f}".rstrip("0").rstrip(".").replace(".", "p")
)
tier_args = argparse.Namespace(**vars(args))
tier_args.tiers = tier
tier_args.alpha_values = str(float(alpha))
tier_args.no_robust = bool(baseline_mode)
run = wandb.init(
project=args.project,
name=(
f"benchmark-{tier}-{mode_label}-a{alpha_token}-{run_stamp}-{run_idx}"
),
tags=[
"benchmark",
compare_tag,
f"backend:{tier}",
f"mode:{mode_label}",
f"alpha:{alpha_token}",
],
config={
"run.kind": "benchmark",
"runtime/backend": tier,
"study/mode": mode_label,
"study/baseline_mode": float(baseline_mode),
"study/alpha": float(alpha),
"tiers": tier,
"alpha_values": str(float(alpha)),
"eval_alpha_values": args.eval_alpha_values,
"episodes": args.episodes,
"total_timesteps": args.total_timesteps,
"lambda_coi": args.lambda_coi,
"ambiguity_radius": args.robust_radius,
"ambiguity_points": args.robust_points,
"ambiguity_rollouts": args.robust_rollouts,
"margin_floor": args.margin_floor,
"baseline_mode": float(baseline_mode),
"eta_ux": args.eta_ux,
"reward_profit_weight": args.reward_profit_weight,
"learning_rate": args.learning_rate,
"device": args.device,
},
mode="offline" if args.offline else "online",
)
try:
_run_with_args(tier_args, compare_robust_override=False)
finally:
if run is not None:
wandb.finish()
if __name__ == "__main__":
run_cli()

124
engine/engine.py Normal file
View File

@@ -0,0 +1,124 @@
from sys import platform
import numpy as np
from .lib.demand import generate_demand_for_actor, estimate_demand
from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions
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,
)
human_transitions = get_adjusted_transitions(demand_h, human=True)
agent_transitions = get_adjusted_transitions(demand_a, human=False)
# sample N trajectories in parallel; each chain is independent so threads
# do not share state and numpy's per-call RNG is thread-safe
human_t = [
sample_behavior_from_transitions(human_transitions)
for _ in range(self.Nhumans)
]
agent_t = [
sample_behavior_from_transitions(agent_transitions)
for _ in range(self.Nagents)
]
# store trajectories for agent probability calculation
self.last_trajectories = human_t + agent_t
demand_proxy = estimate_demand(
self.last_trajectories,
self.action_weights,
normalize=True,
per_session=False,
)
raw_mix = ((1.0 - float(self.alpha)) * demand_h) + (
float(self.alpha) * demand_a
)
total_raw_demand = float(np.sum(raw_mix))
if not demand_proxy:
return {i: float(raw_mix[i]) for i in range(len(prices))}
if total_raw_demand <= 0.0:
return {i: 0.0 for i in range(len(prices))}
return {
i: total_raw_demand * float(demand_proxy.get(i, 0.0)) / 100.0
for i in range(len(prices))
}
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()

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

@@ -0,0 +1,3 @@
from .robust import select_adversarial_alpha_jax, _JAX_OK
__all__ = ["select_adversarial_alpha_jax", "_JAX_OK"]

197
engine/jax/robust.py Normal file
View File

@@ -0,0 +1,197 @@
"""JAX-accelerated robust inner loop for PHANTOM.
provides a drop-in replacement for the sequential alpha-candidate evaluation in
wrapper.py::_select_adversarial_alpha. the demand generation and reward
computation are vmapped over the K candidate alpha values so all candidates are
evaluated in a single vectorized pass instead of K sequential Python calls.
public surface:
select_adversarial_alpha_jax(candidates, prices, human_params, agent_params,
noise_std, n_sessions, n_products,
baseline_prices, lambda_coi, info_value,
reward_profit_weight, rng_key)
-> (best_alpha: float, rewards: np.ndarray)
falls back gracefully when JAX is unavailable.
"""
from __future__ import annotations
import numpy as np
try:
import jax
import jax.numpy as jnp
from jax import vmap, jit
_JAX_OK = True
except ImportError:
_JAX_OK = False
_JAX_RUNTIME_OK = True
def _demand_for_actor_jax(prices, mean, std, noise_std, key):
"""d(p;theta) = max(0, val - price + noise), normalized to sum 100."""
k1, k2 = jax.random.split(key)
val = jax.random.normal(k1, shape=prices.shape) * std + mean
noise = jax.random.normal(k2, shape=prices.shape) * noise_std
demand = jnp.maximum(0.0, val - prices + noise)
total = demand.sum()
return jnp.where(total > 0, demand / total * 100.0, demand)
def _reward_for_candidate(
alpha,
prices,
human_mean,
human_std,
agent_mean,
agent_std,
noise_std,
baseline_prices,
lambda_coi,
info_value,
reward_profit_weight,
key,
):
"""compute a scalar reward for a single alpha candidate (pure JAX, vmappable)."""
k_h, k_a = jax.random.split(key)
# mixed demand proxy: weighted sum of human and agent demand signals
demand_h = _demand_for_actor_jax(prices, human_mean, human_std, noise_std, k_h)
demand_a = _demand_for_actor_jax(prices, agent_mean, agent_std, noise_std, k_a)
demand = (1.0 - alpha) * demand_h + alpha * demand_a
revenue = jnp.dot(prices, demand)
floor_cost = jnp.dot(baseline_prices, demand)
profit = revenue - floor_cost
# agent_prob proxy: use alpha directly (no trajectory available in vectorized path)
coi_leakage = alpha * info_value
info_budget = jnp.maximum(floor_cost, 1.0)
coi_penalty = lambda_coi * coi_leakage * info_budget
return reward_profit_weight * profit - coi_penalty
if _JAX_OK:
# compile once; retracing only happens on shape/dtype changes
# 12 args: alpha, prices, h_mean, h_std, a_mean, a_std, noise_std,
# baseline_prices, lambda_coi, info_value, reward_profit_weight, key
_reward_batched = jit(
vmap(
_reward_for_candidate,
in_axes=(0, None, None, None, None, None, None, None, None, None, None, 0),
)
)
def select_adversarial_alpha_jax(
candidates: np.ndarray,
prices: np.ndarray,
human_params: tuple,
agent_params: tuple,
noise_std: float,
baseline_prices: np.ndarray,
lambda_coi: float,
info_value: float,
reward_profit_weight: float,
rng_seed: int = 0,
) -> tuple[float, np.ndarray]:
"""evaluate all alpha candidates in a single vmapped pass.
returns (best_alpha, rewards_array) where best_alpha minimizes reward
(worst case for the platform, driving robust policy training).
falls back to a pure-numpy sequential loop when JAX is unavailable so the
wrapper can call this function unconditionally.
"""
global _JAX_RUNTIME_OK
if not _JAX_OK or not _JAX_RUNTIME_OK:
return _fallback(
candidates,
prices,
human_params,
agent_params,
noise_std,
baseline_prices,
lambda_coi,
info_value,
reward_profit_weight,
)
try:
k = len(candidates)
key = jax.random.PRNGKey(rng_seed)
keys = jax.random.split(key, k)
rewards = np.asarray(
_reward_batched(
jnp.asarray(candidates, dtype=jnp.float32),
jnp.asarray(prices, dtype=jnp.float32),
float(human_params[0]),
float(human_params[1]),
float(agent_params[0]),
float(agent_params[1]),
float(noise_std),
jnp.asarray(baseline_prices, dtype=jnp.float32),
float(lambda_coi),
float(info_value),
float(reward_profit_weight),
keys,
)
)
best_idx = int(np.argmin(rewards))
return float(candidates[best_idx]), rewards
except Exception as exc:
# TPU contention / backend init failures can happen in distributed schedulers.
# Degrade to numpy path for the remainder of the process.
_JAX_RUNTIME_OK = False
print(f"PHANTOM_JAX_FALLBACK: {exc}")
return _fallback(
candidates,
prices,
human_params,
agent_params,
noise_std,
baseline_prices,
lambda_coi,
info_value,
reward_profit_weight,
)
def _fallback(
candidates,
prices,
human_params,
agent_params,
noise_std,
baseline_prices,
lambda_coi,
info_value,
reward_profit_weight,
):
"""numpy fallback matching the reward formula above."""
rewards = []
for alpha in candidates:
rng = np.random.default_rng()
val_h = rng.normal(*human_params, size=len(prices))
val_a = rng.normal(*agent_params, size=len(prices))
noise_h = rng.normal(0, noise_std, len(prices))
noise_a = rng.normal(0, noise_std, len(prices))
d_h = np.maximum(0, val_h - prices + noise_h)
d_a = np.maximum(0, val_a - prices + noise_a)
s_h, s_a = d_h.sum(), d_a.sum()
d_h = d_h / s_h * 100 if s_h > 0 else d_h
d_a = d_a / s_a * 100 if s_a > 0 else d_a
demand = (1.0 - alpha) * d_h + alpha * d_a
revenue = float(np.dot(prices, demand))
floor_cost = float(np.dot(baseline_prices, demand))
profit = revenue - floor_cost
coi_penalty = lambda_coi * alpha * info_value * max(floor_cost, 1.0)
rewards.append(reward_profit_weight * profit - coi_penalty)
rewards = np.array(rewards)
best_idx = int(np.argmin(rewards))
return float(candidates[best_idx]), rewards

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

@@ -0,0 +1,38 @@
from __future__ import annotations
from importlib import import_module
_EXPORTS: dict[str, tuple[str, str]] = {
"estimate_demand": (".demand", "estimate_demand"),
"estimate_weighted_demand": (".demand", "estimate_weighted_demand"),
"generate_demand_for_actor": (".demand", "generate_demand_for_actor"),
"sample_behavior": (".behavior", "sample_behavior"),
"get_transition_models": (".behavior", "get_transition_models"),
"trajectory_to_events": (".behavior", "trajectory_to_events"),
"DashboardRenderer": (".render", "DashboardRenderer"),
"style_axis": (".render", "style_axis"),
"EconomicMetricsWrapper": (".wrappers", "EconomicMetricsWrapper"),
"MetricsCallback": (".callbacks", "MetricsCallback"),
"EvalMetricsCallback": (".callbacks", "EvalMetricsCallback"),
"ProviderBenchmark": (".providers", "ProviderBenchmark"),
"ProviderResult": (".providers", "ProviderResult"),
"BenchmarkConfig": (".providers", "BenchmarkConfig"),
"RandomBaseline": (".providers", "RandomBaseline"),
"SurgeBaseline": (".providers", "SurgeBaseline"),
"compute_uplift_coi": (".coi", "compute_uplift_coi"),
"extract_purchases": (".coi", "extract_purchases"),
"compute_agent_probability": (".coi", "compute_agent_probability"),
"EventQTable": (".discrete", "EventQTable"),
}
__all__ = sorted(_EXPORTS)
def __getattr__(name: str):
if name not in _EXPORTS:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
module_name, attr_name = _EXPORTS[name]
module = import_module(module_name, package=__name__)
value = getattr(module, attr_name)
globals()[name] = value
return value

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

@@ -0,0 +1,190 @@
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
# cache keyed by (human: bool, condition_tuple) so we skip Kronecker re-expansion
# for repeated calls with the same demand condition inside the robustness inner loop
_transition_cache: dict = {}
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
"""
return [s.rsplit("_product", 1)[0] if "_product" in s else s for s in trajectory]
class _TransitionTable:
"""numpy-backed transition table; replaces per-step pandas .loc[] indexing.
the profiling hotspot was DataFrame.xs called ~4-16k times per outer step.
converting once to a dense float32 array with an int-keyed state index map
reduces each row lookup to a single array slice with no pandas overhead.
rows are pre-normalized so sampling requires no per-step division.
"""
__slots__ = ("matrix", "states", "state_index", "n_states")
def __init__(self, df: pd.DataFrame):
self.states: list[str] = df.index.tolist()
self.state_index: dict[str, int] = {s: i for i, s in enumerate(self.states)}
# float64 throughout: float32 row-sums can drift enough to break np.random.choice
mat = np.nan_to_num(
df.values.astype(np.float64), nan=0.0, posinf=0.0, neginf=0.0
)
mat = np.clip(mat, 0.0, None)
row_sums = mat.sum(axis=1)
# dead rows (all zero) get uniform distribution so sampling never receives NaN
dead = row_sums <= 0
mat[dead] = 1.0
row_sums[dead] = float(mat.shape[1])
mat = mat / row_sums[:, np.newaxis]
# final nan guard in case fp still drifts
np.nan_to_num(mat, nan=0.0, copy=False)
row_sums2 = mat.sum(axis=1, keepdims=True)
row_sums2[row_sums2 <= 0] = 1.0
self.matrix: np.ndarray = mat / row_sums2
self.n_states: int = len(self.states)
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))
cond_norm = (
condition / s
if np.isfinite(s) and s > 0
else np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
)
n_products = len(condition)
base_vals = transition_matrix.values
base_cols, base_rows = (
transition_matrix.columns.tolist(),
transition_matrix.index.tolist(),
)
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 get_adjusted_transitions(condition, human=True) -> _TransitionTable:
"""return a _TransitionTable for the given demand condition.
results are cached by (human, rounded-condition) so that repeated calls with
the same condition inside the robustness inner loop (K candidates, same prices)
skip the Kronecker expansion entirely.
"""
condition = np.asarray(condition, dtype=float)
# round to 4 significant digits for cache key stability
cache_key = (human, tuple(np.round(condition, 4).tolist()))
if cache_key in _transition_cache:
return _transition_cache[cache_key]
# prevent OOM by capping cache size
if len(_transition_cache) > 100:
_transition_cache.clear()
base_pivot = _get_base_pivot(human)
df = adjust_behavior_to_condition(condition, base_pivot)
table = _TransitionTable(df)
_transition_cache[cache_key] = table
return table
def clear_transition_cache():
"""drop cached transition tables; call between episodes if condition space is large."""
_transition_cache.clear()
def sample_behavior_from_transitions(table, max_len=40):
"""sample a Markov trajectory.
accepts _TransitionTable (fast path) or a legacy pandas DataFrame so existing
call sites that pass a DataFrame directly continue to work unchanged.
"""
if isinstance(table, pd.DataFrame):
table = _TransitionTable(table)
idx = np.random.randint(table.n_states)
trajectory = [table.states[idx]]
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
row = table.matrix[table.state_index[trajectory[-1]]]
idx = int(np.random.choice(table.n_states, p=row))
trajectory.append(table.states[idx])
return trajectory
def sample_behavior(condition, human=True, max_len=40):
table = get_adjusted_transitions(condition, human=human)
return sample_behavior_from_transitions(table, max_len=max_len)
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)

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

@@ -0,0 +1,259 @@
"""Training callbacks with algorithm-agnostic metric extraction."""
from typing import Any
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
import numpy as np
from ..telemetry.wandb import get_wandb_module
class MetricsCallback(BaseCallback):
"""Collects interval train metrics from env info dictionaries."""
def __init__(
self,
log_histograms: bool = False,
log_freq: int = 100,
hist_freq: int = 500,
step_offset: int = 0,
verbose: int = 0,
):
super().__init__(verbose)
self.log_histograms = log_histograms
self.log_freq = max(1, int(log_freq))
self.hist_freq = max(1, int(hist_freq))
self.step_offset = max(0, int(step_offset))
self._wandb = get_wandb_module()
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
self._price_samples: list[float] = []
self._demand_samples: list[float] = []
self._window_sums = {
"train/revenue_mean": 0.0,
"train/margin_mean": 0.0,
"train/coi_level_mean": 0.0,
"train/regret_mean": 0.0,
"train/profit_mean": 0.0,
"train/agent_prob": 0.0,
"train/alpha_adv": 0.0,
"train/ux_penalty": 0.0,
"train/volatility": 0.0,
"train/coi_mix": 0.0,
"train/coi_base": 0.0,
"train/coi_leakage": 0.0,
"train/coi_penalty": 0.0,
}
self._window_count = 0
self.events: list[dict[str, Any]] = []
def _accumulate(self, info: dict[str, Any]) -> None:
econ = info.get("economics")
if not isinstance(econ, dict):
return
self._window_sums["train/revenue_mean"] += float(econ.get("revenue", 0.0))
self._window_sums["train/margin_mean"] += float(econ.get("margin", 0.0))
self._window_sums["train/coi_level_mean"] += float(econ.get("coi_level", 0.0))
self._window_sums["train/regret_mean"] += float(econ.get("regret", 0.0))
if "profit" in econ:
self._window_sums["train/profit_mean"] += float(econ.get("profit", 0.0))
if "agent_prob" in econ:
self._window_sums["train/agent_prob"] += float(econ.get("agent_prob", 0.0))
if "alpha_adv" in econ:
self._window_sums["train/alpha_adv"] += float(econ.get("alpha_adv", 0.0))
if "ux_penalty" in econ:
self._window_sums["train/ux_penalty"] += float(econ.get("ux_penalty", 0.0))
if "volatility" in econ:
self._window_sums["train/volatility"] += float(econ.get("volatility", 0.0))
if "coi_mix" in econ:
self._window_sums["train/coi_mix"] += float(econ.get("coi_mix", 0.0))
if "coi_base" in econ:
self._window_sums["train/coi_base"] += float(econ.get("coi_base", 0.0))
if "coi_leakage" in econ:
self._window_sums["train/coi_leakage"] += float(
econ.get("coi_leakage", 0.0)
)
if "coi_penalty" in econ:
self._window_sums["train/coi_penalty"] += float(
econ.get("coi_penalty", 0.0)
)
self._window_count += 1
def _accumulate_histograms(self, info: dict[str, Any]) -> None:
if not self.log_histograms:
return
for key in ("effective_prices", "prices"):
if key not in info:
continue
try:
values = np.asarray(info.get(key), dtype=float).reshape(-1)
except Exception:
continue
if values.size <= 0:
continue
finite_values = values[np.isfinite(values)]
if finite_values.size > 0:
self._price_samples.extend(finite_values.tolist())
break
if "demand" in info:
try:
demand_values = np.asarray(info.get("demand"), dtype=float).reshape(-1)
except Exception:
demand_values = np.array([], dtype=float)
if demand_values.size > 0:
finite_demand = demand_values[np.isfinite(demand_values)]
if finite_demand.size > 0:
self._demand_samples.extend(finite_demand.tolist())
def _flush_histograms(self, step: int, force: bool = False) -> None:
if not self.log_histograms:
return
if not force and step % self.hist_freq != 0:
return
if not self._price_samples and not self._demand_samples:
return
if self._wandb is None:
self._price_samples.clear()
self._demand_samples.clear()
return
payload: dict[str, Any] = {}
if self._price_samples:
payload["train/price_dist"] = self._wandb.Histogram(
np.asarray(self._price_samples, dtype=np.float32)
)
if self._demand_samples:
payload["train/demand_dist"] = self._wandb.Histogram(
np.asarray(self._demand_samples, dtype=np.float32)
)
if payload and self._wandb_live:
try:
self._wandb.log(payload, step=self.step_offset + int(step))
except Exception:
self._wandb_live = False
self._price_samples.clear()
self._demand_samples.clear()
def _flush(self, step: int, *, force_hist: bool = False) -> None:
if self._window_count > 0:
denom = float(self._window_count)
payload = {
key: (value / denom)
for key, value in self._window_sums.items()
if value != 0.0
or key
in {
"train/revenue_mean",
"train/margin_mean",
"train/coi_level_mean",
"train/regret_mean",
}
}
payload["train/global_step"] = int(step)
if self._wandb_live:
try:
self._wandb.log(dict(payload), step=self.step_offset + int(step))
except Exception:
self._wandb_live = False
self.events.append(payload)
else:
self.events.append(payload)
for key in self._window_sums:
self._window_sums[key] = 0.0
self._window_count = 0
self._flush_histograms(step=step, force=force_hist)
def _on_step(self) -> bool:
for info in self.locals.get("infos", []):
if isinstance(info, dict):
self._accumulate(info)
self._accumulate_histograms(info)
if self.num_timesteps % self.log_freq == 0:
self._flush(step=self.num_timesteps)
return True
def _on_training_end(self) -> None:
self._flush(step=self.num_timesteps, force_hist=True)
class EvalMetricsCallback(EvalCallback):
"""Deterministic evaluation collector detached from logging backends."""
def __init__(
self,
eval_env,
eval_freq: int = 1000,
n_eval_episodes: int = 5,
step_offset: int = 0,
**kwargs,
):
super().__init__(
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
)
self.step_offset = max(0, int(step_offset))
self._wandb = get_wandb_module()
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
self._eval_stats: dict[str, list[float]] = {
"eval/revenue_mean": [],
"eval/margin_mean": [],
"eval/coi_level_mean": [],
"eval/coi_leakage_mean": [],
"eval/volatility_mean": [],
"eval/agent_prob_mean": [],
}
self.events: list[dict[str, float | int]] = []
def _on_step(self) -> bool:
result = super()._on_step()
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
payload: dict[str, float | int] = {
"eval/reward_mean": float(self.last_mean_reward),
"train/global_step": int(self.num_timesteps),
}
for key, values in self._eval_stats.items():
payload[key] = float(np.mean(values)) if values else 0.0
if self._wandb_live:
try:
self._wandb.log(
dict(payload),
step=self.step_offset + int(self.num_timesteps),
)
except Exception:
self._wandb_live = False
self.events.append(payload)
else:
self.events.append(payload)
for values in self._eval_stats.values():
values.clear()
return result
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
# called after each eval episode
info = locals_.get("info", {})
econ = info.get("economics") if isinstance(info, dict) else None
if not isinstance(econ, dict):
return
self._eval_stats["eval/revenue_mean"].append(float(econ.get("revenue", 0.0)))
self._eval_stats["eval/margin_mean"].append(float(econ.get("margin", 0.0)))
self._eval_stats["eval/coi_level_mean"].append(
float(econ.get("coi_level", 0.0))
)
self._eval_stats["eval/coi_leakage_mean"].append(
float(econ.get("coi_leakage", 0.0))
)
self._eval_stats["eval/volatility_mean"].append(
float(econ.get("volatility", 0.0))
)
self._eval_stats["eval/agent_prob_mean"].append(
float(econ.get("agent_prob", 0.0))
)

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

@@ -0,0 +1,79 @@
import numpy as np
from typing import Dict
def compute_agent_probability(
trajectory: list,
human_transitions: Dict,
agent_transitions: Dict,
temperature: float = 1.0,
) -> 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)
t = float(max(temperature, 1e-6))
exp_h = np.exp(-kl_human / t)
exp_a = np.exp(-kl_agent / t)
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())
)

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

@@ -0,0 +1,120 @@
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,
normalize: bool = False,
) -> 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)
if not normalize:
return demand
total = np.sum(demand)
return demand / total * 100 if total > 0 else demand
def estimate_demand(
trajectories,
action_weights=None,
*,
normalize: bool = False,
per_session: bool = True,
):
return estimate_weighted_demand(
trajectories,
action_weights,
normalize=normalize,
per_session=per_session,
)
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,
*,
normalize: bool = False,
per_session: bool = True,
):
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
if not scores:
return {}
if per_session and len(trajectories) > 0:
inv_n = 1.0 / float(len(trajectories))
scores = {pid: score * inv_n for pid, score in scores.items()}
if not normalize:
return scores
total = float(sum(scores.values()))
if total <= 0:
return {}
return {pid: (score / total) * 100.0 for pid, score in scores.items()}
# 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

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

@@ -0,0 +1,185 @@
"""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:
try:
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,
}
)
except Exception:
pass
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",
)

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

@@ -0,0 +1,165 @@
"""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

101
engine/lib/tiers.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
class PolicyLike(Protocol):
def predict(self, obs: np.ndarray, deterministic: bool = True): ...
class StaticPolicy:
def __init__(self, n_actions: int):
self._action = int(max(0, n_actions // 2))
def predict(self, obs: np.ndarray, deterministic: bool = True):
return self._action, None
class SurgePolicy:
def __init__(
self,
n_actions: int,
n_products: int,
high_threshold: float = 60.0,
low_threshold: float = 30.0,
):
self.n_actions = int(n_actions)
self.n_products = int(n_products)
self.mid = self.n_actions // 2
self.high_t = float(high_threshold)
self.low_t = float(low_threshold)
def predict(self, obs: np.ndarray, deterministic: bool = True):
obs_arr = np.asarray(obs, dtype=np.float32)
demand = obs_arr[: self.n_products]
demand_mean = float(np.mean(demand)) if demand.size > 0 else 0.0
if demand_mean >= self.high_t:
return min(self.mid + 2, self.n_actions - 1), None
if demand_mean <= self.low_t:
return max(self.mid - 2, 0), None
return self.mid, None
@dataclass
class LinearElasticityPolicy:
n_actions: int
n_products: int
price_low: float
price_high: float
def __post_init__(self):
self.n_actions = int(self.n_actions)
self.n_products = int(self.n_products)
self.price_low = float(self.price_low)
self.price_high = float(self.price_high)
self._target_price = 0.5 * (self.price_low + self.price_high)
self._action_scales = np.linspace(0.8, 1.2, self.n_actions)
def fit(self, env, warmup_steps: int = 800, seed: int = 42):
rng = np.random.default_rng(int(seed))
obs, _ = env.reset(seed=int(seed))
prices: list[float] = []
demands: list[float] = []
for _ in range(int(max(10, warmup_steps))):
action = int(rng.integers(0, self.n_actions))
obs, _, term, trunc, info = env.step(action)
done = bool(term or trunc)
p = np.asarray(info.get("prices", []), dtype=np.float32)
d = np.asarray(info.get("demand", []), dtype=np.float32)
if p.size > 0 and d.size > 0:
prices.append(float(np.mean(p)))
demands.append(float(np.mean(d)))
if done:
obs, _ = env.reset()
if len(prices) < 8:
self._target_price = 0.5 * (self.price_low + self.price_high)
return self
slope, intercept = np.polyfit(np.asarray(prices), np.asarray(demands), 1)
if slope < -1e-6:
p_star = -intercept / (2.0 * slope)
self._target_price = float(np.clip(p_star, self.price_low, self.price_high))
else:
self._target_price = 0.5 * (self.price_low + self.price_high)
return self
def predict(self, obs: np.ndarray, deterministic: bool = True):
obs_arr = np.asarray(obs, dtype=np.float32)
cur_prices = obs_arr[self.n_products : 2 * self.n_products]
cur_mean = (
float(np.mean(cur_prices)) if cur_prices.size > 0 else self._target_price
)
scale = self._target_price / max(cur_mean, 1e-6)
action = int(np.argmin(np.abs(self._action_scales - scale)))
return int(np.clip(action, 0, self.n_actions - 1)), None

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

@@ -0,0 +1,104 @@
"""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
quoted_prices = np.asarray(self.env.unwrapped._prices, dtype=float)
effective_prices = np.asarray(
info.get("effective_prices", quoted_prices), dtype=float
)
if effective_prices.shape != quoted_prices.shape:
effective_prices = quoted_prices
demand_dict = self.env.unwrapped._demand
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(quoted_prices))])
# core calculations
revenue = float(info.get("revenue", np.sum(effective_prices * demand)))
quoted_revenue = float(np.sum(quoted_prices * demand))
avg_price = float(np.mean(effective_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(effective_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,
"quoted_revenue": quoted_revenue,
"margin": margin,
"coi_level": coi_level,
"regret": regret,
}
for key in (
"coi_mix",
"coi_base",
"coi_leakage",
"coi_penalty",
"ux_penalty",
"volatility",
"upward_volatility",
"supra_penalty",
"supra_share",
"competitive_anchor",
"profit",
"cost_floor",
"reward_revenue",
"reward_total",
"agent_prob",
"alpha_adv",
"alpha_nominal",
"erosion_share",
"effective_price_mean",
):
if key in info:
info["economics"][key] = info[key]
info["prices"] = quoted_prices.copy()
info["effective_prices"] = effective_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]))

33
engine/logging_utils.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
import os
import sys
_CONFIGURED = False
def _resolve_level(raw: str | None) -> int:
name = str(raw or os.environ.get("PHANTOM_LOG_LEVEL", "INFO")).upper().strip()
return int(getattr(logging, name, logging.INFO))
def configure_logging(level: str | None = None) -> None:
global _CONFIGURED
if _CONFIGURED:
return
logger = logging.getLogger("engine")
logger.setLevel(_resolve_level(level))
logger.propagate = False
if logger.handlers:
_CONFIGURED = True
return
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
)
logger.addHandler(handler)
_CONFIGURED = True

View File

@@ -0,0 +1,5 @@
from .benchmark import run_benchmark_cli
from .sweep_agent import run_sweep_agent
from .train import run_train_once
__all__ = ["run_benchmark_cli", "run_sweep_agent", "run_train_once"]

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
def run_benchmark_cli(raw_args: list[str] | None = None) -> None:
from ..benchmark import run_cli
run_cli(raw_args)

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from typing import Any, Mapping, Sequence
from ..spec import TrainSpec, run_name
from ..telemetry.wandb import (
current_config,
finish_run,
get_wandb_module,
init_run,
run_agent,
update_summary,
)
from .train import run_with_active_sweep_run
def run_sweep_agent(
*,
project: str,
sweep_id: str,
count: int,
offline: bool,
no_wandb: bool,
base_overrides: Mapping[str, Any],
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> None:
if no_wandb:
raise ValueError("sweep agent requires wandb")
if not sweep_id:
raise ValueError("--sweep-id is required with --sweep-agent")
if get_wandb_module() is None:
raise ImportError("wandb is required for sweep runs")
mode = "offline" if offline else "online"
def _sweep_trial() -> None:
run = init_run(mode=mode, project=project, group=group, sweep_mode=True)
try:
merged = dict(base_overrides)
merged.update(current_config())
spec = TrainSpec.from_flat(merged)
if run is not None:
run.name = run_name(spec, kind=kind, scenario=scenario)
try:
run_with_active_sweep_run(
spec,
kind=kind,
scenario=scenario,
group=group,
extra_tags=extra_tags,
)
update_summary({"run/status": "finished"})
except Exception as exc:
update_summary(
{
"run/status": "crashed",
"run/error": str(exc),
}
)
raise
finally:
finish_run()
run_agent(
sweep_id,
_sweep_trial,
count=count if count > 0 else None,
)

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import json
from typing import Any, Sequence
from ..spec import TrainSpec, run_metadata, run_name
from ..telemetry.wandb import (
finish_run,
get_wandb_module,
init_run,
log_metrics,
update_run_config,
update_summary,
)
from ..train_core import run_train
def _tags_for_run(spec: TrainSpec, kind: str, extra_tags: Sequence[str]) -> list[str]:
tags = [
kind,
spec.algorithm.name,
spec.runtime.backend,
"baseline" if spec.study.no_robust else "defended",
]
tags.extend([tag for tag in extra_tags if tag])
return tags
def _print_local_metrics(metrics: dict[str, Any]) -> None:
print(json.dumps(metrics, indent=2))
print("PHANTOM_METRICS:" + json.dumps(metrics))
def _log_train_events(events: list[dict[str, Any]], log_freq: int) -> None:
if not events:
return
period = max(1, int(log_freq))
last_logged_step = -period
for event in sorted(
[evt for evt in events if isinstance(evt, dict)],
key=lambda evt: int(evt.get("train/global_step", 0)),
):
step = int(event.get("train/global_step", 0))
if step <= 0 or (step - last_logged_step) < period:
continue
log_metrics(event, step=step)
last_logged_step = step
def run_train_once(
spec: TrainSpec,
*,
project: str,
offline: bool,
no_wandb: bool,
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> dict[str, Any]:
wandb = get_wandb_module()
if no_wandb or wandb is None:
result = run_train(spec)
_print_local_metrics(result.metrics)
return result.metrics
mode = "offline" if offline else "online"
tags = _tags_for_run(spec, kind, extra_tags)
metadata = run_metadata(
spec,
kind=kind,
scenario=scenario,
group=group,
tags=tags,
)
config = spec.to_flat_dict()
config.update(metadata)
name = run_name(spec, kind=kind, scenario=scenario)
init_run(
mode=mode,
project=project,
config=config,
name=name,
tags=tags,
group=group,
sweep_mode=False,
)
try:
result = run_train(spec)
_log_train_events(result.events, spec.runtime.log_freq)
metrics = result.metrics
step = int(metrics.get("train/global_step", spec.runtime.total_timesteps))
log_metrics(metrics, step=step)
update_summary(metrics)
return metrics
finally:
finish_run()
def run_with_active_sweep_run(
spec: TrainSpec,
*,
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> dict[str, Any]:
tags = _tags_for_run(spec, kind, extra_tags)
metadata = run_metadata(
spec,
kind=kind,
scenario=scenario,
group=group,
tags=tags,
)
update_run_config({**spec.to_flat_dict(), **metadata})
result = run_train(spec)
_log_train_events(result.events, spec.runtime.log_freq)
metrics = result.metrics
step = int(metrics.get("train/global_step", spec.runtime.total_timesteps))
log_metrics(metrics, step=step)
update_summary(metrics)
return metrics

138
engine/project.json Normal file
View File

@@ -0,0 +1,138 @@
{
"$schema": "../node_modules/nx/schemas/project-schema.json",
"name": "research",
"projectType": "application",
"sourceRoot": "engine",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh install",
"cwd": "."
}
},
"test": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": ".venv/bin/pytest -v",
"cwd": "."
}
},
"train": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh train",
"cwd": "."
}
},
"benchmark": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark",
"cwd": "."
}
},
"benchmark-simple": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark-simple",
"cwd": "."
}
},
"benchmark-agent": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark-agent",
"cwd": "."
}
},
"train-agent": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh train-agent",
"cwd": "."
}
},
"train-bootstrap": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh train-bootstrap",
"cwd": "."
}
},
"stats": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh stats",
"cwd": "."
}
},
"docker-train-publish": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh docker-train-publish",
"cwd": "."
}
},
"whoclicked-publish": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh whoclicked-publish",
"cwd": "."
}
},
"tpu-ray-bootstrap": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh tpu-ray-bootstrap",
"cwd": "."
}
},
"tpu-ray-deps": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh tpu-ray-deps",
"cwd": "."
}
},
"tpu-ray-verify": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh tpu-ray-verify",
"cwd": "."
}
},
"tpu-ray-teardown": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh tpu-ray-teardown",
"cwd": "."
}
}
},
"tags": [
"scope:research",
"type:python"
]
}

353
engine/spec.py Normal file
View File

@@ -0,0 +1,353 @@
from __future__ import annotations
from dataclasses import dataclass, field
import os
from typing import Any, Mapping, Sequence
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 _normalize_keys(raw: Mapping[str, Any]) -> dict[str, Any]:
alias_map = {
"algorithm": "algo",
"algorithm.name": "algo",
"env.n_products": "n_products",
"env.action_levels": "action_levels",
"env.action_scale_low": "action_scale_low",
"env.action_scale_high": "action_scale_high",
"env.price_low": "price_low",
"env.price_high": "price_high",
"env.max_steps": "max_steps",
"env.margin_floor": "margin_floor",
"env.margin_floor_patience": "margin_floor_patience",
"env.n_sessions": "N",
"study.alpha": "alpha",
"study.lambda_coi": "lambda_coi",
"study.robust_radius": "robust_radius",
"study.robust_points": "robust_points",
"study.robust_rollouts": "robust_rollouts",
"study.ambiguity_radius": "robust_radius",
"study.ambiguity_points": "robust_points",
"study.ambiguity_rollouts": "robust_rollouts",
"study.info_value": "info_value",
"study.eta_ux": "eta_ux",
"study.reward_profit_weight": "reward_profit_weight",
"ambiguity_radius": "robust_radius",
"ambiguity_points": "robust_points",
"ambiguity_rollouts": "robust_rollouts",
"baseline_mode": "no_robust",
"stress_eval_enabled": "robust_eval_enabled",
"optimizer.learning_rate": "learning_rate",
"optimizer.gamma": "gamma",
"optimizer.batch_size": "batch_size",
"optimizer.n_steps": "n_steps",
"runtime.backend": "backend",
"runtime.device": "device",
"runtime.seed": "seed",
"runtime.total_timesteps": "total_timesteps",
"runtime.checkpoint_interval": "checkpoint_interval",
"runtime.hist_freq": "hist_freq",
"eval.eval_freq": "eval_freq",
"eval.eval_episodes": "eval_episodes",
}
normalized: dict[str, Any] = {}
for key, value in raw.items():
canonical = alias_map.get(str(key), str(key))
normalized[canonical] = value
return normalized
@dataclass(frozen=True)
class AlgorithmSpec:
name: str = "ppo"
@dataclass(frozen=True)
class EnvSpec:
n_products: int = 10
n_sessions: int = 100
price_low: float = 10.0
price_high: float = 150.0
action_levels: int = 9
action_scale_low: float = 0.8
action_scale_high: float = 1.2
max_steps: int = 100
margin_floor: float = 0.05
margin_floor_patience: int = 5
agent_mu: float = 45.0
agent_std: float = 15.0
@dataclass(frozen=True)
class StudySpec:
alpha: float = 0.3
lambda_coi: float = 0.2
robust_radius: float = 0.15
robust_points: int = 5
robust_rollouts: int = 1
info_value: float = 1.0
eta_ux: float = 0.5
reward_profit_weight: float = 1.0
no_robust: bool = False
@dataclass(frozen=True)
class OptimizerSpec:
learning_rate: float = 3e-4
gamma: float = 0.99
buffer_size: int = 50_000
batch_size: int = 256
tau: float = 0.005
train_freq: int = 1
learning_starts: int = 1_000
target_update_interval: int = 1_000
exploration_fraction: float = 0.2
exploration_final_eps: float = 0.05
n_steps: int = 2_048
n_epochs: int = 10
gae_lambda: float = 0.95
clip_range: float = 0.2
ent_coef: float = 0.0
q_lr: float = 0.1
q_bins: int = 6
eps_start: float = 1.0
eps_end: float = 0.05
eps_decay: float = 0.9995
arch: str = "small"
activation: str = "relu"
vf_coef: float = 0.5
max_grad_norm: float = 0.5
@dataclass(frozen=True)
class RuntimeSpec:
project: str = "capstone"
backend: str = "sb3"
device: str = "auto"
seed: int = 42
total_timesteps: int = 50_000
checkpoint_interval: int = 200_000
model_dir: str = "engine/models"
log_freq: int = 100
hist_freq: int = 500
@dataclass(frozen=True)
class EvalSpec:
eval_freq: int = 1_000
eval_episodes: int = 5
robust_eval_enabled: bool = True
@dataclass(frozen=True)
class TrainSpec:
algorithm: AlgorithmSpec = field(default_factory=AlgorithmSpec)
env: EnvSpec = field(default_factory=EnvSpec)
study: StudySpec = field(default_factory=StudySpec)
optimizer: OptimizerSpec = field(default_factory=OptimizerSpec)
runtime: RuntimeSpec = field(default_factory=RuntimeSpec)
eval: EvalSpec = field(default_factory=EvalSpec)
def to_flat_dict(self) -> dict[str, Any]:
return {
"project": self.runtime.project,
"algo": self.algorithm.name,
"seed": self.runtime.seed,
"total_timesteps": self.runtime.total_timesteps,
"eval_episodes": self.eval.eval_episodes,
"eval_freq": self.eval.eval_freq,
"log_freq": self.runtime.log_freq,
"model_dir": self.runtime.model_dir,
"backend": self.runtime.backend,
"device": self.runtime.device,
"checkpoint_interval": self.runtime.checkpoint_interval,
"hist_freq": self.runtime.hist_freq,
"n_products": self.env.n_products,
"N": self.env.n_sessions,
"price_low": self.env.price_low,
"price_high": self.env.price_high,
"action_levels": self.env.action_levels,
"action_scale_low": self.env.action_scale_low,
"action_scale_high": self.env.action_scale_high,
"max_steps": self.env.max_steps,
"margin_floor": self.env.margin_floor,
"margin_floor_patience": self.env.margin_floor_patience,
"agent_mu": self.env.agent_mu,
"agent_std": self.env.agent_std,
"alpha": self.study.alpha,
"lambda_coi": self.study.lambda_coi,
"robust_radius": self.study.robust_radius,
"robust_points": self.study.robust_points,
"robust_rollouts": self.study.robust_rollouts,
"info_value": self.study.info_value,
"eta_ux": self.study.eta_ux,
"reward_profit_weight": self.study.reward_profit_weight,
"no_robust": self.study.no_robust,
"learning_rate": self.optimizer.learning_rate,
"gamma": self.optimizer.gamma,
"buffer_size": self.optimizer.buffer_size,
"batch_size": self.optimizer.batch_size,
"tau": self.optimizer.tau,
"train_freq": self.optimizer.train_freq,
"learning_starts": self.optimizer.learning_starts,
"target_update_interval": self.optimizer.target_update_interval,
"exploration_fraction": self.optimizer.exploration_fraction,
"exploration_final_eps": self.optimizer.exploration_final_eps,
"n_steps": self.optimizer.n_steps,
"n_epochs": self.optimizer.n_epochs,
"gae_lambda": self.optimizer.gae_lambda,
"clip_range": self.optimizer.clip_range,
"ent_coef": self.optimizer.ent_coef,
"q_lr": self.optimizer.q_lr,
"q_bins": self.optimizer.q_bins,
"eps_start": self.optimizer.eps_start,
"eps_end": self.optimizer.eps_end,
"eps_decay": self.optimizer.eps_decay,
"arch": self.optimizer.arch,
"activation": self.optimizer.activation,
"vf_coef": self.optimizer.vf_coef,
"max_grad_norm": self.optimizer.max_grad_norm,
"robust_eval_enabled": self.eval.robust_eval_enabled,
}
@classmethod
def from_flat(
cls,
raw: Mapping[str, Any] | None = None,
*,
env_vars: Mapping[str, str] | None = None,
) -> "TrainSpec":
base = cls().to_flat_dict()
incoming = _normalize_keys(raw or {})
base.update({k: v for k, v in incoming.items() if v is not None})
runtime_env = os.environ if env_vars is None else env_vars
base["device"] = str(
base.get("device", runtime_env.get("PHANTOM_DEVICE", "auto"))
)
backend = str(base.get("backend", "sb3")).lower()
if backend == "auto":
backend = "sb3"
if backend != "sb3":
backend = "sb3"
no_robust = _truthy(base.get("no_robust"))
if no_robust:
base["lambda_coi"] = 0.0
base["robust_radius"] = 0.0
base["robust_points"] = 1
base["robust_rollouts"] = 1
return cls(
algorithm=AlgorithmSpec(name=str(base["algo"]).lower().strip()),
env=EnvSpec(
n_products=int(base["n_products"]),
n_sessions=int(base["N"]),
price_low=float(base["price_low"]),
price_high=float(base["price_high"]),
action_levels=int(base["action_levels"]),
action_scale_low=float(base["action_scale_low"]),
action_scale_high=float(base["action_scale_high"]),
max_steps=int(base["max_steps"]),
margin_floor=float(base["margin_floor"]),
margin_floor_patience=int(base["margin_floor_patience"]),
agent_mu=float(base.get("agent_mu", 45.0)),
agent_std=float(base.get("agent_std", 15.0)),
),
study=StudySpec(
alpha=float(base["alpha"]),
lambda_coi=float(base["lambda_coi"]),
robust_radius=float(base["robust_radius"]),
robust_points=int(base["robust_points"]),
robust_rollouts=int(base["robust_rollouts"]),
info_value=float(base["info_value"]),
eta_ux=float(base["eta_ux"]),
reward_profit_weight=float(base["reward_profit_weight"]),
no_robust=no_robust,
),
optimizer=OptimizerSpec(
learning_rate=float(base["learning_rate"]),
gamma=float(base["gamma"]),
buffer_size=int(base["buffer_size"]),
batch_size=int(base["batch_size"]),
tau=float(base["tau"]),
train_freq=int(base["train_freq"]),
learning_starts=int(base["learning_starts"]),
target_update_interval=int(base["target_update_interval"]),
exploration_fraction=float(base["exploration_fraction"]),
exploration_final_eps=float(base["exploration_final_eps"]),
n_steps=int(base["n_steps"]),
n_epochs=int(base["n_epochs"]),
gae_lambda=float(base["gae_lambda"]),
clip_range=float(base["clip_range"]),
ent_coef=float(base["ent_coef"]),
q_lr=float(base["q_lr"]),
q_bins=int(base["q_bins"]),
eps_start=float(base["eps_start"]),
eps_end=float(base["eps_end"]),
eps_decay=float(base["eps_decay"]),
arch=str(base["arch"]),
activation=str(base["activation"]),
vf_coef=float(base["vf_coef"]),
max_grad_norm=float(base["max_grad_norm"]),
),
runtime=RuntimeSpec(
project=str(base["project"]),
backend=backend,
device=str(base["device"]),
seed=int(base["seed"]),
total_timesteps=int(base["total_timesteps"]),
checkpoint_interval=int(base["checkpoint_interval"]),
model_dir=str(base["model_dir"]),
log_freq=int(base["log_freq"]),
hist_freq=int(base["hist_freq"]),
),
eval=EvalSpec(
eval_freq=int(base["eval_freq"]),
eval_episodes=int(base["eval_episodes"]),
robust_eval_enabled=_truthy(base.get("robust_eval_enabled", True)),
),
)
def run_name(spec: TrainSpec, *, kind: str, scenario: str) -> str:
alpha_token = f"{float(spec.study.alpha):.2f}".rstrip("0").rstrip(".")
mode = "baseline" if bool(spec.study.no_robust) else "defended"
return (
f"{kind}/{spec.algorithm.name}/{spec.runtime.backend}/"
f"{spec.runtime.device}/{scenario}/a{alpha_token}/{mode}/s{spec.runtime.seed}"
)
def run_metadata(
spec: TrainSpec,
*,
kind: str,
scenario: str,
group: str | None = None,
tags: Sequence[str] = (),
) -> dict[str, Any]:
mode = "baseline" if bool(spec.study.no_robust) else "defended"
metadata: dict[str, Any] = {
"run.kind": str(kind),
"run.algo": spec.algorithm.name,
"run.backend": spec.runtime.backend,
"run.device": spec.runtime.device,
"run.scenario": str(scenario),
"run.seed": spec.runtime.seed,
"run.tags": list(tags),
"study/alpha": float(spec.study.alpha),
"study/mode": mode,
"study/baseline_mode": float(bool(spec.study.no_robust)),
"tiers": spec.algorithm.name,
}
if group:
metadata["run.group"] = group
return metadata

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

@@ -0,0 +1,33 @@
"""shared factor definitions for experimental designs"""
import numpy as np
from dataclasses import dataclass
@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,104 @@
"""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)

View File

@@ -0,0 +1,136 @@
import sys
import numpy as np
import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
from gymnasium.wrappers import FlattenObservation
from stable_baselines3 import PPO
# Add parent directory to path to allow importing engine
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from engine.wrapper import PHANTOM
from engine.lib.wrappers import EconomicMetricsWrapper
from engine.lib.providers import (
ProviderBenchmark,
BenchmarkConfig,
RandomBaseline,
SurgeBaseline,
)
def env_factory(alpha: float):
"""Creates a wrapped PHANTOM environment for testing at a specific alpha level."""
# Action levels=9 matches the trained PPO model
# n_products=8 matches the pretrained model's expectation of Box(16,)
env = PHANTOM(
n_products=8,
alpha=alpha,
N=100,
action_levels=9,
action_scale_low=0.8,
action_scale_high=1.2,
max_steps=20, # Short episodes so simulation goes fast
robust_points=1, # disable expensive adversarial lookaheads
render_mode=None,
)
env = EconomicMetricsWrapper(env)
return FlattenObservation(env)
def main():
print("Loading pre-trained Robust RL model...")
model_path = Path(__file__).parent.parent / "models" / "phantom_ppo.zip"
if not model_path.exists():
print(f"Error: Model not found at {model_path}")
print("Please ensure you have a trained model before running this script.")
return
rl_model = PPO.load(model_path)
# The action space is Discrete(9). Index 4 is the middle (1.0 scale).
n_actions = 9
mid_action = n_actions // 2
providers = {
"Static (Base)": lambda obs: mid_action,
"Random": RandomBaseline(n_actions),
"Heuristic Surge": SurgeBaseline(
n_actions, high_threshold=60.0, low_threshold=30.0
),
"Robust RL (PPO)": lambda obs: rl_model.predict(obs, deterministic=True)[0],
}
config = BenchmarkConfig(
n_episodes=10, # Lower episodes to run faster
alpha_range=[0.0, 0.5, 1.0], # Fewer alpha levels
baseline_name="Static (Base)",
)
print(f"\nStarting benchmark across alpha levels: {config.alpha_range}")
print(
f"Testing {len(providers)} strategies for {config.n_episodes} episodes each...\n"
)
benchmark = ProviderBenchmark(env_factory, providers, config)
results = benchmark.run()
# 1. Print tabular results
df = benchmark.to_dataframe()
summary = benchmark.summary_table()
print("\n--- Benchmark Summary Table ---")
print(summary)
# 2. Save results to CSV for thesis inclusion
out_dir = Path(__file__).parent / "results"
out_dir.mkdir(exist_ok=True)
csv_path = out_dir / "provider_comparison.csv"
df.to_csv(csv_path, index=False)
print(f"\nSaved raw results to {csv_path}")
# 3. Plot the degradation of COI / Revenue as alpha increases
plt.figure(figsize=(12, 5))
# Plot 1: Revenue vs Alpha
plt.subplot(1, 2, 1)
for name in providers.keys():
provider_data = df[df["name"] == name]
plt.plot(
provider_data["alpha"],
provider_data["mean_revenue"],
marker="o",
label=name,
linewidth=2,
)
plt.title("Revenue under Agent Contamination")
plt.xlabel("Contamination Level (α)")
plt.ylabel("Mean Episode Revenue ($)")
plt.grid(True, linestyle="--", alpha=0.7)
plt.legend()
# Plot 2: COI Preservation vs Alpha
plt.subplot(1, 2, 2)
for name in providers.keys():
provider_data = df[df["name"] == name]
plt.plot(
provider_data["alpha"],
provider_data["coi_preserved_pct"],
marker="s",
label=name,
linewidth=2,
)
plt.title("Cost of Information (COI) Preservation")
plt.xlabel("Contamination Level (α)")
plt.ylabel("COI Preserved (%)")
plt.grid(True, linestyle="--", alpha=0.7)
plt.legend()
plt.tight_layout()
plot_path = out_dir / "alpha_degradation_plot.png"
plt.savefig(plot_path, dpi=300)
print(f"Saved visualization to {plot_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
"""validate core thesis problem: margin erosion under agent contamination
trains standard RL (no robust components) across α levels to demonstrate systematic failure
"""
from __future__ import annotations
import json, sys, time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from engine.spec import TrainSpec
from engine.orchestrators import run_train_once
def _run_baseline(alpha: float, algo: str, seed: int, steps: int) -> dict:
spec = TrainSpec.from_flat(
{
"algo": algo,
"seed": seed,
"alpha": alpha,
"total_timesteps": steps,
"lambda_coi": 0.0,
"robust_radius": 0.0,
"robust_points": 1,
"robust_rollouts": 1,
"no_robust": True,
"arch": "small",
"n_products": 10,
"N": 100,
"max_steps": 50,
"eval_freq": 5000,
"eval_episodes": 10,
"log_freq": 500,
"robust_eval_enabled": False,
"agent_mu": 12.0,
"agent_std": 2.0,
}
)
result = run_train_once(
spec,
project="phantom-margin-erosion",
offline=True,
no_wandb=True,
kind="study",
scenario=f"alpha{int(alpha * 100):02d}",
group=f"baseline_{algo}",
extra_tags=("margin_erosion", "baseline"),
)
return {
"alpha": alpha,
"algo": algo,
"seed": seed,
"eval_reward": result.get("eval/reward_mean", np.nan),
"eval_revenue": result.get("eval/revenue_mean", np.nan),
"eval_coi_level": result.get("eval/coi_level_mean", np.nan),
"eval_margin": result.get("eval/margin_mean", np.nan),
"eval_agent_prob": result.get("eval/agent_prob_mean", np.nan),
}
def run_margin_erosion_study(
alphas: list[float] | None = None,
algos: list[str] | None = None,
seeds: int = 3,
steps: int = 30_000,
) -> dict:
alphas = alphas or [0.1, 0.3, 0.5, 0.7, 0.9]
algos = algos or ["ppo", "dqn", "qtable"]
output_dir = Path(__file__).parent / "results"
output_dir.mkdir(exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
results = []
for α in alphas:
for algo in algos:
for si in range(seeds):
seed = 42 + si
print(f"α={α:.1f} {algo} seed={seed}")
m = _run_baseline(α, algo, seed, steps)
results.append(m)
print(
f" margin={m['eval_margin']:.3f} rev={m['eval_revenue']:.0f} coi={m['eval_coi_level']:.1f}"
)
summary = {}
for α in alphas:
runs = [r for r in results if abs(r["alpha"] - α) < 0.01]
if not runs:
continue
s = {}
for metric in ["margin", "revenue", "coi_level", "agent_prob"]:
vals = [r[f"eval_{metric}"] for r in runs]
s[f"{metric}_mean"] = float(np.mean(vals))
s[f"{metric}_std"] = float(np.std(vals))
s["n_runs"] = len(runs)
summary[f"alpha_{α:.1f}"] = s
output = {
"timestamp": ts,
"config": {"alphas": alphas, "algos": algos, "seeds": seeds, "steps": steps},
"results": results,
"summary": summary,
}
path = output_dir / f"margin_erosion_alpha_{ts}.json"
with open(path, "w") as f:
json.dump(output, f, indent=2)
print(f"\n{path}")
for α in alphas:
k = f"alpha_{α:.1f}"
if k in summary:
s = summary[k]
print(
f" {k}: margin={s['margin_mean']:.3f}±{s['margin_std']:.3f} "
f"coi={s['coi_level_mean']:.1f}±{s['coi_level_std']:.1f}"
)
return output
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description="margin erosion vs α")
p.add_argument("--quick", action="store_true", help="fast test")
args = p.parse_args()
run_margin_erosion_study(
alphas=[0.1, 0.7] if args.quick else [0.1, 0.3, 0.5, 0.7, 0.9],
algos=["qtable"] if args.quick else ["ppo", "dqn", "qtable"],
seeds=1 if args.quick else 3,
steps=5_000 if args.quick else 30_000,
)

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

@@ -0,0 +1,127 @@
"""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,60 @@
method: grid
metric:
name: eval/stress_reward_worst
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
value: ppo
backend:
value: sb3
device:
value: cpu
seed:
values: [42, 1337, 7777]
alpha:
values: [0.1, 0.2, 0.3, 0.4, 0.6, 0.8]
n_products:
values: [25, 50, 100]
N:
value: 100
no_robust:
values: [false, true]
lambda_coi:
values: [0.15, 0.30]
robust_radius:
value: 0.2
robust_points:
value: 7
robust_rollouts:
value: 1
eta_ux:
value: 0.5
reward_profit_weight:
value: 1.0
action_levels:
value: 9
action_scale_low:
value: 0.8
action_scale_high:
value: 1.2
total_timesteps:
value: 100000
eval_episodes:
value: 12
eval_freq:
value: 1000
log_freq:
value: 100
hist_freq:
value: 500
learning_rate:
value: 0.0003
batch_size:
value: 256
n_steps:
value: 2048

View File

@@ -0,0 +1,84 @@
method: random
metric:
name: objective/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: objective/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,53 @@
method: random
metric:
name: eval/supra_share_mean
goal: minimize
run_cap: 256
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
value: ppo
seed:
values: [42, 1337, 7777]
alpha:
values: [0.1, 0.2, 0.3, 0.4, 0.6]
n_products:
values: [25, 50]
N:
value: 100
no_robust:
values: [false, true]
lambda_coi:
values: [0.05, 0.15, 0.3]
robust_radius:
values: [0.1, 0.2, 0.3]
robust_points:
value: 7
robust_rollouts:
value: 1
eta_ux:
values: [0.05, 0.15, 0.3, 0.5, 0.75]
reward_profit_weight:
value: 1.0
total_timesteps:
value: 100000
eval_episodes:
value: 10
eval_freq:
value: 1000
log_freq:
value: 100
hist_freq:
value: 500
learning_rate:
value: 0.0003
batch_size:
value: 256
n_steps:
value: 2048
device:
value: cpu

View File

@@ -0,0 +1,54 @@
method: bayes
metric:
name: objective/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: objective/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]

View File

@@ -0,0 +1,23 @@
from .metrics import canonicalize_metrics
from .wandb import (
current_config,
finish_run,
get_wandb_module,
init_run,
log_metrics,
run_agent,
update_run_config,
update_summary,
)
__all__ = [
"canonicalize_metrics",
"current_config",
"finish_run",
"get_wandb_module",
"init_run",
"log_metrics",
"run_agent",
"update_run_config",
"update_summary",
]

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any, Mapping
from ..spec import TrainSpec
_ALIASES = {
"train/reward": "train/reward_mean",
"train/revenue": "train/revenue_mean",
"train/dqn_loss": "train/loss",
"eval/reward": "eval/reward_mean",
"eval/revenue": "eval/revenue_mean",
"train/steps_per_second": "runtime/steps_per_second",
}
def _as_float(value: Any, default: float | None = None) -> float | None:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def canonicalize_metrics(raw: Mapping[str, Any], spec: TrainSpec) -> dict[str, Any]:
metrics: dict[str, Any] = {}
for key, value in raw.items():
canonical = _ALIASES.get(str(key), str(key))
if canonical in metrics and canonical != key:
continue
metrics[canonical] = value
metrics.setdefault("train/global_step", spec.runtime.total_timesteps)
eval_reward = (
_as_float(
metrics.get(
"eval/stress_reward_worst",
metrics.get(
"eval/robust_reward_worst", metrics.get("eval/reward_mean")
),
),
0.0,
)
or 0.0
)
metrics["objective/score"] = eval_reward
margin_mean = _as_float(metrics.get("eval/margin_mean"), None)
if margin_mean is not None:
metrics["objective/constraint_margin"] = margin_mean - spec.env.margin_floor
coi_level = _as_float(metrics.get("eval/coi_level_mean"), None)
metrics["objective/coi_preserved"] = 0.0 if coi_level is None else coi_level
metrics["study/alpha"] = spec.study.alpha
metrics["study/mode"] = "baseline" if bool(spec.study.no_robust) else "defended"
metrics["study/baseline_mode"] = float(bool(spec.study.no_robust))
metrics["study/lambda_coi"] = spec.study.lambda_coi
metrics["study/ambiguity_radius"] = spec.study.robust_radius
metrics["study/info_value"] = spec.study.info_value
metrics["tiers"] = spec.algorithm.name
metrics["runtime/backend"] = spec.runtime.backend
metrics["runtime/device"] = spec.runtime.device
metrics["runtime/seed"] = spec.runtime.seed
return metrics

202
engine/telemetry/wandb.py Normal file
View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import os
import time
from typing import Any, Callable, Iterable, Mapping
def get_wandb_module():
try:
import wandb
return wandb
except ImportError:
return None
def _require_wandb():
wandb = get_wandb_module()
if wandb is None:
raise ImportError("wandb is required for this workflow")
return wandb
def _warn(message: str) -> None:
print(f"PHANTOM_WANDB_WARNING: {message}")
def _sanitize_key(raw_key: str) -> str | None:
key = str(raw_key)
replacements = {
"no_robust": "baseline_mode",
"study/no_robust": "study/baseline_mode",
"study/robust_radius": "study/ambiguity_radius",
"robust_radius": "ambiguity_radius",
"robust_points": "ambiguity_points",
"robust_rollouts": "ambiguity_rollouts",
"robust_eval_enabled": "stress_eval_enabled",
"eval/robust_alpha_high": "eval/stress_alpha_high",
"eval/robust_alpha_low": "eval/stress_alpha_low",
"eval/robust_reward_worst": "eval/stress_reward_worst",
"eval/robust_revenue_worst": "eval/stress_revenue_worst",
"eval/robust_coi_leakage_worst": "eval/stress_coi_leakage_worst",
}
key = replacements.get(key, key)
if "robust" in key.lower():
return None
return key
def _sanitize_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
sanitized: dict[str, Any] = {}
for key, value in payload.items():
clean_key = _sanitize_key(str(key))
if clean_key is None:
continue
sanitized[clean_key] = value
return sanitized
def init_run(
*,
mode: str,
project: str | None = None,
config: Mapping[str, Any] | None = None,
name: str | None = None,
tags: Iterable[str] | None = None,
group: str | None = None,
sweep_mode: bool = False,
):
wandb = _require_wandb()
kwargs: dict[str, Any] = {"mode": mode}
if group:
kwargs["group"] = group
if sweep_mode:
try:
run = wandb.init(**kwargs)
except Exception as exc:
_warn(f"init failed in sweep mode ({exc})")
return None
if name and run is not None:
run.name = name
return run
init_kwargs = dict(kwargs)
init_kwargs["project"] = project
if config is not None:
init_kwargs["config"] = _sanitize_payload(dict(config))
if name:
init_kwargs["name"] = name
if tags:
init_kwargs["tags"] = list(tags)
try:
return wandb.init(**init_kwargs)
except Exception as exc:
_warn(f"init failed ({exc})")
return None
def finish_run() -> None:
wandb = get_wandb_module()
if wandb is not None and wandb.run is not None:
try:
wandb.finish()
except Exception as exc:
_warn(f"finish failed ({exc})")
def current_config() -> dict[str, Any]:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return {}
return {key: wandb.config[key] for key in wandb.config.keys()}
def update_run_config(config: Mapping[str, Any]) -> None:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return
payload = _sanitize_payload(dict(config))
if not payload:
return
try:
wandb.config.update(payload, allow_val_change=True)
except TypeError:
try:
wandb.config.update(payload)
except Exception as exc:
_warn(f"config update failed ({exc})")
except Exception as exc:
_warn(f"config update failed ({exc})")
def log_metrics(metrics: Mapping[str, Any], *, step: int) -> None:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return
payload = _sanitize_payload(dict(metrics))
if not payload:
return
try:
wandb.log(payload, step=step)
except Exception as exc:
_warn(f"log failed at step {step} ({exc})")
def update_summary(metrics: Mapping[str, Any]) -> None:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return
payload = _sanitize_payload(dict(metrics))
if not payload:
return
try:
for key, value in payload.items():
wandb.run.summary[key] = value
except Exception as exc:
_warn(f"summary update failed ({exc})")
def run_agent(
sweep_id: str,
fn: Callable[[], None],
*,
count: int | None = None,
) -> None:
wandb = _require_wandb()
retry_max = max(0, int(os.getenv("PHANTOM_WANDB_AGENT_RETRIES", "8")))
retry_delay = max(1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_DELAY", "5")))
retry_backoff = max(
1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_BACKOFF", "1.5"))
)
retry_max_delay = max(
retry_delay,
float(os.getenv("PHANTOM_WANDB_AGENT_MAX_RETRY_DELAY", "60")),
)
target = None if count is None else max(0, int(count))
completed = 0
def _wrapped() -> None:
nonlocal completed
fn()
completed += 1
attempt = 0
while True:
remaining = None if target is None else max(0, int(target - completed))
if target is not None and remaining == 0:
return
try:
wandb.agent(sweep_id, function=_wrapped, count=remaining)
return
except Exception as exc:
attempt += 1
if attempt > retry_max:
raise
wait = min(retry_max_delay, retry_delay * (retry_backoff ** (attempt - 1)))
_warn(
f"agent disconnected (attempt {attempt}/{retry_max}, "
f"completed={completed}, remaining={remaining}): {exc}"
)
time.sleep(wait)

251
engine/train.py Normal file
View File

@@ -0,0 +1,251 @@
from __future__ import annotations
import argparse
from typing import Any
from .logging_utils import configure_logging
from .orchestrators import run_benchmark_cli, run_sweep_agent, run_train_once
from .spec import TrainSpec
def _parse_tags(raw: str | None) -> list[str]:
if raw is None:
return []
return [piece.strip() for piece in str(raw).split(",") if piece.strip()]
def _probe_run_kind(argv: list[str]) -> str:
probe = argparse.ArgumentParser(add_help=False)
probe.add_argument("--run-kind", choices=["train", "benchmark"])
probe.add_argument("--run-mode", choices=["train", "benchmark"])
args, _ = probe.parse_known_args(argv)
return str(args.run_kind or args.run_mode or "train")
def _strip_run_kind(argv: list[str]) -> list[str]:
stripped: list[str] = []
skip_next = False
for item in argv:
if skip_next:
skip_next = False
continue
if item in {"--run-kind", "--run-mode"}:
skip_next = True
continue
if item.startswith("--run-kind=") or item.startswith("--run-mode="):
continue
stripped.append(item)
return stripped
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="PHANTOM unified training entrypoint")
parser.add_argument("--run-kind", choices=["train", "benchmark"], default="train")
parser.add_argument("--run-mode", choices=["train", "benchmark"])
parser.add_argument("--project", default="capstone")
parser.add_argument("--scenario", default="default")
parser.add_argument("--group", type=str)
parser.add_argument("--tags", type=str)
parser.add_argument("--backend", choices=["auto", "sb3"], default="auto")
parser.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable", "sac"])
parser.add_argument("--seed", type=int)
parser.add_argument("--total-timesteps", type=int)
parser.add_argument("--model-dir", type=str)
parser.add_argument("--log-freq", type=int)
parser.add_argument("--hist-freq", type=int)
parser.add_argument("--checkpoint-interval", type=int)
parser.add_argument("--device", type=str)
parser.add_argument("--alpha", type=float)
parser.add_argument("--N", type=int)
parser.add_argument("--n-products", type=int)
parser.add_argument("--lambda-coi", type=float)
parser.add_argument("--info-value", type=float)
parser.add_argument("--robust-radius", type=float)
parser.add_argument("--robust-points", type=int)
parser.add_argument("--robust-rollouts", type=int)
parser.add_argument("--no-robust", action="store_true")
parser.add_argument("--eta-ux", type=float)
parser.add_argument("--reward-profit-weight", type=float)
parser.add_argument("--price-low", type=float)
parser.add_argument("--price-high", type=float)
parser.add_argument("--action-levels", type=int)
parser.add_argument("--action-scale-low", type=float)
parser.add_argument("--action-scale-high", type=float)
parser.add_argument("--max-steps", type=int)
parser.add_argument("--margin-floor", type=float)
parser.add_argument("--margin-floor-patience", type=int)
parser.add_argument("--learning-rate", type=float)
parser.add_argument("--gamma", type=float)
parser.add_argument("--buffer-size", type=int)
parser.add_argument("--batch-size", type=int)
parser.add_argument("--tau", type=float)
parser.add_argument("--train-freq", type=int)
parser.add_argument("--learning-starts", type=int)
parser.add_argument("--target-update-interval", type=int)
parser.add_argument("--exploration-fraction", type=float)
parser.add_argument("--exploration-final-eps", type=float)
parser.add_argument("--n-steps", type=int)
parser.add_argument("--n-epochs", type=int)
parser.add_argument("--gae-lambda", type=float)
parser.add_argument("--clip-range", type=float)
parser.add_argument("--ent-coef", type=float)
parser.add_argument("--q-lr", type=float)
parser.add_argument("--q-bins", type=int)
parser.add_argument("--eps-start", type=float)
parser.add_argument("--eps-end", type=float)
parser.add_argument("--eps-decay", type=float)
parser.add_argument("--arch", type=str)
parser.add_argument("--activation", type=str)
parser.add_argument("--vf-coef", type=float)
parser.add_argument("--max-grad-norm", type=float)
parser.add_argument("--eval-freq", type=int)
parser.add_argument("--eval-episodes", type=int)
parser.add_argument("--sweep-agent", action="store_true")
parser.add_argument("--sweep-id", type=str)
parser.add_argument("--count", type=int, default=0)
parser.add_argument("--offline", action="store_true")
parser.add_argument("--no-wandb", action="store_true")
return parser
def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
backend = None if args.backend == "auto" else args.backend
overrides = {
"project": args.project,
"backend": backend,
"algo": args.algo,
"seed": args.seed,
"total_timesteps": args.total_timesteps,
"model_dir": args.model_dir,
"log_freq": args.log_freq,
"hist_freq": args.hist_freq,
"checkpoint_interval": args.checkpoint_interval,
"device": args.device,
"alpha": args.alpha,
"N": args.N,
"n_products": args.n_products,
"lambda_coi": args.lambda_coi,
"info_value": args.info_value,
"robust_radius": args.robust_radius,
"robust_points": args.robust_points,
"robust_rollouts": args.robust_rollouts,
"no_robust": args.no_robust,
"eta_ux": args.eta_ux,
"reward_profit_weight": args.reward_profit_weight,
"price_low": args.price_low,
"price_high": args.price_high,
"action_levels": args.action_levels,
"action_scale_low": args.action_scale_low,
"action_scale_high": args.action_scale_high,
"max_steps": args.max_steps,
"margin_floor": args.margin_floor,
"margin_floor_patience": args.margin_floor_patience,
"learning_rate": args.learning_rate,
"gamma": args.gamma,
"buffer_size": args.buffer_size,
"batch_size": args.batch_size,
"tau": args.tau,
"train_freq": args.train_freq,
"learning_starts": args.learning_starts,
"target_update_interval": args.target_update_interval,
"exploration_fraction": args.exploration_fraction,
"exploration_final_eps": args.exploration_final_eps,
"n_steps": args.n_steps,
"n_epochs": args.n_epochs,
"gae_lambda": args.gae_lambda,
"clip_range": args.clip_range,
"ent_coef": args.ent_coef,
"q_lr": args.q_lr,
"q_bins": args.q_bins,
"eps_start": args.eps_start,
"eps_end": args.eps_end,
"eps_decay": args.eps_decay,
"arch": args.arch,
"activation": args.activation,
"vf_coef": args.vf_coef,
"max_grad_norm": args.max_grad_norm,
"eval_freq": args.eval_freq,
"eval_episodes": args.eval_episodes,
}
return {key: value for key, value in overrides.items() if value is not None}
def main(argv: list[str] | None = None) -> None:
import subprocess
import sys
# Ensure data is downloaded
from pathlib import Path
project_root = Path(__file__).parents[1]
data_dir = project_root / "experiments" / "collected_data"
needs_pull = (not data_dir.exists()) or (not any(data_dir.iterdir()))
if needs_pull:
try:
subprocess.run(["make", "data.pull"], cwd=str(project_root), check=True)
except (subprocess.SubprocessError, OSError) as exc:
sys.path.insert(0, str(project_root))
try:
from scripts.hf_data import pull
pull()
except (ImportError, OSError, RuntimeError, ValueError) as fallback_exc:
print(
f"Warning: data.pull failed ({exc}); fallback pull failed ({fallback_exc})"
)
configure_logging()
raw_args = list(sys.argv[1:] if argv is None else argv)
run_kind = _probe_run_kind(raw_args)
if run_kind == "benchmark":
run_benchmark_cli(_strip_run_kind(raw_args))
return
parser = _build_parser()
args, unknown = parser.parse_known_args(raw_args)
if unknown:
raise ValueError(f"Unknown arguments for training mode: {' '.join(unknown)}")
overrides = _overrides_from_args(args)
scenario = str(args.scenario)
group = args.group
extra_tags = tuple(_parse_tags(args.tags))
if args.sweep_agent:
run_sweep_agent(
project=args.project,
sweep_id=str(args.sweep_id or ""),
count=int(args.count),
offline=bool(args.offline),
no_wandb=bool(args.no_wandb),
base_overrides=overrides,
kind="sweep",
scenario=scenario,
group=group,
extra_tags=extra_tags,
)
return
spec = TrainSpec.from_flat(overrides)
run_train_once(
spec,
project=args.project,
offline=bool(args.offline),
no_wandb=bool(args.no_wandb),
kind="train",
scenario=scenario,
group=group,
extra_tags=extra_tags,
)
if __name__ == "__main__":
main()

40
engine/train_core.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .spec import TrainSpec
from .telemetry.metrics import canonicalize_metrics
@dataclass(frozen=True)
class TrainResult:
spec: TrainSpec
metrics: dict[str, Any]
artifacts: dict[str, str]
events: list[dict[str, Any]]
def run_train(spec: TrainSpec) -> TrainResult:
cfg = spec.to_flat_dict()
algo = spec.algorithm.name
if algo == "qtable":
from .backends.qtable import train_qtable
_, raw_metrics = train_qtable(cfg)
else:
from .backends.sb3 import train_sb3
_, raw_metrics = train_sb3(cfg)
events_raw = raw_metrics.pop("_train_events", [])
events = [evt for evt in events_raw if isinstance(evt, dict)]
metrics = canonicalize_metrics(raw_metrics, spec)
artifacts: dict[str, str] = {}
model_path = raw_metrics.get("model/path")
if isinstance(model_path, str):
artifacts["model/path"] = model_path
return TrainResult(spec=spec, metrics=metrics, artifacts=artifacts, events=events)

130
engine/wandb_checkpoint.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Mapping
try:
import wandb
from wandb.errors import CommError
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
wandb = None # type: ignore[assignment]
CommError = RuntimeError # type: ignore[assignment]
def _safe_value(value: Any) -> Any:
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, (list, tuple)):
return [_safe_value(v) for v in value]
if isinstance(value, dict):
return {str(k): _safe_value(value[k]) for k in sorted(value)}
return str(value)
def _safe_scope(scope: str | None) -> str:
raw = "manual" if scope in (None, "") else str(scope)
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-")
return cleaned or "manual"
def checkpoint_artifact_name(
cfg: Mapping[str, Any], *, backend: str, sweep_id: str | None = None
) -> str:
payload = {k: _safe_value(cfg[k]) for k in sorted(cfg)}
scope = _safe_scope(sweep_id)
canonical = json.dumps(
{"backend": backend, "scope": scope, "cfg": payload},
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:14]
return f"phantom-{backend}-ckpt-{scope}-{digest}"[:128]
def _is_missing_artifact_error(exc: Exception) -> bool:
if isinstance(exc, CommError):
msg = str(exc).lower()
return "not found" in msg or "does not exist" in msg
return False
def download_latest_checkpoint(
artifact_name: str, *, file_name: str
) -> tuple[Path, dict[str, Any]] | None:
if not HAS_WANDB or wandb.run is None:
return None
try:
artifact = wandb.run.use_artifact(f"{artifact_name}:latest")
except Exception as exc:
if _is_missing_artifact_error(exc):
return None
raise
directory = Path(artifact.download())
checkpoint_path = directory / file_name
if not checkpoint_path.exists():
return None
metadata = dict(getattr(artifact, "metadata", {}) or {})
return checkpoint_path, metadata
def _aliases_from_metadata(metadata: dict[str, Any] | None) -> list[str]:
aliases = ["latest"]
if metadata is None:
return aliases
if "step" in metadata:
try:
aliases.append(f"step-{int(metadata['step'])}")
except (TypeError, ValueError):
pass
return aliases
def log_checkpoint_bytes(
artifact_name: str,
*,
file_name: str,
payload: bytes,
metadata: dict[str, Any] | None = None,
) -> bool:
if not HAS_WANDB or wandb.run is None:
return False
with TemporaryDirectory(prefix="phantom-ckpt-") as tmpdir:
path = Path(tmpdir) / file_name
path.write_bytes(payload)
artifact = wandb.Artifact(
name=artifact_name,
type="checkpoint",
metadata=metadata or {},
)
artifact.add_file(path.as_posix(), name=file_name)
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
return True
def log_checkpoint_file(
artifact_name: str,
*,
file_path: str | Path,
artifact_file_name: str,
metadata: dict[str, Any] | None = None,
) -> bool:
if not HAS_WANDB or wandb.run is None:
return False
src = Path(file_path)
if not src.exists():
return False
artifact = wandb.Artifact(
name=artifact_name,
type="checkpoint",
metadata=metadata or {},
)
artifact.add_file(src.as_posix(), name=artifact_file_name)
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
return True

477
engine/wrapper.py Normal file
View File

@@ -0,0 +1,477 @@
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
from .jax.robust import select_adversarial_alpha_jax, _JAX_OK
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,
robust_rollouts: int = 1,
info_value: float = 1.0,
eta_ux: float = 0.5,
reward_profit_weight: 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.robust_rollouts = max(1, int(robust_rollouts))
self.info_value = float(info_value)
self.eta_ux = float(eta_ux)
self.reward_profit_weight = float(reward_profit_weight)
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,
),
"signals": spaces.Box(
low=np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32),
high=np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32),
shape=(4,),
dtype=np.float32,
),
}
)
self._prices = None
self._demand = None
self._step_count = 0
self._global_step = 0 # monotonic; used as JAX RNG seed across resets
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.anchor_prices = np.full(
self.n_products,
float(np.clip(float(self.human_params[0]), *self.price_bounds)),
)
self.competitive_cap = float(
min(self.price_bounds[1], float(np.mean(self.anchor_prices)) * 1.15)
)
self._low_margin_streak = 0 # consecutive steps below margin_floor
self._last_agent_prob = float(self.alpha)
self._last_alpha_adv = float(self.alpha)
# 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
)
signals = np.array(
[
float(np.clip(self._last_agent_prob, 0.0, 1.0)),
float(np.clip(self._last_alpha_adv, 0.0, 1.0)),
float(np.clip(self.nominal_alpha, 0.0, 1.0)),
float(np.clip(self.robust_radius, 0.0, 1.0)),
],
dtype=np.float32,
)
return {
"demand": demand_arr,
"prices": self._prices.astype(np.float32),
"signals": signals,
}
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:
prev = self._prices
base = self.anchor_prices
def _blend(target: np.ndarray) -> np.ndarray:
if prev is None:
lower = float(self.price_bounds[0])
return np.clip(target, lower, self.competitive_cap)
blended = 0.75 * np.asarray(prev, dtype=float) + 0.25 * target
lower = float(self.price_bounds[0])
return np.clip(blended, lower, self.competitive_cap)
if np.isscalar(action):
idx = int(np.clip(int(action), 0, self.action_levels - 1))
target = base * self._action_scales[idx]
return _blend(target)
a = np.asarray(action)
if a.size == 1:
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
target = base * self._action_scales[idx]
return _blend(target)
lower = float(self.price_bounds[0])
return np.clip(a.astype(float), lower, self.competitive_cap)
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))
floor_cost = float(np.dot(self.baseline_prices, demand_arr))
profit = revenue - floor_cost
purchases = extract_purchases(trajectories)
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
coi_leakage = float(agent_prob * self.info_value)
info_budget = max(floor_cost, 1.0)
coi_penalty = self.lambda_coi * coi_leakage * info_budget
if len(self._price_history) > 0:
prev_prices = np.asarray(self._price_history[-1], dtype=float)
rel_change = (prices - prev_prices) / np.maximum(prev_prices, 1.0)
volatility = float(np.mean(np.abs(rel_change)))
upward_volatility = float(np.mean(np.clip(rel_change, 0.0, None)))
else:
volatility = 0.0
upward_volatility = 0.0
ux_penalty = self.eta_ux * info_budget * (volatility + 0.5 * upward_volatility)
competitive_anchor = float(np.mean(self.anchor_prices))
price_ratio = prices / max(competitive_anchor, 1.0)
supra_excess = np.clip(price_ratio - 1.15, 0.0, None)
supra_penalty = 4.0 * info_budget * float(np.mean(np.square(supra_excess)))
supra_share = float(np.mean(supra_excess > 0.0))
reward_revenue = self.reward_profit_weight * profit
reward = reward_revenue - coi_penalty - ux_penalty - supra_penalty
return reward, {
"revenue": revenue,
"cost_floor": floor_cost,
"profit": profit,
"coi_mix": float(coi_mix),
"coi_base": 0.0,
"coi_leakage": coi_leakage,
"coi_penalty": coi_penalty,
"coi_info_budget": info_budget,
"ux_penalty": ux_penalty,
"volatility": volatility,
"upward_volatility": upward_volatility,
"supra_penalty": supra_penalty,
"supra_share": supra_share,
"competitive_anchor": competitive_anchor,
"reward_revenue": reward_revenue,
"reward_total": reward,
}
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 _evaluate_candidate(self, alpha: float, prices: np.ndarray) -> float:
self._set_market_mix(alpha)
rewards = []
for _ in range(self.robust_rollouts):
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)
rewards.append(float(reward))
return float(np.mean(rewards)) if rewards else 0.0
def _select_adversarial_alpha(self, prices: np.ndarray) -> float:
"""inner robust step: pick worst-case alpha from the ambiguity interval.
when JAX is available and robust_rollouts==1 we use a vmapped pass over
all K candidates in a single call (no Python loop, no market.act overhead).
the JAX path approximates demand as the mixed closed-form d(p;theta) signal
rather than running full trajectory sampling, which is accurate for the
alpha-selection decision while being dramatically cheaper.
when robust_rollouts>1 or JAX is unavailable we fall back to the sequential
market.act() loop so behavior is identical to the original implementation.
"""
candidates = self._alpha_candidates()
if len(candidates) == 1:
return float(candidates[0])
if _JAX_OK and self.robust_rollouts == 1:
best_alpha, _ = select_adversarial_alpha_jax(
candidates=candidates,
prices=prices,
human_params=self.market.human_params,
agent_params=self.market.agent_params,
noise_std=self.market.noise_std,
baseline_prices=self.baseline_prices,
lambda_coi=self.lambda_coi,
info_value=self.info_value,
reward_profit_weight=self.reward_profit_weight,
rng_seed=self._global_step,
)
return best_alpha
# fallback: full trajectory-based sequential evaluation
evaluations = [
(float(alpha), self._evaluate_candidate(float(alpha), prices))
for alpha in candidates
]
best_alpha, _ = min(evaluations, key=lambda x: x[1])
return best_alpha
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._last_agent_prob = float(self.nominal_alpha)
self._last_alpha_adv = float(self.nominal_alpha)
self._record_history()
return self._get_obs(), {}
def step(self, action):
self._prices = self._decode_action(action)
alpha_adv = self._select_adversarial_alpha(self._prices)
self._global_step += 1 # always increment; JAX path may have already done so
self._set_market_mix(alpha_adv)
self._platform_stub.set_prices(self._prices)
self._step_count += 1
self._demand = self.market.act(self._prices)
trajectories = list(self.market.last_trajectories)
agent_prob = self._compute_agent_prob(trajectories)
self._trajectories.extend(trajectories)
self._last_agent_prob = float(agent_prob)
self._last_alpha_adv = float(alpha_adv)
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),
"alpha_nominal": float(self.nominal_alpha),
"wasserstein_radius": float(self.robust_radius),
"robust_rollouts": int(self.robust_rollouts),
**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="capstone", 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()

View File

@@ -0,0 +1,8 @@
# Products
# Agents
# Pipeline
Our pipeline technically should follow principles in a style like this:
- Each step should be defined as an inheriting child of an scikit pipeline step, the granularity of the steps is dictated by the following: a step should be a transformation, augmentation or computation independently, no single stage should run multiple in-itself. This way we can modularize properly all the components and track properly in airflow. A stage can be defined as an sklearn step but then must be transalted to a function that takes the context in our DAG of airflow. All parametrization must be done via contexts.

View File

@@ -38,7 +38,10 @@ def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
if __name__ == "__main__":
import asyncio
JTBD= "Name all the products on this site and try to find out more about each product by clicking into them (they might not open)"
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal=JTBD, url="http://localhost:3000/products", timeout=300)
JTBD= "Find me the cheapest room in Madrid for 2 people in the next two days, review each hotel room in detail and then add it to cart."
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
goal=JTBD,
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
timeout=300)
R=asyncio.run(agent.act())
print(R)

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

@@ -0,0 +1,115 @@
from airflow import DAG, Dataset
from airflow.decorators import task
from airflow.utils.dates import days_ago
from datetime import timedelta
import pandas as pd
import logging
import sys
import pickle
sys.path.insert(0, '/opt/airflow')
from procesing.context import PipelineContext
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.steps import (
FetchInteractionsStep,
ValidateDataStep,
ExtractSessionFeaturesStep,
JoinLabelsStep,
)
TRAINING_DATASET = Dataset('phantom://ml/training-data')
DEFAULT_ARGS = {
'owner': 'phantom-research',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 2,
'retry_delay': timedelta(minutes=5),
}
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
def __init__(self):
SupabaseProvider.__init__(self)
BackendAPIProvider.__init__(self)
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
with DAG(
'ml_training_pipeline',
default_args=DEFAULT_ARGS,
description='ML training data pipeline: fetch -> validate -> extract features -> label -> publish',
schedule=None,
start_date=days_ago(1),
catchup=False,
max_active_runs=1,
tags=['ml', 'training', 'features', 'research'],
) as dag:
@task
def fetch_interactions(**kwargs) -> bytes:
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
df = FetchInteractionsStep(ctx).transform(None)
logging.info(f"Fetched {len(df)} interactions, {df['sessionId'].nunique()} sessions")
return pickle.dumps(df)
@task
def validate_data(raw_data: bytes, **kwargs) -> bytes:
df = pickle.loads(raw_data)
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
validated = ValidateDataStep(ctx).transform(df)
report = ctx.get_cached('validation_report') or {}
logging.info(f"Validation: {report.get('status')}, {report.get('sessions', 0)} sessions")
return pickle.dumps(validated)
@task
def extract_session_features(validated_data: bytes, **kwargs) -> bytes:
df = pickle.loads(validated_data)
if df.empty:
logging.warning("Empty input, skipping feature extraction")
return pickle.dumps(pd.DataFrame())
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
features = ExtractSessionFeaturesStep(ctx).transform(df)
logging.info(f"Extracted {len(features.columns)} features for {len(features)} sessions")
return pickle.dumps(features)
@task
def join_labels(features_data: bytes, **kwargs) -> bytes:
features_df = pickle.loads(features_data)
if features_df.empty:
logging.warning("Empty features, skipping label join")
return pickle.dumps(pd.DataFrame())
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
labeled = JoinLabelsStep(ctx).transform(features_df)
n_agents = labeled['is_agent'].sum() if 'is_agent' in labeled.columns else 0
logging.info(f"Labeled {len(labeled)} sessions: {n_agents} agents")
return pickle.dumps(labeled)
@task(outlets=[TRAINING_DATASET])
def publish_training_data(labeled_data: bytes, **kwargs) -> dict:
labeled_df = pickle.loads(labeled_data)
if labeled_df.empty:
return {'status': 'skipped', 'reason': 'empty_data'}
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
return {
'status': 'success',
'n_sessions': len(labeled_df),
'n_features': len([c for c in labeled_df.columns if c not in ['sessionId', 'experimentId', 'is_agent']]),
'store_mode': dag_conf.get('store_mode', 'hotel'),
'timestamp': pd.Timestamp.now().isoformat(),
}
raw = fetch_interactions()
validated = validate_data(raw)
features = extract_session_features(validated)
labeled = join_labels(features)
publish_training_data(labeled)

View File

@@ -0,0 +1,269 @@
"""
Session-Aware Pricing DAG
THIS implements the core pricing computation (policy layer).
Flow: τ → θ̂ → D → p*
1. Fetch recent sessions from Kafka (last 10 active)
2. Extract features per session (τ → θ̂)
3. Map features to demand proxy (θ̂ → D)
4. Compute optimal prices (D → p*)
5. Write to Redis session:{sessionId}:prices
Scheduled: every 1 minute when enabled
"""
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
import pandas as pd
import numpy as np
import logging
import sys
import pickle
sys.path.insert(0, '/opt/airflow')
from procesing.context import PipelineContext
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.steps.session import ExtractSessionFeaturesStep
from procesing.pricers.simple import SimpleSurgePricer, session_features_to_demand
from procesing.pricing import StateSpace
from lib.model_registry import ModelRegistry
DEFAULT_ARGS = {
'owner': 'phantom-research',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 1,
'retry_delay': timedelta(seconds=30),
}
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
def __init__(self):
SupabaseProvider.__init__(self)
BackendAPIProvider.__init__(self)
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
def fetch_recent_sessions(**kwargs):
"""
Task: Fetch last N active sessions from Kafka.
Returns: DataFrame of interaction events for recent sessions.
"""
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
store_mode = dag_conf.get('store_mode', 'hotel')
session_limit = dag_conf.get('session_limit', 10)
ctx = _get_context(store_mode)
provider = ctx.provider
# fetch all recent interactions from Kafka
try:
interactions_df = provider.fetch_kafka_topic("user-interactions")
except Exception as e:
logging.error(f"Failed to fetch interactions: {e}")
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
return 0
if interactions_df.empty or 'sessionId' not in interactions_df.columns:
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
return 0
# identify last N active sessions (most recent by event count)
recent_sessions = interactions_df['sessionId'].value_counts().head(session_limit).index.tolist()
# filter to only those sessions
filtered_df = interactions_df[interactions_df['sessionId'].isin(recent_sessions)].copy()
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(filtered_df))
kwargs['ti'].xcom_push(key='session_ids', value=recent_sessions)
logging.info(f"Fetched {len(filtered_df)} events for {len(recent_sessions)} sessions")
return len(recent_sessions)
def extract_session_features(**kwargs):
"""
Task: Extract behavioral features from session trajectories.
THIS implements τ → θ̂ transformation.
"""
ti = kwargs['ti']
sessions_df = pickle.loads(ti.xcom_pull(key='sessions_data'))
if sessions_df.empty:
ti.xcom_push(key='session_features', value=pickle.dumps(pd.DataFrame()))
return 0
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
# extract features using vectorized pipeline
feature_extractor = ExtractSessionFeaturesStep(ctx)
features_df = feature_extractor.transform(sessions_df)
ti.xcom_push(key='session_features', value=pickle.dumps(features_df))
logging.info(f"Extracted {len(features_df.columns)} features for {len(features_df)} sessions")
logging.info(f"Feature columns: {list(features_df.columns)}")
logging.info(f"Sample features (first session):\n{features_df.iloc[0].to_dict()}")
return len(features_df)
def compute_session_prices(**kwargs):
"""
Task: Compute optimal prices for each session.
THIS implements θ̂ → D → p* transformation.
"""
ti = kwargs['ti']
features_df = pickle.loads(ti.xcom_pull(key='session_features'))
if features_df.empty:
ti.xcom_push(key='price_results', value=pickle.dumps({}))
return 0
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
store_mode = dag_conf.get('store_mode', 'hotel')
ctx = _get_context(store_mode)
# fetch product catalog for base prices
products_df = ctx.provider.fetch_products(store_mode)
if products_df.empty:
logging.error("No products found in catalog")
ti.xcom_push(key='price_results', value=pickle.dumps({}))
return 0
products_df['base_price'] = products_df['metadata'].apply(
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
)
# initialize pricing model
pricer = SimpleSurgePricer(
high_threshold=dag_conf.get('high_threshold', 10),
low_threshold=dag_conf.get('low_threshold', 2),
surge_multiplier=dag_conf.get('surge_multiplier', 1.15),
discount_multiplier=dag_conf.get('discount_multiplier', 0.95)
)
pricer.fit(products_df)
# compute prices per session
price_results = {}
n_products = len(products_df)
logging.info(f"Starting price computation for {len(features_df)} sessions, {n_products} products")
logging.info(f"Pricer config: high_thresh={pricer.high_threshold}, low_thresh={pricer.low_threshold}, surge_mult={pricer.surge_multiplier}")
for idx, session_row in features_df.iterrows():
session_id = session_row.get('sessionId')
if not session_id:
continue
# map features to demand proxy (θ̂ → D)
session_features_single = pd.DataFrame([session_row])
demand_proxy = session_features_to_demand(session_features_single)
logging.info(f"[Session {session_id}] Features → Demand: {demand_proxy:.2f}")
logging.info(f"[Session {session_id}] Key features: velocity={session_row.get('interaction_velocity', 0):.2f}, cart_ratio={session_row.get('cart_to_view_ratio', 0):.2f}, item_views={session_row.get('item_views', 0)}")
# build state space
state_space = StateSpace(
demand=np.full(n_products, demand_proxy), # broadcast session demand to all products
prices=products_df['base_price'].values,
session_features=session_features_single
)
# compute optimal prices (D → p*)
optimal_prices = pricer.predict(state_space)
base_avg = products_df['base_price'].mean()
optimal_avg = optimal_prices.mean()
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
logging.info(f"[Session {session_id}] Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
# store as dict {productId: price}
price_map = {
str(products_df.iloc[i]['id']): float(optimal_prices[i])
for i in range(n_products)
}
price_results[session_id] = price_map
ti.xcom_push(key='price_results', value=pickle.dumps(price_results))
logging.info(f"Computed prices for {len(price_results)} sessions, {n_products} products each")
return len(price_results)
def publish_to_registry(**kwargs):
"""
Task: Write session prices to Redis registry.
THIS is the write path: prices → session:{sessionId}:prices
"""
ti = kwargs['ti']
price_results = pickle.loads(ti.xcom_pull(key='price_results'))
if not price_results:
logging.warning("No prices to publish")
return 0
registry = ModelRegistry()
ttl = kwargs.get('dag_run').conf.get('ttl', 1800) if kwargs.get('dag_run') and kwargs.get('dag_run').conf else 1800
published_count = 0
for session_id, price_map in price_results.items():
registry.set_session_prices(session_id, price_map, ttl=ttl)
published_count += 1
logging.info(f"Published prices for {published_count} sessions to registry (TTL={ttl}s)")
return {
'sessions_published': published_count,
'products_per_session': len(next(iter(price_results.values()))) if price_results else 0,
'status': 'success'
}
# DAG definition
with DAG(
'session_pricing_pipeline',
default_args=DEFAULT_ARGS,
description='Session-aware pricing: extract features → compute prices → publish to registry',
schedule_interval='*/1 * * * *', # every 1 minute
start_date=days_ago(1),
catchup=False,
max_active_runs=1,
tags=['pricing', 'session-aware', 'research', 'real-time'],
) as dag:
t_fetch_sessions = PythonOperator(
task_id='fetch_recent_sessions',
python_callable=fetch_recent_sessions,
provide_context=True,
)
t_extract_features = PythonOperator(
task_id='extract_session_features',
python_callable=extract_session_features,
provide_context=True,
)
t_compute_prices = PythonOperator(
task_id='compute_session_prices',
python_callable=compute_session_prices,
provide_context=True,
)
t_publish = PythonOperator(
task_id='publish_to_registry',
python_callable=publish_to_registry,
provide_context=True,
)
# linear dependency: fetch → extract → compute → publish
t_fetch_sessions >> t_extract_features >> t_compute_prices >> t_publish

View File

@@ -0,0 +1,220 @@
from pandas.core.algorithms import factorize_array
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
import pandas as pd
import logging
import sys
import pickle
sys.path.insert(0, '/opt/airflow')
from procesing.context import PipelineContext
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.steps import (
FetchInteractionsStep,
FetchPriceLogsStep,
ComputeDemandStep,
AggregatePriceLogsStep,
JoinProductFeaturesStep,
)
from procesing.pricers.simple import SimpleSurgePricer
DEFAULT_ARGS = {
'owner': 'phantom-research',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 2,
'retry_delay': timedelta(minutes=5),
}
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
def __init__(self):
SupabaseProvider.__init__(self)
BackendAPIProvider.__init__(self)
def _get_provider():
return CompositeProvider()
def _make_task_callables(store_mode: str):
"""Generate task callables bound to a specific store_mode."""
def get_context(**kwargs):
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
def fetch_interactions(**kwargs):
ctx = get_context(**kwargs)
df = FetchInteractionsStep(ctx).transform(None)
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
return len(df)
def fetch_price_logs(**kwargs):
ctx = get_context(**kwargs)
df = FetchPriceLogsStep(ctx).transform(None)
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
return len(df)
def compute_demand(**kwargs):
ti = kwargs['ti']
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
ctx = get_context(**kwargs)
demand_df = ComputeDemandStep(ctx).transform(df)
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
return len(demand_df)
def aggregate_price_logs(**kwargs):
ti = kwargs['ti']
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
ctx = get_context(**kwargs)
price_df = AggregatePriceLogsStep(ctx).transform(df)
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
return len(price_df)
def join_product_features(**kwargs):
ti = kwargs['ti']
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
ctx = get_context(**kwargs)
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
return len(joined_df)
def apply_surge_pricing(**kwargs):
ti = kwargs['ti']
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
data = product_features.rename(columns={'demand_score': 'demand'})
surge_pricer = SimpleSurgePricer(
high_threshold=dag_conf.get('high_threshold', 10),
low_threshold=dag_conf.get('low_threshold', 2),
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
)
surge_pricer.fit(data)
data['optimal_price'] = surge_pricer.predict()
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
'price': 'current_price', 'demand': 'demand_score'
})
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
return len(prices_df)
def publish_results(**kwargs):
ti = kwargs['ti']
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
from lib.model_registry import ModelRegistry
registry = ModelRegistry()
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
metadata = {
'timestamp': pd.Timestamp.now().isoformat(),
'store_mode': store_mode,
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
'pricing_method': 'surge',
'high_threshold': dag_conf.get('high_threshold', 10),
'low_threshold': dag_conf.get('low_threshold', 2),
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
}
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
return {
'n_products': len(prices_df),
'registry_status': 'success',
'store_mode': store_mode,
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
}
return {
'fetch_interactions': fetch_interactions,
'fetch_price_logs': fetch_price_logs,
'compute_demand': compute_demand,
'aggregate_price_logs': aggregate_price_logs,
'join_product_features': join_product_features,
'apply_surge_pricing': apply_surge_pricing,
'publish_results': publish_results,
}
def create_surge_pricing_dag(store_mode: str) -> DAG:
"""Factory: generates a surge pricing DAG for a given store_mode."""
callables = _make_task_callables(store_mode)
dag = DAG(
f'surge_pricing_{store_mode}',
default_args=DEFAULT_ARGS,
description=f'Surge pricing pipeline for {store_mode} store mode',
schedule_interval='*/15 * * * *',
start_date=days_ago(1),
catchup=False,
max_active_runs=1,
tags=['pricing', 'surge', 'research', store_mode],
)
with dag:
t_fetch_interactions = PythonOperator(
task_id='fetch_interactions',
python_callable=callables['fetch_interactions'],
provide_context=True,
)
t_fetch_price_logs = PythonOperator(
task_id='fetch_price_logs',
python_callable=callables['fetch_price_logs'],
provide_context=True,
)
t_compute_demand = PythonOperator(
task_id='compute_demand',
python_callable=callables['compute_demand'],
provide_context=True,
)
t_aggregate_prices = PythonOperator(
task_id='aggregate_price_logs',
python_callable=callables['aggregate_price_logs'],
provide_context=True,
)
t_join_features = PythonOperator(
task_id='join_product_features',
python_callable=callables['join_product_features'],
provide_context=True,
)
t_surge_pricing = PythonOperator(
task_id='apply_surge_pricing',
python_callable=callables['apply_surge_pricing'],
provide_context=True,
)
t_publish = PythonOperator(
task_id='publish_results',
python_callable=callables['publish_results'],
provide_context=True,
)
t_fetch_interactions >> t_compute_demand
t_fetch_price_logs >> t_aggregate_prices
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
return dag
# instantiate DAGs for Airflow to discover
dag_airline = create_surge_pricing_dag('airline')
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

@@ -0,0 +1,253 @@
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
import pandas as pd
import logging
import sys
import pickle
import io
# add parent dir to path so procesing package can be imported
sys.path.insert(0, '/opt/airflow')
from procesing.context import PipelineContext
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.steps import (
FetchInteractionsStep,
FetchPriceLogsStep,
ComputeDemandStep,
AggregatePriceLogsStep,
JoinProductFeaturesStep,
)
from procesing.pricers.simple import SimpleSurgePricer
default_args = {
'owner': 'phantom-research',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 2,
'retry_delay': timedelta(minutes=5),
}
def get_provider():
"""Factory to create composite provider"""
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
def __init__(self):
SupabaseProvider.__init__(self)
BackendAPIProvider.__init__(self)
return CompositeProvider()
def get_context(**kwargs):
"""Build pipeline context from Airflow config"""
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
return PipelineContext(
provider=get_provider(),
store_mode=dag_conf.get('store_mode', 'hotel'),
)
# atomic task functions (each wraps one sklearn step)
def fetch_interactions(**kwargs):
"""Task: Fetch interaction data from Kafka"""
context = get_context(**kwargs)
step = FetchInteractionsStep(context)
df = step.transform(None)
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
logging.info(f"Fetched {len(df)} interaction records")
return len(df)
def fetch_price_logs(**kwargs):
"""Task: Fetch price logs from Kafka"""
context = get_context(**kwargs)
step = FetchPriceLogsStep(context)
df = step.transform(None)
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
logging.info(f"Fetched {len(df)} price records")
return len(df)
def compute_demand(**kwargs):
"""Task: Compute demand scores from interactions"""
ti = kwargs['ti']
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
context = get_context(**kwargs)
step = ComputeDemandStep(context)
demand_df = step.transform(df)
# TODO: clear the xcom
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
logging.info(f"Computed demand for {len(demand_df)} products")
return len(demand_df)
def aggregate_price_logs(**kwargs):
"""Task: Aggregate price logs"""
ti = kwargs['ti']
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
context = get_context(**kwargs)
step = AggregatePriceLogsStep(context)
price_df = step.transform(df)
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
logging.info(f"Aggregated price logs for {len(price_df)} products")
return len(price_df)
def join_product_features(**kwargs):
"""Task: Join demand and price data"""
ti = kwargs['ti']
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
context = get_context(**kwargs)
step = JoinProductFeaturesStep(context)
joined_df = step.transform((demand_df, price_df))
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
logging.info(f"Joined features for {len(joined_df)} products")
return len(joined_df)
def apply_surge_pricing(**kwargs):
"""Task: Apply surge pricing rules to generate optimal prices"""
ti = kwargs['ti']
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
# rename demand_score to demand for pricer compatibility
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(
high_threshold=high_thresh,
low_threshold=low_thresh,
surge_multiplier=surge_mult,
discount_multiplier=discount_mult
)
surge_pricer.fit(data)
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={
'price': 'current_price',
'demand': 'demand_score'
})
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
logging.info(f"Applied surge pricing for {len(prices_df)} products")
return len(prices_df)
def publish_results(**kwargs):
"""Task: Publish surge pricing results to registry"""
ti = kwargs['ti']
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
sys.path.insert(0, '/opt/airflow')
from lib.model_registry import ModelRegistry
registry = ModelRegistry()
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
metadata = {
'timestamp': pd.Timestamp.now().isoformat(),
'store_mode': dag_conf.get('store_mode', 'hotel'),
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
'pricing_method': 'surge',
'high_threshold': dag_conf.get('high_threshold', 10),
'low_threshold': dag_conf.get('low_threshold', 2),
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
}
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
logging.info(f"Published surge pricing for {len(prices_df)} products")
return {
'n_products': len(prices_df),
'registry_status': 'success',
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
}
# DAG definition
with DAG(
'surge_pricing_pipeline',
default_args=default_args,
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
schedule_interval='*/15 * * * *',
start_date=days_ago(1),
catchup=False,
max_active_runs=1,
tags=['pricing', 'surge', 'research', 'simplified'],
) as dag:
# parallel data fetching
t_fetch_interactions = PythonOperator(
task_id='fetch_interactions',
python_callable=fetch_interactions,
provide_context=True,
)
t_fetch_price_logs = PythonOperator(
task_id='fetch_price_logs',
python_callable=fetch_price_logs,
provide_context=True,
)
# compute demand from interactions
t_compute_demand = PythonOperator(
task_id='compute_demand',
python_callable=compute_demand,
provide_context=True,
)
# aggregate price logs
t_aggregate_prices = PythonOperator(
task_id='aggregate_price_logs',
python_callable=aggregate_price_logs,
provide_context=True,
)
# join demand and prices
t_join_features = PythonOperator(
task_id='join_product_features',
python_callable=join_product_features,
provide_context=True,
)
# apply surge pricing
t_surge_pricing = PythonOperator(
task_id='apply_surge_pricing',
python_callable=apply_surge_pricing,
provide_context=True,
)
# publish to registry
t_publish = PythonOperator(
task_id='publish_results',
python_callable=publish_results,
provide_context=True,
)
# dependency graph: parallel fetch -> process -> join -> surge -> publish
t_fetch_interactions >> t_compute_demand
t_fetch_price_logs >> t_aggregate_prices
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish

View File

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

212
experiments/ml/arch.py Normal file
View File

@@ -0,0 +1,212 @@
# sklearn compatible models for agent detection
from sklearn.base import BaseEstimator, ClassifierMixin
from typing import Any, Optional, Tuple, Dict, List
from abc import ABC, abstractmethod
from collections import defaultdict
import numpy as np
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'
LABELS = ['human', 'agent']
class WeakClassifier(BaseEstimator, ClassifierMixin, ABC):
# 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
class TrajectoryEncoder(nn.Module):
"""Encode variable-length event sequences to fixed-dim embedding via bidirectional LSTM"""
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.max_depth = max_depth
self.learning_rate = learning_rate
self.model = None
self.kwargs = kwargs
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
def predict(self, X: np.ndarray) -> np.ndarray:
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:
if self.model is None:
raise ValueError("fit the model first")
return self.model.predict_proba(X)
class LightGBMAgentClassifier(BaseEstimator, ClassifierMixin):
"""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 fit(self, X: np.ndarray, y: np.ndarray):
try:
import lightgbm as lgb
self.model = lgb.LGBMClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
learning_rate=self.learning_rate, verbose=-1, **self.kwargs)
self.model.fit(X, y)
except ImportError:
raise ImportError("lightgbm required for LightGBMAgentClassifier")
return self
def predict(self, X: np.ndarray) -> np.ndarray:
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:
if self.model is None:
raise ValueError("fit the model first")
return self.model.predict_proba(X)

View File

@@ -0,0 +1 @@
from .encoder import Window, extract_windows, build_windows, WindowDataset, PrototypeClassifier, train, loocv

View File

@@ -0,0 +1,210 @@
"""Contrastive encoder via trajectory windowing. Classification by prototype distance."""
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 JointLoader, PayloadModel
from arch import TrajectoryEncoder, featurize_trajectory, nt_xent_loss
from typing import List, Dict, Tuple
from dataclasses import dataclass
from datetime import datetime
import numpy as np, torch, torch.nn.functional as F, random, optuna
from torch.utils.data import Dataset, DataLoader
from torch.optim import Adam
from torch.utils.tensorboard import SummaryWriter
RUNS = "/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/"
@dataclass
class Window:
events: List[PayloadModel]
traj_id: str
label: int # 0=human, 1=agent
def extract_windows(events: List[PayloadModel], traj_id: str, label: int,
sizes: List[int] = [5, 10, 15], stride: int = 2) -> List[Window]:
"""Multi-scale overlapping windows from trajectory"""
n = len(events)
wins = [Window(events[i:i+s], traj_id, label) for s in sizes if n >= s for i in range(0, n-s+1, stride)]
if n >= 3: wins.append(Window(events, traj_id, label)) # full traj
return wins
def build_windows(data: Dict[str, List], sizes=[5,10,15], stride=2) -> List[Window]:
return [w for tid, evts in data.items()
for w in extract_windows(evts, tid, 0 if tid.startswith('human_') else 1, sizes, stride)]
class WindowDataset(Dataset):
"""Yields (anchor, positive) pairs from same class"""
def __init__(self, windows: List[Window], dim: int = 64):
self.wins, self.dim = windows, dim
self.by_label = {0: [i for i,w in enumerate(windows) if w.label==0],
1: [i for i,w in enumerate(windows) if w.label==1]}
self.by_traj = {}
for i, w in enumerate(windows): self.by_traj.setdefault(w.traj_id, []).append(i)
def __len__(self): return len(self.wins)
def _feat(self, evts): return featurize_trajectory(evts, None, self.dim)
def _aug(self, evts): # subsample 70-100%
if len(evts) < 4: return evts
k = max(3, int(len(evts) * random.uniform(0.7, 1.0)))
start = random.randint(0, len(evts) - k)
return evts[start:start+k]
def __getitem__(self, idx):
w = self.wins[idx]
pool = [i for i in self.by_label[w.label] if self.wins[i].traj_id != w.traj_id]
pos_idx = random.choice(pool) if pool else idx
a = torch.tensor(self._feat(self._aug(w.events)), dtype=torch.float32)
p = torch.tensor(self._feat(self._aug(self.wins[pos_idx].events)), dtype=torch.float32)
return a, p, w.label
class PrototypeClassifier:
"""Classify by distance to class centroids"""
def __init__(self, encoder: TrajectoryEncoder, device = 'cuda', dim=64):
self.enc, self.dev, self.dim = encoder, device, dim
self.centroids = {0: None, 1: None}
def fit(self, windows: List[Window]):
self.enc.eval()
embs = {0: [], 1: []}
with torch.no_grad():
for w in windows:
x = torch.tensor(featurize_trajectory(w.events, None, self.dim), dtype=torch.float32)
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
embs[w.label].append(z)
self.centroids = {k: torch.cat(v).mean(0, keepdim=True) if v else None for k, v in embs.items()}
return self
def predict(self, events: List[PayloadModel]) -> Tuple[int, float, Dict]:
"""Returns (pred, confidence, debug). Confidence via softmax over -distances."""
self.enc.eval()
with torch.no_grad():
x = torch.tensor(featurize_trajectory(events, None, self.dim), dtype=torch.float32)
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
dists = {k: torch.norm(z - c, dim=1).item() for k, c in self.centroids.items() if c is not None}
if not dists: return 0, 0.0, {'d': {}, 'p': [0.5, 0.5]}
pred = min(dists, key=dists.get)
d0, d1 = dists.get(0, 1e6), dists.get(1, 1e6) # softmax(-d) gives higher prob to closer centroid
probs = F.softmax(torch.tensor([[-d0, -d1]]), dim=1).squeeze()
return pred, probs[pred].item(), {'d': dists, 'p': probs.tolist()}
def train(epochs=200, lr=5e-4, batch=16, dim=64, emb=32, temp=0.5,
sizes=[5,10,15], stride=2, name=None, verbose=True):
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
wins = build_windows(data, sizes, stride)
if verbose: print(f"Windows: {len(wins)} ({sum(w.label==0 for w in wins)}h/{sum(w.label==1 for w in wins)}a)")
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
enc = TrajectoryEncoder(dim, emb).to(dev)
opt = Adam(enc.parameters(), lr=lr)
loader = DataLoader(WindowDataset(wins, dim), batch_size=batch, shuffle=True, drop_last=True)
name = name or f"enc_{dim}_{emb}_{datetime.now():%Y%m%d_%H%M%S}"
writer = SummaryWriter(f"{RUNS}/encoder/{name}")
for ep in range(epochs):
enc.train()
total, n = 0.0, 0
for a, p, _ in loader:
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
opt.zero_grad(); loss.backward(); opt.step()
total += loss.item(); n += 1
avg = total / max(n, 1)
writer.add_scalar('loss-ntxent', avg, ep)
if verbose and (ep+1) % 20 == 0: print(f"Epoch {ep+1}: {avg:.4f}")
writer.close()
return enc, wins, dev
def loocv(epochs=100, lr=5e-4, dim=64, emb=32, temp=0.5, sizes=[5,10,15], stride=2, verbose=True):
"""Leave-one-trajectory-out CV"""
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
results = []
for test_id in data:
train_data = {k: v for k, v in data.items() if k != test_id}
if not any(k.startswith('human_') for k in train_data) or not any(k.startswith('agent_') for k in train_data):
continue
wins = build_windows(train_data, sizes, stride)
enc = TrajectoryEncoder(dim, emb).to(dev)
opt = Adam(enc.parameters(), lr=lr)
loader = DataLoader(WindowDataset(wins, dim), batch_size=min(16, len(wins)//2 or 1),
shuffle=True, drop_last=len(wins)>2)
for _ in range(epochs):
enc.train()
for a, p, _ in loader:
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
opt.zero_grad(); loss.backward(); opt.step()
clf = PrototypeClassifier(enc, dev, dim).fit(wins)
pred, conf, dbg = clf.predict(data[test_id])
actual = 0 if test_id.startswith('human_') else 1
results.append((pred, actual, conf))
if verbose: print(f"{test_id[:18]}: pred={pred} conf={conf:.2f} actual={actual} {'OK' if pred==actual else 'MISS'}")
if results:
acc = sum(p==a for p,a,_ in results) / len(results)
if verbose: print(f"\nAccuracy: {acc:.1%} ({sum(p==a for p,a,_ in results)}/{len(results)})")
return acc, results
return 0.0, []
def hparam_tune(n_trials=50, epochs=60, n_jobs=2, verbose=True):
"""Optuna hyperparameter search maximizing LOOCV accuracy"""
def objective(trial):
lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True)
dim = trial.suggest_categorical('dim', [32, 64, 128, 256])
emb = trial.suggest_categorical('emb', [16, 32, 64, 128])
temp = trial.suggest_float('temp', 0.05, 1.0)
stride = trial.suggest_int('stride', 1, 4)
sizes = [trial.suggest_int(f's{i}', 3, 20) for i in range(3)]
sizes = sorted(set(sizes)) # unique sorted
acc, _ = loocv(epochs, lr, dim, emb, temp, sizes, stride, verbose=False)
return acc
study = optuna.create_study(direction='maximize', study_name='encoder_hparam',
sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=verbose)
best = study.best_params
if verbose:
print(f"\nBest accuracy: {study.best_value:.1%}")
print(f"Best params: {best}")
return best, study
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['train', 'eval', 'hparam'], default='train')
p.add_argument('--epochs', type=int, default=200)
p.add_argument('--lr', type=float, default=5e-4)
p.add_argument('--dim', type=int, default=128)
p.add_argument('--emb', type=int, default=64)
p.add_argument('--temp', type=float, default=0.1)
p.add_argument('--sizes', type=str, default='5,10,15')
p.add_argument('--stride', type=int, default=2)
p.add_argument('--n_trials', type=int, default=50)
args = p.parse_args()
sizes = [int(x) for x in args.sizes.split(',')]
if args.mode == 'train':
enc, wins, dev = train(args.epochs, args.lr, 16, args.dim, args.emb, args.temp, sizes, args.stride)
elif args.mode == 'hparam':
best, study = hparam_tune(args.n_trials, min(args.epochs, 60))
else:
loocv(args.epochs, args.lr, args.dim, args.emb, args.temp, sizes, args.stride)

103
experiments/ml/evals.py Normal file
View File

@@ -0,0 +1,103 @@
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, confusion_matrix, roc_curve)
from torch.utils.tensorboard import SummaryWriter
from logging import getLogger
import numpy as np
import matplotlib.pyplot as plt
import io
from PIL import Image
logger = getLogger(__name__)
def log_feature_importance(writer, model, feature_names, epoch):
"""Visualize and log feature importance to TensorBoard"""
if not hasattr(model, 'feature_importances_') or model.feature_importances_ is None:
return
importance = model.feature_importances_
indices = np.argsort(importance)[::-1][:20] # top 20
top_features = [feature_names[i] for i in indices]
top_importance = importance[indices]
for i, (feat, imp) in enumerate(zip(top_features, top_importance)):
writer.add_scalar(f'FeatureImportance/{feat}', imp, epoch)
fig, ax = plt.subplots(figsize=(10, 8))
ax.barh(range(len(top_features)), top_importance, align='center')
ax.set_yticks(range(len(top_features)))
ax.set_yticklabels(top_features)
ax.invert_yaxis()
ax.set_xlabel('Importance')
ax.set_title(f'Top 20 Feature Importance (Epoch {epoch})')
ax.grid(axis='x', alpha=0.3)
buf = io.BytesIO()
plt.tight_layout()
plt.savefig(buf, format='png', dpi=100)
buf.seek(0)
img = Image.open(buf)
img_arr = np.array(img)
writer.add_image('FeatureImportance/Chart', img_arr, epoch, dataformats='HWC')
plt.close()
def evaluate(perdicted_class, predicted_proba, true_class, writer: SummaryWriter, epoch: int):
accuracy = accuracy_score(true_class, perdicted_class)
precision = precision_score(true_class, perdicted_class, zero_division=0)
recall = recall_score(true_class, perdicted_class, zero_division=0)
f1 = f1_score(true_class, perdicted_class, zero_division=0)
roc_auc = roc_auc_score(true_class, predicted_proba)
writer.add_scalar('Eval/Accuracy', accuracy, epoch)
writer.add_scalar('Eval/Precision', precision, epoch)
writer.add_scalar('Eval/Recall', recall, epoch)
writer.add_scalar('Eval/F1_Score', f1, epoch)
writer.add_scalar('Eval/ROC_AUC', roc_auc, epoch)
# confusion matrix
cm = confusion_matrix(true_class, perdicted_class)
tn, fp, fn, tp = cm.ravel()
writer.add_scalar('Eval/TrueNeg', tn, epoch)
writer.add_scalar('Eval/FalsePos', fp, epoch)
writer.add_scalar('Eval/FalseNeg', fn, epoch)
writer.add_scalar('Eval/TruePos', tp, epoch)
# specificity and sensitivity
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
sensitivity = recall # same as recall/TPR
writer.add_scalar('Eval/Specificity', specificity, epoch)
writer.add_scalar('Eval/Sensitivity', sensitivity, epoch)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.matshow(cm, cmap='Blues', alpha=0.7)
for i in range(2):
for j in range(2):
ax1.text(j, i, str(cm[i, j]), ha='center', va='center', fontsize=14)
ax1.set_xlabel('Predicted')
ax1.set_ylabel('True')
ax1.set_title(f'Confusion Matrix (Epoch {epoch})')
ax1.set_xticks([0, 1])
ax1.set_yticks([0, 1])
ax1.set_xticklabels(['Human', 'Agent'])
ax1.set_yticklabels(['Human', 'Agent'])
# ROC curve
fpr, tpr, _ = roc_curve(true_class, predicted_proba)
ax2.plot(fpr, tpr, label=f'AUC={roc_auc:.3f}', linewidth=2)
ax2.plot([0, 1], [0, 1], 'k--', label='Random')
ax2.set_xlabel('False Positive Rate')
ax2.set_ylabel('True Positive Rate')
ax2.set_title('ROC Curve')
ax2.legend()
ax2.grid(alpha=0.3)
buf = io.BytesIO()
plt.tight_layout()
plt.savefig(buf, format='png', dpi=100)
buf.seek(0)
img = Image.open(buf)
img_arr = np.array(img)
writer.add_image('Eval/Metrics', img_arr, epoch, dataformats='HWC')
plt.close()
logger.info(f"Eval {epoch}: Acc={accuracy:.4f} Prec={precision:.4f} Rec={recall:.4f} F1={f1:.4f} AUC={roc_auc:.4f}")

View File

@@ -0,0 +1,6 @@
torch
tensorboard
fastparquet
pyarrow
xgboost
lightgbm

137
experiments/ml/train.py Normal file
View File

@@ -0,0 +1,137 @@
from torch.utils.tensorboard import SummaryWriter
from sklearn.model_selection import train_test_split
from logging import getLogger
from pathlib import Path
import pandas as pd
import numpy as np
import joblib
from datetime import datetime
from ml.evals import evaluate, log_feature_importance
from ml.arch import XGBoostAgentClassifier, LightGBMAgentClassifier, LABELS
logger = getLogger(__name__)
FEATURE_COLS_EXCLUDE = ['sessionId', 'experimentId', 'is_agent', 'xp_human_only', 'xp_market_mode', 'browser_family']
RUNS_DIR = Path('ml/runs')
CHECKPOINTS_DIR = Path('ml/checkpoints')
def prepare_data(df):
"""
Prepare feature matrix and labels from raw dataframe
Handles missing labels, feature selection, and categorical encoding
Returns: (X, y, feature_cols)
"""
# drop rows with missing labels
n_before = len(df)
df = df[df['is_agent'].notna()].copy()
n_dropped = n_before - len(df)
if n_dropped > 0:
logger.warning(f"Dropped {n_dropped} sessions with missing labels")
if len(df) == 0:
logger.error("No labeled data available")
return None, None, None
feature_cols = [c for c in df.columns if c not in FEATURE_COLS_EXCLUDE]
# handle categorical browser_family via one-hot encoding
if 'browser_family' in df.columns:
browser_dummies = pd.get_dummies(df['browser_family'], prefix='browser', drop_first=True)
df = pd.concat([df, browser_dummies], axis=1)
feature_cols.extend(browser_dummies.columns.tolist())
X = df[feature_cols].fillna(0)
y = df['is_agent'].astype(int)
return X, y, feature_cols
def train(data_path=None, model_type='xgboost', test_size=0.2, random_state=42,
n_estimators=200, max_depth=6, learning_rate=0.05):
"""
Train agent detection classifier
Args:
data_path: path to labeled feature matrix CSV or parquet
model_type: 'xgboost' or 'lightgbm'
test_size: fraction for test split
random_state: seed for reproducibility
"""
RUNS_DIR.mkdir(exist_ok=True)
CHECKPOINTS_DIR.mkdir(exist_ok=True)
run_name = f"{model_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
writer = SummaryWriter(log_dir=RUNS_DIR / run_name)
logger.info(f"Starting training run: {run_name}")
# load data
if data_path is None:
logger.error("data_path required")
return
df = pd.read_parquet(data_path)
logger.info(f"Loaded {len(df)} sessions from {data_path}")
# prepare features and labels
if 'is_agent' not in df.columns:
logger.error("Missing is_agent column")
return
X, y, feature_cols = prepare_data(df)
if X is None:
return
# class distribution
n_agents = y.sum()
n_humans = (y == 0).sum()
logger.info(f"Class distribution: {n_humans} humans, {n_agents} agents" + (f" (ratio {n_humans / n_agents:.2f})" if n_agents > 0 else ""))
# train/test split with stratification
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=test_size, random_state=random_state, stratify=y
)
logger.info(f"Train: {len(X_train)}, Test: {len(X_test)}")
# init model
if model_type == 'xgboost':
model = XGBoostAgentClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate
)
elif model_type == 'lightgbm':
model = LightGBMAgentClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate
)
else:
logger.error(f"Unknown model type: {model_type}")
return
# train with eval set for early stopping
model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
logger.info("Training complete")
# evaluate on test set
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]
evaluate(y_pred, y_prob, y_test, writer, epoch=0)
# log feature importance
log_feature_importance(writer, model, X.columns.tolist(), epoch=0)
# save model
model_path = CHECKPOINTS_DIR / f"{run_name}.pkl"
joblib.dump({'model': model, 'feature_cols': X.columns.tolist(), 'run_name': run_name}, model_path)
logger.info(f"Model saved to {model_path}")
writer.close()
return model, X.columns.tolist()
if __name__ == "__main__":
import sys
data_path = sys.argv[1]
model_type = sys.argv[2] if len(sys.argv) > 2 else 'xgboost'
train(data_path, model_type=model_type)

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)

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