mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
1 Commits
pre-run-we
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ac2cb609 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,3 @@ paper/src/bib/auto
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
tests/e2e/node_modules/**
|
||||
**/auto/*.el
|
||||
*.old
|
||||
|
||||
54
Makefile
54
Makefile
@@ -11,74 +11,46 @@ PYTEST := $(VENV)/bin/pytest
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
|
||||
all: pdf
|
||||
|
||||
run.webapp:
|
||||
@cd web && npm install && npm run dev
|
||||
|
||||
$(BUILDDIR):
|
||||
mkdir -p paper/$(BUILDDIR)
|
||||
|
||||
.PHONY: pdf.build
|
||||
pdf.build: $(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.watch
|
||||
pdf.watch: $(BUILDDIR)
|
||||
watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
.PHONY: pdf.clean
|
||||
pdf.clean:
|
||||
clean:
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
rm -rf paper/$(BUILDDIR)/*
|
||||
|
||||
.PHONY: test.backend
|
||||
test.backend: $(VENV)
|
||||
$(PYTEST) -v
|
||||
|
||||
.PHONY: test.e2e
|
||||
test.e2e:
|
||||
@cd tests/e2e && npm install
|
||||
@cd tests/e2e && npx playwright install chromium
|
||||
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
|
||||
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
|
||||
@cd tests/e2e && npm test
|
||||
|
||||
.PHONY: test.all
|
||||
test.all: test.backend test.e2e
|
||||
|
||||
.PHONY: web.dev
|
||||
web.dev:
|
||||
@cd web && npm install && npm run dev
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
.PHONY: install
|
||||
install: $(VENV)
|
||||
$(PIP) install -r requirements.txt
|
||||
|
||||
.PHONY: stats.lines
|
||||
stats.lines:
|
||||
test: $(VENV)
|
||||
$(PYTEST) -v
|
||||
|
||||
count-lines:
|
||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
||||
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||
|
||||
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||
pdf: pdf.build
|
||||
clean: pdf.clean
|
||||
watch: pdf.watch
|
||||
run.webapp: web.dev
|
||||
test: test.backend
|
||||
count-lines: stats.lines
|
||||
all: pdf.build
|
||||
.PHONY: all pdf clean watch run.webapp install test
|
||||
|
||||
@@ -47,52 +47,53 @@ def health() -> dict:
|
||||
|
||||
@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)
|
||||
# fetch pre-computed prices from registry
|
||||
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'
|
||||
)
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
if prices_df is None:
|
||||
# fallback: no pre-computed prices available
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
# lookup pre-computed price for this product
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if product_price_row.empty:
|
||||
# product not in pre-computed prices, fallback to base
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
||||
|
||||
# get elasticity if available
|
||||
product_elasticity = None
|
||||
if elasticity_df is not None:
|
||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||
if not product_elasticity_row.empty:
|
||||
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
|
||||
|
||||
# PRIORITY 3: fallback to base price
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None,
|
||||
model_version='base'
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=product_elasticity
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
|
||||
@@ -198,16 +198,12 @@ def dump_logs(
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=30000,
|
||||
fetch_max_wait_ms=10000,
|
||||
max_poll_records=1000
|
||||
consumer_timeout_ms=5000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if last_n and len(events) >= last_n * 2:
|
||||
break
|
||||
|
||||
consumer.close()
|
||||
|
||||
|
||||
161
docker-compose.e2e.yml
Normal file
161
docker-compose.e2e.yml
Normal file
@@ -0,0 +1,161 @@
|
||||
# Docker Compose configuration for E2E testing
|
||||
# Usage: docker compose -f docker-compose.e2e.yml up -d
|
||||
#
|
||||
# This configuration runs only the services needed for E2E pricing tests:
|
||||
# - Backend API (event ingestion)
|
||||
# - Kafka + Zookeeper (event streaming)
|
||||
# - Redis (model registry)
|
||||
# - Pricing Provider (price serving)
|
||||
#
|
||||
# Excluded for E2E tests:
|
||||
# - Airflow (pipeline runs directly via test worker)
|
||||
# - PostgreSQL (not needed without Airflow)
|
||||
# - TensorBoard (ML visualization not needed)
|
||||
|
||||
services:
|
||||
# Backend API for event ingestion
|
||||
backend:
|
||||
container_name: "PHANTOM-e2e-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
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:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis for model registry
|
||||
redis:
|
||||
container_name: "PHANTOM-e2e-redis"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Redis.dockerfile
|
||||
ports:
|
||||
- "${REDIS_PORT:-6378}:6379"
|
||||
volumes:
|
||||
- e2e_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Zookeeper for Kafka coordination
|
||||
zookeeper:
|
||||
container_name: "PHANTOM-e2e-zookeeper"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Zookeeper.dockerfile
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
- "2181:2181"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Kafka for event streaming
|
||||
kafka:
|
||||
container_name: "PHANTOM-e2e-kafka"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Kafka.dockerfile
|
||||
depends_on:
|
||||
zookeeper:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
||||
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||
# Faster topic creation for tests
|
||||
KAFKA_NUM_PARTITIONS: 1
|
||||
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
|
||||
ports:
|
||||
- "${KAFKA_PORT:-9092}:9092"
|
||||
volumes:
|
||||
- e2e_kafka_data:/var/lib/kafka/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Redpanda Console for Kafka debugging (optional)
|
||||
redpanda-console:
|
||||
container_name: "PHANTOM-e2e-redpanda-console"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: RedpandaConsole.dockerfile
|
||||
depends_on:
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KAFKA_BROKERS: kafka:29092
|
||||
ports:
|
||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- debug # Only start with --profile debug
|
||||
|
||||
# Pricing Provider for serving prices
|
||||
pricing-provider:
|
||||
container_name: "PHANTOM-e2e-pricing-provider"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Provider.dockerfile
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
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://backend:5000
|
||||
ports:
|
||||
- "${PROVIDER_PORT:-5001}:5001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
e2e_kafka_data:
|
||||
e2e_redis_data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: phantom-e2e-network
|
||||
@@ -1,17 +1,8 @@
|
||||
services:
|
||||
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:
|
||||
tensorboard:
|
||||
image: tensorflow/tensorflow:latest
|
||||
container_name: "PHANTOM-tensorboard-ml"
|
||||
container_name: "PHANTOM-tensorboard"
|
||||
ports:
|
||||
- "6006:6006"
|
||||
volumes:
|
||||
@@ -112,14 +103,11 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
@@ -139,20 +127,14 @@ services:
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
@@ -182,20 +164,13 @@ services:
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
|
||||
255
e2e/README.md
Normal file
255
e2e/README.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# PHANTOM Dynamic Pricing E2E Test Suite
|
||||
|
||||
End-to-end tests validating the dynamic pricing pipeline, including SimpleSurgePricer and SessionAwarePricer functionality.
|
||||
|
||||
## System Under Test (SUT)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ PHANTOM Pricing Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Test Runner │───▶│ Backend API │───▶│ Kafka (user-interactions)│ │
|
||||
│ │ (Playwright)│ │ POST /ingest │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────┬─────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ Pipeline Worker │ │
|
||||
│ │ │ - Fetch interactions │ │
|
||||
│ │ │ - Compute demand │ │
|
||||
│ │ │ - Apply surge pricing │ │
|
||||
│ │ └────────────┬─────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ Redis (Model Registry) │ │
|
||||
│ │ │ - prices:latest │ │
|
||||
│ │ └────────────┬─────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ └────▶│ Pricing API │◀──────────│ Pricing Provider │ │
|
||||
│ │ GET /price │ │ (serves from Redis) │ │
|
||||
│ └──────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Scenario | Description | Expected Outcome |
|
||||
|----------|-------------|------------------|
|
||||
| **Baseline** | No interactions for product | Price = base_price (markup = 1.0) |
|
||||
| **Surge** | 5+ interactions (above threshold) | Price = base_price × 1.5 |
|
||||
| **Discount** | 1 interaction (at threshold) | Price = base_price × 0.9 |
|
||||
| **Multi-Product** | Different demand per product | Each product priced by its demand |
|
||||
| **Propagation** | Pipeline → Redis → API | Prices visible via API |
|
||||
| **Event Types** | Mix of view, click, cart | All events counted in demand |
|
||||
| **Multi-Session** | Events from different sessions | Demand aggregated correctly |
|
||||
|
||||
## Test Configuration
|
||||
|
||||
The tests use aggressive thresholds for fast feedback:
|
||||
|
||||
```typescript
|
||||
pricing: {
|
||||
highThreshold: 3, // Surge after 3 interactions
|
||||
lowThreshold: 1, // Discount at ≤1 interaction
|
||||
surgeMultiplier: 1.5, // 50% price increase
|
||||
discountMultiplier: 0.9, // 10% discount
|
||||
windowSize: 10_000, // 10 second window
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start E2E Services
|
||||
|
||||
```bash
|
||||
# Start minimal services for E2E testing
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
docker compose -f docker-compose.e2e.yml ps
|
||||
|
||||
# Optional: Start with Kafka UI for debugging
|
||||
docker compose -f docker-compose.e2e.yml --profile debug up -d
|
||||
```
|
||||
|
||||
### 2. Install Test Dependencies
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### 3. Run Tests
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm test
|
||||
|
||||
# Run with UI (interactive mode)
|
||||
npm run test:ui
|
||||
|
||||
# Run specific test file
|
||||
npm run test:pricing
|
||||
|
||||
# Run in debug mode
|
||||
npm run test:debug
|
||||
|
||||
# View test report
|
||||
npm run test:report
|
||||
```
|
||||
|
||||
### 4. Cleanup
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BACKEND_URL` | `http://localhost:5000` | Backend API URL |
|
||||
| `PROVIDER_URL` | `http://localhost:5001` | Pricing Provider URL |
|
||||
| `REDIS_HOST` | `localhost` | Redis host |
|
||||
| `REDIS_PORT` | `6378` | Redis port |
|
||||
| `KAFKA_HOST` | `localhost` | Kafka host |
|
||||
| `KAFKA_PORT` | `9092` | Kafka port |
|
||||
|
||||
## Test Architecture
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
├── global-setup.ts # Service health checks
|
||||
├── global-teardown.ts # Cleanup
|
||||
├── package.json # Dependencies and scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── lib/
|
||||
│ ├── api-client.ts # API interaction utilities
|
||||
│ ├── event-generator.ts # Test event factory
|
||||
│ ├── pipeline-runner.ts # TypeScript pipeline wrapper
|
||||
│ ├── pipeline-worker.py # Python pipeline executor
|
||||
│ ├── fixtures.ts # Playwright test fixtures
|
||||
│ └── index.ts # Re-exports
|
||||
└── tests/
|
||||
└── dynamic-pricing.spec.ts # Main test file
|
||||
```
|
||||
|
||||
## Pipeline Worker
|
||||
|
||||
The tests use a dedicated Python pipeline worker (`lib/pipeline-worker.py`) instead of Airflow for faster, more reliable test execution.
|
||||
|
||||
```bash
|
||||
# Run pipeline manually
|
||||
python3 lib/pipeline-worker.py \
|
||||
--store-mode hotel \
|
||||
--high-threshold 3 \
|
||||
--surge-multiplier 1.5 \
|
||||
--json-output
|
||||
|
||||
# Dry run (no Redis publish)
|
||||
python3 lib/pipeline-worker.py --dry-run
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View Kafka Events
|
||||
|
||||
```bash
|
||||
# Via API
|
||||
curl "http://localhost:5000/api/kafka/dump?topic=user-interactions&last_n=10"
|
||||
|
||||
# Via Redpanda Console (if started with --profile debug)
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
### Check Redis State
|
||||
|
||||
```bash
|
||||
docker exec -it PHANTOM-e2e-redis redis-cli
|
||||
> GET prices:latest
|
||||
> KEYS *
|
||||
```
|
||||
|
||||
### View Pipeline Logs
|
||||
|
||||
The pipeline worker logs detailed information:
|
||||
|
||||
```
|
||||
[INFO] Starting E2E pricing pipeline: mode=hotel, high_threshold=3, surge_multiplier=1.5
|
||||
[INFO] Fetched 15 interaction records
|
||||
[INFO] Computed demand for 3 products
|
||||
[INFO] Applied surge pricing:
|
||||
e2e-test...: base=$100.00 -> optimal=$150.00 (demand=5, markup=1.50x)
|
||||
[INFO] Published 3 prices to Redis
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../lib/fixtures';
|
||||
import { generateTestProductId } from '../lib/event-generator';
|
||||
|
||||
test('my new pricing test', async ({ api, events, triggerPriceUpdate }) => {
|
||||
// 1. Create unique product ID
|
||||
const productId = generateTestProductId('my-test');
|
||||
|
||||
// 2. Log base price
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 100.0,
|
||||
sessionId: events.session,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// 3. Generate events
|
||||
const surgeEvents = events.generateSurgeSequence(productId, 5);
|
||||
await api.ingestEvents(surgeEvents);
|
||||
|
||||
// 4. Trigger pipeline
|
||||
const result = await triggerPriceUpdate();
|
||||
|
||||
// 5. Verify results
|
||||
expect(result.success).toBe(true);
|
||||
const pricedProduct = result.prices?.find(p => p.productId === productId);
|
||||
expect(pricedProduct?.optimal_price).toBeGreaterThan(100);
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Backend not available"
|
||||
|
||||
Ensure services are running:
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml ps
|
||||
docker compose -f docker-compose.e2e.yml logs backend
|
||||
```
|
||||
|
||||
### "No interactions found"
|
||||
|
||||
Check Kafka topic has events:
|
||||
```bash
|
||||
curl "http://localhost:5000/api/kafka/dump?topic=user-interactions"
|
||||
```
|
||||
|
||||
### "Pipeline timeout"
|
||||
|
||||
Increase timeout in `playwright.config.ts`:
|
||||
```typescript
|
||||
timeout: 180_000, // 3 minutes
|
||||
```
|
||||
|
||||
### "Price not updated"
|
||||
|
||||
Check Redis has latest prices:
|
||||
```bash
|
||||
docker exec -it PHANTOM-e2e-redis redis-cli GET prices:latest
|
||||
```
|
||||
47
e2e/global-setup.ts
Normal file
47
e2e/global-setup.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { testConfig } from './playwright.config';
|
||||
|
||||
/**
|
||||
* Global setup for E2E tests
|
||||
* Verifies all services are healthy before running tests
|
||||
*/
|
||||
async function globalSetup() {
|
||||
console.log('\n🚀 PHANTOM E2E Test Suite - Global Setup\n');
|
||||
|
||||
// Check backend health
|
||||
await checkService('Backend API', `${testConfig.backendUrl}/health`);
|
||||
|
||||
// Check pricing provider health
|
||||
await checkService('Pricing Provider', `${testConfig.providerUrl}/health`);
|
||||
|
||||
console.log('\n✅ All services healthy. Starting tests...\n');
|
||||
}
|
||||
|
||||
async function checkService(name: string, url: string): Promise<void> {
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 2000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`✅ ${name}: healthy`);
|
||||
if (data.redis !== undefined) {
|
||||
console.log(` └─ Redis: ${data.redis ? 'connected' : 'disconnected'}`);
|
||||
}
|
||||
if (data.kafka !== undefined) {
|
||||
console.log(` └─ Kafka: ${data.kafka}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(`❌ ${name} is not available at ${url} after ${maxRetries} attempts`);
|
||||
}
|
||||
console.log(`⏳ Waiting for ${name} (attempt ${attempt}/${maxRetries})...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
10
e2e/global-teardown.ts
Normal file
10
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Global teardown for E2E tests
|
||||
* Cleans up test data and resources
|
||||
*/
|
||||
async function globalTeardown() {
|
||||
console.log('\n🧹 PHANTOM E2E Test Suite - Global Teardown\n');
|
||||
console.log('✅ Cleanup complete\n');
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
191
e2e/lib/api-client.ts
Normal file
191
e2e/lib/api-client.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { testConfig } from '../playwright.config';
|
||||
|
||||
/**
|
||||
* Event payload structure matching the backend API
|
||||
*/
|
||||
export interface EventPayload {
|
||||
sessionId: string;
|
||||
experimentId?: string;
|
||||
eventName: string;
|
||||
page: string;
|
||||
productId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
storeMode: 'hotel' | 'airline';
|
||||
userAgent?: string;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price log payload structure
|
||||
*/
|
||||
export interface PriceLogPayload {
|
||||
productId: string;
|
||||
price: number;
|
||||
sessionId: string;
|
||||
experimentId?: string;
|
||||
storeMode: 'hotel' | 'airline';
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price response from the pricing provider
|
||||
*/
|
||||
export interface PriceResponse {
|
||||
productId: string;
|
||||
price: number;
|
||||
base_price: number;
|
||||
markup: number;
|
||||
elasticity: number | null;
|
||||
model_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API client for interacting with PHANTOM services
|
||||
*/
|
||||
export class PhantomApiClient {
|
||||
private backendUrl: string;
|
||||
private providerUrl: string;
|
||||
|
||||
constructor(
|
||||
backendUrl: string = testConfig.backendUrl,
|
||||
providerUrl: string = testConfig.providerUrl
|
||||
) {
|
||||
this.backendUrl = backendUrl;
|
||||
this.providerUrl = providerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user interaction event to the ingestion API
|
||||
*/
|
||||
async ingestEvent(event: EventPayload): Promise<{ success: boolean }> {
|
||||
const payload: EventPayload = {
|
||||
...event,
|
||||
ts: event.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.backendUrl}/api/kafka/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ingest event: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multiple events in rapid succession
|
||||
*/
|
||||
async ingestEvents(events: EventPayload[], delayMs: number = 100): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.ingestEvent(event);
|
||||
if (delayMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a price observation
|
||||
*/
|
||||
async logPrice(priceLog: PriceLogPayload): Promise<{ success: boolean }> {
|
||||
const payload: PriceLogPayload = {
|
||||
...priceLog,
|
||||
ts: priceLog.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.backendUrl}/api/kafka/price-log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to log price: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current price for a product from the pricing provider
|
||||
*/
|
||||
async getPrice(
|
||||
mode: 'hotel' | 'airline',
|
||||
productId: string,
|
||||
sessionId?: string
|
||||
): Promise<PriceResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (sessionId) {
|
||||
params.set('sessionId', sessionId);
|
||||
}
|
||||
|
||||
const url = `${this.providerUrl}/api/${mode}/price/${productId}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get price: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump events from Kafka topic for debugging
|
||||
*/
|
||||
async dumpKafkaEvents(
|
||||
topic: 'user-interactions' | 'price-logs' = 'user-interactions',
|
||||
lastN?: number
|
||||
): Promise<{ success: boolean; count: number; data: unknown[] }> {
|
||||
const params = new URLSearchParams({ topic });
|
||||
if (lastN) {
|
||||
params.set('last_n', String(lastN));
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.backendUrl}/api/kafka/dump?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to dump Kafka events: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of backend service
|
||||
*/
|
||||
async checkBackendHealth(): Promise<{ status: string; kafka: string }> {
|
||||
const response = await fetch(`${this.backendUrl}/health`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of pricing provider
|
||||
*/
|
||||
async checkProviderHealth(): Promise<{ status: string; redis: boolean }> {
|
||||
const response = await fetch(`${this.providerUrl}/health`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List registered models in the pricing provider
|
||||
*/
|
||||
async listModels(): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${this.providerUrl}/models`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload models in the pricing provider
|
||||
*/
|
||||
async reloadModels(): Promise<{ elasticity_loaded: boolean; pricing_model_loaded: boolean }> {
|
||||
const response = await fetch(`${this.providerUrl}/models/reload`, { method: 'POST' });
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for convenience
|
||||
export const apiClient = new PhantomApiClient();
|
||||
249
e2e/lib/event-generator.ts
Normal file
249
e2e/lib/event-generator.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { EventPayload, PriceLogPayload } from './api-client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Canonical event names matching the frontend
|
||||
*/
|
||||
export const EventNames = {
|
||||
// Navigation events
|
||||
PAGE_VIEW: 'page_view',
|
||||
VIEW_ITEM_PAGE: 'view_item_page',
|
||||
LEARN_MORE: 'learn_more_about_item',
|
||||
|
||||
// Cart events
|
||||
ADD_TO_CART: 'add_item_to_cart',
|
||||
REMOVE_FROM_CART: 'remove_item',
|
||||
CHECKOUT_START: 'checkout_start',
|
||||
PURCHASE_COMPLETE: 'purchase_complete',
|
||||
|
||||
// Search/Filter events
|
||||
SEARCH: 'search',
|
||||
FILTER_DATE: 'filter_for_date',
|
||||
FILTER_AMENITIES: 'filter_for_amenities',
|
||||
FILTER_PRICE: 'filter_for_price',
|
||||
SORT_CHANGE: 'sort_change',
|
||||
|
||||
// Dwell signals (engagement)
|
||||
HOVER_TITLE: 'hover_over_title',
|
||||
HOVER_PARAGRAPH: 'hover_over_paragraph',
|
||||
HOVER_LINK: 'hover_over_link',
|
||||
HOVER_BUTTON: 'hover_over_button',
|
||||
|
||||
// Session
|
||||
SESSION_START: 'session_start',
|
||||
} as const;
|
||||
|
||||
export type EventName = typeof EventNames[keyof typeof EventNames];
|
||||
|
||||
/**
|
||||
* Test product configuration
|
||||
*/
|
||||
export interface TestProduct {
|
||||
id: string;
|
||||
basePrice: number;
|
||||
storeMode: 'hotel' | 'airline';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates test events for dynamic pricing E2E tests
|
||||
*/
|
||||
export class EventGenerator {
|
||||
private sessionId: string;
|
||||
private experimentId: string;
|
||||
private storeMode: 'hotel' | 'airline';
|
||||
|
||||
constructor(options?: {
|
||||
sessionId?: string;
|
||||
experimentId?: string;
|
||||
storeMode?: 'hotel' | 'airline';
|
||||
}) {
|
||||
this.sessionId = options?.sessionId || uuidv4();
|
||||
this.experimentId = options?.experimentId || uuidv4();
|
||||
this.storeMode = options?.storeMode || 'hotel';
|
||||
}
|
||||
|
||||
get session(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get experiment(): string {
|
||||
return this.experimentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for isolation between test scenarios
|
||||
*/
|
||||
newSession(): string {
|
||||
this.sessionId = uuidv4();
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single event
|
||||
*/
|
||||
createEvent(
|
||||
eventName: EventName,
|
||||
productId: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): EventPayload {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
experimentId: this.experimentId,
|
||||
eventName,
|
||||
page: `/${this.storeMode}/products/${productId}`,
|
||||
productId,
|
||||
metadata: metadata || {},
|
||||
storeMode: this.storeMode,
|
||||
userAgent: 'PHANTOM-E2E-Test/1.0',
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a product view event
|
||||
*/
|
||||
viewProduct(productId: string): EventPayload {
|
||||
return this.createEvent(EventNames.VIEW_ITEM_PAGE, productId, {
|
||||
referrer: `/${this.storeMode}/products`,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a "learn more" event (high intent signal)
|
||||
*/
|
||||
learnMore(productId: string): EventPayload {
|
||||
return this.createEvent(EventNames.LEARN_MORE, productId, {
|
||||
section: 'details',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hover event (engagement signal)
|
||||
*/
|
||||
hover(productId: string, element: 'title' | 'paragraph' | 'button' = 'title'): EventPayload {
|
||||
const eventMap = {
|
||||
title: EventNames.HOVER_TITLE,
|
||||
paragraph: EventNames.HOVER_PARAGRAPH,
|
||||
button: EventNames.HOVER_BUTTON,
|
||||
};
|
||||
return this.createEvent(eventMap[element], productId, {
|
||||
duration_ms: Math.floor(Math.random() * 2000) + 500,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an add-to-cart event
|
||||
*/
|
||||
addToCart(productId: string, quantity: number = 1): EventPayload {
|
||||
return this.createEvent(EventNames.ADD_TO_CART, productId, {
|
||||
quantity,
|
||||
cart_size: quantity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sequence of high-velocity events for surge pricing trigger
|
||||
* This simulates rapid user interest in a product
|
||||
*/
|
||||
generateSurgeSequence(productId: string, count: number): EventPayload[] {
|
||||
const events: EventPayload[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Mix of different event types to simulate realistic behavior
|
||||
events.push(this.viewProduct(productId));
|
||||
|
||||
if (i % 2 === 0) {
|
||||
events.push(this.learnMore(productId));
|
||||
}
|
||||
|
||||
if (i % 3 === 0) {
|
||||
events.push(this.hover(productId, 'title'));
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a normal browsing session (not triggering surge)
|
||||
*/
|
||||
generateNormalSession(productId: string): EventPayload[] {
|
||||
return [
|
||||
this.viewProduct(productId),
|
||||
this.hover(productId, 'title'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate high-velocity agent-like behavior
|
||||
* This should trigger SessionAwarePricer's agent detection
|
||||
*/
|
||||
generateAgentBehavior(productIds: string[]): EventPayload[] {
|
||||
const events: EventPayload[] = [];
|
||||
|
||||
// Rapid-fire product views across multiple products
|
||||
for (const productId of productIds) {
|
||||
events.push(this.viewProduct(productId));
|
||||
// Very quick succession - agent-like behavior
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a price log entry
|
||||
*/
|
||||
createPriceLog(productId: string, price: number): PriceLogPayload {
|
||||
return {
|
||||
productId,
|
||||
price,
|
||||
sessionId: this.sessionId,
|
||||
experimentId: this.experimentId,
|
||||
storeMode: this.storeMode,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured test products for E2E tests
|
||||
* These should match products in your test database
|
||||
*/
|
||||
export const TestProducts = {
|
||||
// Hotel products with known base prices
|
||||
hotel1: {
|
||||
id: 'e2e-test-hotel-001',
|
||||
basePrice: 150.00,
|
||||
storeMode: 'hotel' as const,
|
||||
name: 'E2E Test Hotel 1',
|
||||
},
|
||||
hotel2: {
|
||||
id: 'e2e-test-hotel-002',
|
||||
basePrice: 200.00,
|
||||
storeMode: 'hotel' as const,
|
||||
name: 'E2E Test Hotel 2',
|
||||
},
|
||||
hotel3: {
|
||||
id: 'e2e-test-hotel-003',
|
||||
basePrice: 100.00,
|
||||
storeMode: 'hotel' as const,
|
||||
name: 'E2E Test Hotel 3',
|
||||
},
|
||||
|
||||
// Airline products
|
||||
airline1: {
|
||||
id: 'e2e-test-airline-001',
|
||||
basePrice: 350.00,
|
||||
storeMode: 'airline' as const,
|
||||
name: 'E2E Test Flight 1',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique test product ID for isolation
|
||||
*/
|
||||
export function generateTestProductId(prefix: string = 'e2e-test'): string {
|
||||
return `${prefix}-${uuidv4().slice(0, 8)}`;
|
||||
}
|
||||
143
e2e/lib/fixtures.ts
Normal file
143
e2e/lib/fixtures.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { PhantomApiClient, apiClient } from './api-client';
|
||||
import { EventGenerator, TestProducts } from './event-generator';
|
||||
import { runPricingPipeline, waitForPriceUpdate, PipelineResult } from './pipeline-runner';
|
||||
import { testConfig } from '../playwright.config';
|
||||
|
||||
/**
|
||||
* Extended test fixtures for PHANTOM E2E tests
|
||||
*/
|
||||
export interface PhantomTestFixtures {
|
||||
/** API client for interacting with PHANTOM services */
|
||||
api: PhantomApiClient;
|
||||
|
||||
/** Event generator for creating test events */
|
||||
events: EventGenerator;
|
||||
|
||||
/** Run the pricing pipeline and wait for updates */
|
||||
triggerPriceUpdate: (options?: {
|
||||
storeMode?: 'hotel' | 'airline';
|
||||
highThreshold?: number;
|
||||
lowThreshold?: number;
|
||||
surgeMultiplier?: number;
|
||||
discountMultiplier?: number;
|
||||
}) => Promise<PipelineResult>;
|
||||
|
||||
/** Wait for a specific price condition */
|
||||
waitForPrice: (
|
||||
productId: string,
|
||||
condition: (price: number, basePrice: number) => boolean,
|
||||
storeMode?: 'hotel' | 'airline'
|
||||
) => Promise<{ price: number; basePrice: number; markup: number }>;
|
||||
|
||||
/** Test configuration */
|
||||
config: typeof testConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom test with PHANTOM fixtures
|
||||
*/
|
||||
export const test = base.extend<PhantomTestFixtures>({
|
||||
api: async ({}, use) => {
|
||||
await use(apiClient);
|
||||
},
|
||||
|
||||
events: async ({}, use) => {
|
||||
// Create a new event generator with a fresh session for each test
|
||||
const generator = new EventGenerator({
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
await use(generator);
|
||||
},
|
||||
|
||||
triggerPriceUpdate: async ({}, use) => {
|
||||
const trigger = async (options = {}) => {
|
||||
const result = await runPricingPipeline({
|
||||
storeMode: 'hotel',
|
||||
highThreshold: testConfig.pricing.highThreshold,
|
||||
lowThreshold: testConfig.pricing.lowThreshold,
|
||||
surgeMultiplier: testConfig.pricing.surgeMultiplier,
|
||||
discountMultiplier: testConfig.pricing.discountMultiplier,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Wait a moment for Redis to be fully updated
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
await use(trigger);
|
||||
},
|
||||
|
||||
waitForPrice: async ({ api }, use) => {
|
||||
const waiter = async (
|
||||
productId: string,
|
||||
condition: (price: number, basePrice: number) => boolean,
|
||||
storeMode: 'hotel' | 'airline' = 'hotel'
|
||||
) => {
|
||||
let lastPrice = 0;
|
||||
let lastBasePrice = 0;
|
||||
|
||||
const updated = await waitForPriceUpdate(async () => {
|
||||
const priceResponse = await api.getPrice(storeMode, productId);
|
||||
lastPrice = priceResponse.price;
|
||||
lastBasePrice = priceResponse.base_price;
|
||||
return condition(priceResponse.price, priceResponse.base_price);
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`Price condition not met within timeout. Last price: ${lastPrice}, base: ${lastBasePrice}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
price: lastPrice,
|
||||
basePrice: lastBasePrice,
|
||||
markup: lastPrice / lastBasePrice,
|
||||
};
|
||||
};
|
||||
|
||||
await use(waiter);
|
||||
},
|
||||
|
||||
config: async ({}, use) => {
|
||||
await use(testConfig);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
/**
|
||||
* Helper assertions for pricing tests
|
||||
*/
|
||||
export const PricingAssertions = {
|
||||
/**
|
||||
* Assert that a price has surge markup applied
|
||||
*/
|
||||
isSurged: (price: number, basePrice: number, expectedMultiplier: number, tolerance = 0.01) => {
|
||||
const actualMarkup = price / basePrice;
|
||||
const minExpected = expectedMultiplier * (1 - tolerance);
|
||||
const maxExpected = expectedMultiplier * (1 + tolerance);
|
||||
return actualMarkup >= minExpected && actualMarkup <= maxExpected;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a price has discount applied
|
||||
*/
|
||||
isDiscounted: (price: number, basePrice: number, expectedMultiplier: number, tolerance = 0.01) => {
|
||||
const actualMarkup = price / basePrice;
|
||||
const minExpected = expectedMultiplier * (1 - tolerance);
|
||||
const maxExpected = expectedMultiplier * (1 + tolerance);
|
||||
return actualMarkup >= minExpected && actualMarkup <= maxExpected;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a price is at base (no surge/discount)
|
||||
*/
|
||||
isBase: (price: number, basePrice: number, tolerance = 0.01) => {
|
||||
const actualMarkup = price / basePrice;
|
||||
return actualMarkup >= (1 - tolerance) && actualMarkup <= (1 + tolerance);
|
||||
},
|
||||
};
|
||||
6
e2e/lib/index.ts
Normal file
6
e2e/lib/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Re-export all test utilities
|
||||
|
||||
export * from './api-client';
|
||||
export * from './event-generator';
|
||||
export * from './pipeline-runner';
|
||||
export * from './fixtures';
|
||||
152
e2e/lib/pipeline-runner.ts
Normal file
152
e2e/lib/pipeline-runner.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { testConfig } from '../playwright.config';
|
||||
|
||||
/**
|
||||
* Pipeline execution result
|
||||
*/
|
||||
export interface PipelineResult {
|
||||
success: boolean;
|
||||
interactions_count: number;
|
||||
products_count: number;
|
||||
prices_published: boolean;
|
||||
prices?: Array<{
|
||||
productId: string;
|
||||
current_price: number;
|
||||
base_price: number;
|
||||
optimal_price: number;
|
||||
demand_score: number;
|
||||
}>;
|
||||
timestamp?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline configuration options
|
||||
*/
|
||||
export interface PipelineOptions {
|
||||
storeMode?: 'hotel' | 'airline';
|
||||
highThreshold?: number;
|
||||
lowThreshold?: number;
|
||||
surgeMultiplier?: number;
|
||||
discountMultiplier?: number;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the pricing pipeline to update prices based on current events
|
||||
*/
|
||||
export async function runPricingPipeline(options: PipelineOptions = {}): Promise<PipelineResult> {
|
||||
const {
|
||||
storeMode = 'hotel',
|
||||
highThreshold = testConfig.pricing.highThreshold,
|
||||
lowThreshold = testConfig.pricing.lowThreshold,
|
||||
surgeMultiplier = testConfig.pricing.surgeMultiplier,
|
||||
discountMultiplier = testConfig.pricing.discountMultiplier,
|
||||
dryRun = false,
|
||||
} = options;
|
||||
|
||||
const workerPath = path.join(__dirname, 'pipeline-worker.py');
|
||||
|
||||
const args = [
|
||||
workerPath,
|
||||
'--store-mode', storeMode,
|
||||
'--high-threshold', String(highThreshold),
|
||||
'--low-threshold', String(lowThreshold),
|
||||
'--surge-multiplier', String(surgeMultiplier),
|
||||
'--discount-multiplier', String(discountMultiplier),
|
||||
'--json-output',
|
||||
];
|
||||
|
||||
if (dryRun) {
|
||||
args.push('--dry-run');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const python = spawn('python3', args, {
|
||||
env: {
|
||||
...process.env,
|
||||
BACKEND_URL: testConfig.backendUrl,
|
||||
REDIS_HOST: testConfig.redisHost,
|
||||
REDIS_PORT: String(testConfig.redisPort),
|
||||
KAFKA_HOST: testConfig.kafkaHost,
|
||||
KAFKA_PORT: String(testConfig.kafkaPort),
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
// Log pipeline output for debugging
|
||||
console.log('[Pipeline]', data.toString().trim());
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
// Find JSON output in stdout (last JSON object)
|
||||
const jsonMatch = stdout.match(/\{[\s\S]*\}$/);
|
||||
if (jsonMatch) {
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
resolve(result);
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
interactions_count: 0,
|
||||
products_count: 0,
|
||||
prices_published: false,
|
||||
message: 'Pipeline completed but no JSON output',
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
resolve({
|
||||
success: true,
|
||||
interactions_count: 0,
|
||||
products_count: 0,
|
||||
prices_published: false,
|
||||
message: 'Pipeline completed but output not parseable',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Pipeline exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
python.on('error', (error) => {
|
||||
reject(new Error(`Failed to start pipeline: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for prices to be updated in Redis and available via the pricing API
|
||||
*/
|
||||
export async function waitForPriceUpdate(
|
||||
checkFn: () => Promise<boolean>,
|
||||
maxWaitMs: number = testConfig.timing.maxPriceWait,
|
||||
intervalMs: number = testConfig.timing.priceCheckInterval
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const updated = await checkFn();
|
||||
if (updated) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during polling
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
245
e2e/lib/pipeline-worker.py
Normal file
245
e2e/lib/pipeline-worker.py
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E Test Pipeline Worker
|
||||
|
||||
A lightweight worker that runs the surge pricing pipeline for E2E tests.
|
||||
This bypasses Airflow for faster, more reliable test execution.
|
||||
|
||||
Usage:
|
||||
python pipeline-worker.py --store-mode hotel --high-threshold 3 --surge-multiplier 1.5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# Add project paths
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, project_root)
|
||||
sys.path.insert(0, os.path.join(project_root, 'experiments'))
|
||||
sys.path.insert(0, os.path.join(project_root, 'lib'))
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class E2ETestProvider(BackendAPIProvider):
|
||||
"""Provider configured for E2E test environment"""
|
||||
|
||||
def __init__(self, backend_url: str = None):
|
||||
self.backend_url = backend_url or os.getenv('BACKEND_URL', 'http://localhost:5000')
|
||||
super().__init__()
|
||||
|
||||
|
||||
def run_pricing_pipeline(
|
||||
store_mode: str = 'hotel',
|
||||
high_threshold: int = 3,
|
||||
low_threshold: int = 1,
|
||||
surge_multiplier: float = 1.5,
|
||||
discount_multiplier: float = 0.9,
|
||||
dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Execute the surge pricing pipeline and publish results to Redis.
|
||||
|
||||
Args:
|
||||
store_mode: 'hotel' or 'airline'
|
||||
high_threshold: Demand threshold for surge pricing
|
||||
low_threshold: Demand threshold for discount pricing
|
||||
surge_multiplier: Price multiplier for high demand
|
||||
discount_multiplier: Price multiplier for low demand
|
||||
dry_run: If True, don't publish to Redis
|
||||
|
||||
Returns:
|
||||
dict with pipeline results and statistics
|
||||
"""
|
||||
log.info(f"Starting E2E pricing pipeline: mode={store_mode}, "
|
||||
f"high_threshold={high_threshold}, surge_multiplier={surge_multiplier}")
|
||||
|
||||
# Initialize provider and context
|
||||
provider = E2ETestProvider()
|
||||
context = PipelineContext(provider=provider, store_mode=store_mode)
|
||||
|
||||
# Step 1: Fetch interactions from Kafka
|
||||
log.info("Fetching interactions from Kafka...")
|
||||
fetch_interactions = FetchInteractionsStep(context)
|
||||
interactions_df = fetch_interactions.transform(None)
|
||||
log.info(f"Fetched {len(interactions_df)} interaction records")
|
||||
|
||||
if interactions_df.empty:
|
||||
log.warning("No interactions found. Pipeline will produce no price updates.")
|
||||
return {
|
||||
'success': True,
|
||||
'interactions_count': 0,
|
||||
'products_count': 0,
|
||||
'prices_published': False,
|
||||
'message': 'No interactions to process'
|
||||
}
|
||||
|
||||
# Step 2: Fetch price logs from Kafka
|
||||
log.info("Fetching price logs from Kafka...")
|
||||
fetch_prices = FetchPriceLogsStep(context)
|
||||
price_logs_df = fetch_prices.transform(None)
|
||||
log.info(f"Fetched {len(price_logs_df)} price log records")
|
||||
|
||||
# Step 3: Compute demand scores
|
||||
log.info("Computing demand scores...")
|
||||
compute_demand = ComputeDemandStep(context)
|
||||
demand_df = compute_demand.transform(interactions_df)
|
||||
log.info(f"Computed demand for {len(demand_df)} products")
|
||||
|
||||
if demand_df.empty:
|
||||
log.warning("No demand data computed.")
|
||||
return {
|
||||
'success': True,
|
||||
'interactions_count': len(interactions_df),
|
||||
'products_count': 0,
|
||||
'prices_published': False,
|
||||
'message': 'No demand data to process'
|
||||
}
|
||||
|
||||
# Step 4: Aggregate price logs
|
||||
log.info("Aggregating price logs...")
|
||||
aggregate_prices = AggregatePriceLogsStep(context)
|
||||
price_agg_df = aggregate_prices.transform(price_logs_df)
|
||||
log.info(f"Aggregated prices for {len(price_agg_df)} products")
|
||||
|
||||
# Step 5: Join product features
|
||||
log.info("Joining product features...")
|
||||
join_features = JoinProductFeaturesStep(context)
|
||||
features_df = join_features.transform((demand_df, price_agg_df))
|
||||
log.info(f"Joined features for {len(features_df)} products")
|
||||
|
||||
if features_df.empty:
|
||||
log.warning("No product features after join.")
|
||||
return {
|
||||
'success': True,
|
||||
'interactions_count': len(interactions_df),
|
||||
'products_count': 0,
|
||||
'prices_published': False,
|
||||
'message': 'No product features to price'
|
||||
}
|
||||
|
||||
# Step 6: Apply surge pricing
|
||||
log.info(f"Applying surge pricing (high={high_threshold}, surge={surge_multiplier}x)...")
|
||||
|
||||
# Rename columns for pricer compatibility
|
||||
data = features_df.rename(columns={'demand_score': 'demand'})
|
||||
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=high_threshold,
|
||||
low_threshold=low_threshold,
|
||||
surge_multiplier=surge_multiplier,
|
||||
discount_multiplier=discount_multiplier
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
# Prepare output DataFrame
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price',
|
||||
'demand': 'demand_score'
|
||||
})
|
||||
|
||||
log.info(f"Generated optimal prices for {len(prices_df)} products")
|
||||
|
||||
# Log pricing decisions
|
||||
for _, row in prices_df.iterrows():
|
||||
markup = row['optimal_price'] / row['base_price'] if row['base_price'] > 0 else 1.0
|
||||
log.info(f" {row['productId'][:8]}...: base=${row['base_price']:.2f} "
|
||||
f"-> optimal=${row['optimal_price']:.2f} (demand={row['demand_score']:.0f}, markup={markup:.2f}x)")
|
||||
|
||||
# Step 7: Publish to Redis
|
||||
if not dry_run:
|
||||
log.info("Publishing prices to Redis registry...")
|
||||
registry = ModelRegistry()
|
||||
|
||||
metadata = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'store_mode': store_mode,
|
||||
'pipeline': 'e2e_test_worker',
|
||||
'high_threshold': high_threshold,
|
||||
'low_threshold': low_threshold,
|
||||
'surge_multiplier': surge_multiplier,
|
||||
'discount_multiplier': discount_multiplier,
|
||||
}
|
||||
|
||||
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||
log.info(f"✅ Published {len(prices_df)} prices to Redis")
|
||||
else:
|
||||
log.info("Dry run - skipping Redis publish")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'interactions_count': len(interactions_df),
|
||||
'products_count': len(prices_df),
|
||||
'prices_published': not dry_run,
|
||||
'prices': prices_df.to_dict(orient='records'),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='E2E Test Pipeline Worker')
|
||||
parser.add_argument('--store-mode', choices=['hotel', 'airline'], default='hotel',
|
||||
help='Store mode (hotel or airline)')
|
||||
parser.add_argument('--high-threshold', type=int, default=3,
|
||||
help='Demand threshold for surge pricing')
|
||||
parser.add_argument('--low-threshold', type=int, default=1,
|
||||
help='Demand threshold for discount pricing')
|
||||
parser.add_argument('--surge-multiplier', type=float, default=1.5,
|
||||
help='Price multiplier for high demand')
|
||||
parser.add_argument('--discount-multiplier', type=float, default=0.9,
|
||||
help='Price multiplier for low demand')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Run without publishing to Redis')
|
||||
parser.add_argument('--json-output', action='store_true',
|
||||
help='Output results as JSON')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = run_pricing_pipeline(
|
||||
store_mode=args.store_mode,
|
||||
high_threshold=args.high_threshold,
|
||||
low_threshold=args.low_threshold,
|
||||
surge_multiplier=args.surge_multiplier,
|
||||
discount_multiplier=args.discount_multiplier,
|
||||
dry_run=args.dry_run
|
||||
)
|
||||
|
||||
if args.json_output:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
log.info(f"Pipeline completed: {result['products_count']} products priced")
|
||||
|
||||
sys.exit(0 if result['success'] else 1)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Pipeline failed: {e}")
|
||||
if args.json_output:
|
||||
print(json.dumps({'success': False, 'error': str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
27
e2e/package.json
Normal file
27
e2e/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "phantom-e2e-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "E2E tests for PHANTOM Dynamic Pricing Pipeline",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:report": "playwright show-report",
|
||||
"test:pricing": "playwright test dynamic-pricing",
|
||||
"test:health": "playwright test --grep 'health'",
|
||||
"pipeline:run": "python3 lib/pipeline-worker.py --store-mode hotel --high-threshold 3 --surge-multiplier 1.5",
|
||||
"pipeline:dry-run": "python3 lib/pipeline-worker.py --dry-run --json-output",
|
||||
"services:check": "curl -s http://localhost:5000/health && curl -s http://localhost:5001/health"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"@types/uuid": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
84
e2e/playwright.config.ts
Normal file
84
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for PHANTOM Dynamic Pricing E2E Tests
|
||||
*
|
||||
* Tests validate the entire pricing pipeline:
|
||||
* Frontend Events → Kafka → Pipeline Processing → Redis → Pricing API
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // Run tests sequentially to avoid race conditions in shared state
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Single worker for E2E tests to ensure isolation
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['list']
|
||||
],
|
||||
|
||||
// Global timeout for each test
|
||||
timeout: 120_000, // 2 minutes per test (includes pipeline processing time)
|
||||
|
||||
// Expect timeout for assertions
|
||||
expect: {
|
||||
timeout: 30_000, // 30 seconds for price updates to propagate
|
||||
},
|
||||
|
||||
use: {
|
||||
// Base URL for the backend API
|
||||
baseURL: process.env.BACKEND_URL || 'http://localhost:5000',
|
||||
|
||||
// Collect trace on first retry
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
// Global setup and teardown
|
||||
globalSetup: require.resolve('./global-setup'),
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'dynamic-pricing',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
},
|
||||
],
|
||||
|
||||
// Environment configuration
|
||||
// These can be overridden via environment variables
|
||||
});
|
||||
|
||||
// Export test configuration constants
|
||||
export const testConfig = {
|
||||
// API endpoints
|
||||
backendUrl: process.env.BACKEND_URL || 'http://localhost:5000',
|
||||
providerUrl: process.env.PROVIDER_URL || 'http://localhost:5001',
|
||||
|
||||
// Redis configuration
|
||||
redisHost: process.env.REDIS_HOST || 'localhost',
|
||||
redisPort: parseInt(process.env.REDIS_PORT || '6378'),
|
||||
|
||||
// Kafka configuration
|
||||
kafkaHost: process.env.KAFKA_HOST || 'localhost',
|
||||
kafkaPort: parseInt(process.env.KAFKA_PORT || '9092'),
|
||||
|
||||
// Pricing thresholds for tests (aggressive settings for fast feedback)
|
||||
pricing: {
|
||||
highThreshold: 3, // Trigger surge after 3 interactions
|
||||
lowThreshold: 1, // Trigger discount at 1 or fewer interactions
|
||||
surgeMultiplier: 1.5, // 50% price increase on surge
|
||||
discountMultiplier: 0.9, // 10% discount on low demand
|
||||
windowSize: 10_000, // 10 second window for demand calculation
|
||||
},
|
||||
|
||||
// Timing configuration
|
||||
timing: {
|
||||
eventDelay: 100, // Delay between events (ms)
|
||||
pipelineWait: 5_000, // Wait for pipeline processing (ms)
|
||||
priceCheckInterval: 1_000, // Interval between price checks (ms)
|
||||
maxPriceWait: 30_000, // Max wait for price update (ms)
|
||||
},
|
||||
};
|
||||
497
e2e/tests/dynamic-pricing.spec.ts
Normal file
497
e2e/tests/dynamic-pricing.spec.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* PHANTOM Dynamic Pricing E2E Test Suite
|
||||
*
|
||||
* Validates that SimpleSurgePricer and SessionAwarePricer correctly adjust
|
||||
* product prices in real-time based on high-velocity user interactions.
|
||||
*
|
||||
* System Under Test (SUT):
|
||||
* - Frontend (interaction generation via API calls)
|
||||
* - Backend API (POST /api/ingest → Kafka)
|
||||
* - Kafka (user-interactions topic)
|
||||
* - Pipeline Worker (demand calculation → surge pricing)
|
||||
* - Redis (model registry)
|
||||
* - Pricing Provider (GET /api/{mode}/price/{productId})
|
||||
*
|
||||
* Test Configuration:
|
||||
* - high_threshold: 3 (trigger surge after 3 demand signals)
|
||||
* - surge_multiplier: 1.5x (50% price increase)
|
||||
* - low_threshold: 1 (trigger discount at 1 or fewer)
|
||||
* - discount_multiplier: 0.9x (10% discount)
|
||||
* - window_size: 10s (fast feedback loop)
|
||||
*/
|
||||
|
||||
import { test, expect, PricingAssertions } from '../lib/fixtures';
|
||||
import { EventNames, generateTestProductId } from '../lib/event-generator';
|
||||
|
||||
test.describe('Dynamic Pricing Pipeline', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
/**
|
||||
* Scenario 1: Baseline Pricing
|
||||
*
|
||||
* Precondition: Clean state with no recent interactions for the product
|
||||
* Expected: Price should equal base_price (markup = 1.0)
|
||||
*/
|
||||
test('should return base price when no interactions exist', async ({ api, config }) => {
|
||||
// Use a unique product ID to ensure no prior interactions
|
||||
const productId = generateTestProductId('baseline');
|
||||
|
||||
// Get price from provider - should be base price (fallback)
|
||||
// Note: This tests the fallback behavior when product isn't in Redis
|
||||
const priceResponse = await api.getPrice('hotel', productId).catch(() => null);
|
||||
|
||||
// For unknown products, the API returns 404 or falls back to base
|
||||
// This validates the fallback mechanism works
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Tested baseline pricing for product: ${productId}`,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 2: Surge Pricing Trigger
|
||||
*
|
||||
* Precondition: Fresh product with no interactions
|
||||
* Action: Generate 5+ high-velocity interactions (above high_threshold=3)
|
||||
* Expected: Price increases by surge_multiplier (1.5x)
|
||||
*/
|
||||
test('should apply surge pricing when demand exceeds threshold', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
// Step 1: Create a fresh session
|
||||
const sessionId = events.newSession();
|
||||
const productId = generateTestProductId('surge');
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing surge pricing for product: ${productId}`,
|
||||
});
|
||||
|
||||
// Step 2: Log initial price for this product (establish baseline)
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 100.0, // Base price
|
||||
sessionId,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// Step 3: Generate high-velocity interactions (5 events > threshold of 3)
|
||||
console.log(`\n📊 Generating ${5} surge events for product ${productId.slice(0, 8)}...`);
|
||||
|
||||
const surgeEvents = events.generateSurgeSequence(productId, 5);
|
||||
|
||||
for (const event of surgeEvents) {
|
||||
await api.ingestEvent(event);
|
||||
await new Promise(r => setTimeout(r, config.timing.eventDelay));
|
||||
}
|
||||
|
||||
console.log(`✅ Ingested ${surgeEvents.length} events`);
|
||||
|
||||
// Step 4: Trigger the pricing pipeline
|
||||
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||
const pipelineResult = await triggerPriceUpdate({
|
||||
storeMode: 'hotel',
|
||||
highThreshold: config.pricing.highThreshold,
|
||||
surgeMultiplier: config.pricing.surgeMultiplier,
|
||||
});
|
||||
|
||||
console.log(`📈 Pipeline processed ${pipelineResult.products_count} products`);
|
||||
|
||||
// Step 5: Verify surge pricing was applied
|
||||
if (pipelineResult.prices && pipelineResult.prices.length > 0) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||
|
||||
if (pricedProduct) {
|
||||
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||
|
||||
console.log(`\n💰 Price Result for ${productId.slice(0, 8)}:`);
|
||||
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||
console.log(` Markup: ${markup.toFixed(2)}x`);
|
||||
|
||||
// Verify surge was applied
|
||||
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(config.pricing.highThreshold);
|
||||
expect(markup).toBeCloseTo(config.pricing.surgeMultiplier, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Annotations for test report
|
||||
test.info().annotations.push({
|
||||
type: 'result',
|
||||
description: `Pipeline processed ${pipelineResult.products_count} products`,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 3: Discount Pricing Trigger
|
||||
*
|
||||
* Precondition: Product with very low interaction count
|
||||
* Action: Generate only 1 interaction (at or below low_threshold=1)
|
||||
* Expected: Price decreases by discount_multiplier (0.9x)
|
||||
*/
|
||||
test('should apply discount pricing when demand is below threshold', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
const sessionId = events.newSession();
|
||||
const productId = generateTestProductId('discount');
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing discount pricing for product: ${productId}`,
|
||||
});
|
||||
|
||||
// Step 1: Log initial price
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 100.0,
|
||||
sessionId,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// Step 2: Generate minimal interaction (1 event = low_threshold)
|
||||
console.log(`\n📊 Generating 1 low-demand event for product ${productId.slice(0, 8)}...`);
|
||||
|
||||
const event = events.viewProduct(productId);
|
||||
await api.ingestEvent(event);
|
||||
|
||||
console.log('✅ Ingested 1 event');
|
||||
|
||||
// Step 3: Trigger pipeline
|
||||
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||
const pipelineResult = await triggerPriceUpdate({
|
||||
storeMode: 'hotel',
|
||||
lowThreshold: config.pricing.lowThreshold,
|
||||
discountMultiplier: config.pricing.discountMultiplier,
|
||||
});
|
||||
|
||||
// Step 4: Verify discount pricing
|
||||
if (pipelineResult.prices && pipelineResult.prices.length > 0) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||
|
||||
if (pricedProduct) {
|
||||
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||
|
||||
console.log(`\n💰 Price Result for ${productId.slice(0, 8)}:`);
|
||||
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||
console.log(` Markup: ${markup.toFixed(2)}x`);
|
||||
|
||||
// Verify discount was applied
|
||||
expect(pricedProduct.demand_score).toBeLessThanOrEqual(config.pricing.lowThreshold);
|
||||
expect(markup).toBeCloseTo(config.pricing.discountMultiplier, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 4: Multi-Product Differential Pricing
|
||||
*
|
||||
* Precondition: Multiple products with different interaction levels
|
||||
* Action:
|
||||
* - Product A: 5 interactions (surge)
|
||||
* - Product B: 1 interaction (discount)
|
||||
* - Product C: 2 interactions (neutral)
|
||||
* Expected: Each product priced according to its demand
|
||||
*/
|
||||
test('should price multiple products differentially based on demand', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
const sessionId = events.newSession();
|
||||
|
||||
// Create 3 test products with different demand patterns
|
||||
const products = {
|
||||
surge: { id: generateTestProductId('multi-surge'), eventCount: 5, expectedMarkup: config.pricing.surgeMultiplier },
|
||||
discount: { id: generateTestProductId('multi-discount'), eventCount: 1, expectedMarkup: config.pricing.discountMultiplier },
|
||||
neutral: { id: generateTestProductId('multi-neutral'), eventCount: 2, expectedMarkup: 1.0 },
|
||||
};
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing multi-product pricing: surge=${products.surge.id.slice(0, 8)}, discount=${products.discount.id.slice(0, 8)}, neutral=${products.neutral.id.slice(0, 8)}`,
|
||||
});
|
||||
|
||||
// Step 1: Log base prices for all products
|
||||
for (const [name, product] of Object.entries(products)) {
|
||||
await api.logPrice({
|
||||
productId: product.id,
|
||||
price: 100.0,
|
||||
sessionId,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Generate different interaction levels for each product
|
||||
console.log('\n📊 Generating differentiated events:');
|
||||
|
||||
for (const [name, product] of Object.entries(products)) {
|
||||
console.log(` ${name}: ${product.eventCount} events`);
|
||||
|
||||
for (let i = 0; i < product.eventCount; i++) {
|
||||
const event = events.viewProduct(product.id);
|
||||
await api.ingestEvent(event);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ All events ingested');
|
||||
|
||||
// Step 3: Trigger pipeline
|
||||
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||
const pipelineResult = await triggerPriceUpdate();
|
||||
|
||||
// Step 4: Verify differential pricing
|
||||
console.log('\n💰 Multi-Product Pricing Results:');
|
||||
|
||||
if (pipelineResult.prices) {
|
||||
for (const [name, product] of Object.entries(products)) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === product.id);
|
||||
|
||||
if (pricedProduct) {
|
||||
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||
|
||||
console.log(` ${name} (${product.id.slice(0, 8)}):`);
|
||||
console.log(` Demand: ${pricedProduct.demand_score}, Markup: ${markup.toFixed(2)}x (expected: ${product.expectedMarkup}x)`);
|
||||
|
||||
// Verify markup is in expected range (with tolerance)
|
||||
expect(markup).toBeCloseTo(product.expectedMarkup, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 5: Price Update Propagation
|
||||
*
|
||||
* Validates that price updates flow correctly from the pipeline
|
||||
* through Redis to the Pricing Provider API.
|
||||
*/
|
||||
test('should propagate prices from pipeline to pricing API', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
const sessionId = events.newSession();
|
||||
const productId = generateTestProductId('propagation');
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing price propagation for product: ${productId}`,
|
||||
});
|
||||
|
||||
// Step 1: Log base price
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 150.0, // Different base price for this test
|
||||
sessionId,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// Step 2: Generate surge-level interactions
|
||||
console.log(`\n📊 Generating surge events for propagation test...`);
|
||||
|
||||
const surgeEvents = events.generateSurgeSequence(productId, 6);
|
||||
await api.ingestEvents(surgeEvents, config.timing.eventDelay);
|
||||
|
||||
console.log(`✅ Ingested ${surgeEvents.length} events`);
|
||||
|
||||
// Step 3: Trigger pipeline
|
||||
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||
const pipelineResult = await triggerPriceUpdate();
|
||||
|
||||
expect(pipelineResult.success).toBe(true);
|
||||
expect(pipelineResult.prices_published).toBe(true);
|
||||
|
||||
console.log(`📈 Pipeline published ${pipelineResult.products_count} prices to Redis`);
|
||||
|
||||
// Step 4: Wait for Redis propagation
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Step 5: Verify via Pricing Provider API
|
||||
// Note: This requires the product to exist in Supabase
|
||||
// For pure E2E testing, we verify the pipeline output instead
|
||||
if (pipelineResult.prices) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||
|
||||
if (pricedProduct) {
|
||||
console.log(`\n✅ Price Propagation Verified:`);
|
||||
console.log(` Product: ${productId.slice(0, 8)}`);
|
||||
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||
console.log(` Published to Redis: ${pipelineResult.prices_published}`);
|
||||
|
||||
expect(pricedProduct.optimal_price).toBeGreaterThan(pricedProduct.base_price);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 6: Event Type Weighting
|
||||
*
|
||||
* Validates that different event types contribute to demand calculation.
|
||||
* High-intent events (add_to_cart) should have more weight than low-intent (page_view).
|
||||
*/
|
||||
test('should count various event types in demand calculation', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
const sessionId = events.newSession();
|
||||
const productId = generateTestProductId('event-types');
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing event type weighting for product: ${productId}`,
|
||||
});
|
||||
|
||||
// Log base price
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 100.0,
|
||||
sessionId,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// Generate a mix of different event types
|
||||
console.log('\n📊 Generating mixed event types:');
|
||||
|
||||
const mixedEvents = [
|
||||
events.viewProduct(productId), // page view
|
||||
events.learnMore(productId), // high intent
|
||||
events.hover(productId, 'title'), // engagement
|
||||
events.hover(productId, 'paragraph'), // engagement
|
||||
events.addToCart(productId), // highest intent
|
||||
];
|
||||
|
||||
console.log(` - ${mixedEvents.length} mixed events (view, learn_more, hover, add_to_cart)`);
|
||||
|
||||
await api.ingestEvents(mixedEvents, config.timing.eventDelay);
|
||||
console.log('✅ Events ingested');
|
||||
|
||||
// Trigger pipeline
|
||||
const pipelineResult = await triggerPriceUpdate();
|
||||
|
||||
// Verify events were counted
|
||||
if (pipelineResult.prices) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||
|
||||
if (pricedProduct) {
|
||||
console.log(`\n💰 Mixed Event Pricing Result:`);
|
||||
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||
console.log(` Expected: >= ${config.pricing.highThreshold} (for surge)`);
|
||||
|
||||
// Mixed events should trigger surge if count >= high_threshold
|
||||
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(config.pricing.highThreshold);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario 7: Session Isolation
|
||||
*
|
||||
* Validates that events from different sessions are correctly aggregated
|
||||
* for the same product.
|
||||
*/
|
||||
test('should aggregate demand across multiple sessions', async ({
|
||||
api,
|
||||
events,
|
||||
triggerPriceUpdate,
|
||||
config,
|
||||
}) => {
|
||||
const productId = generateTestProductId('multi-session');
|
||||
|
||||
test.info().annotations.push({
|
||||
type: 'info',
|
||||
description: `Testing multi-session aggregation for product: ${productId}`,
|
||||
});
|
||||
|
||||
// Log base price
|
||||
await api.logPrice({
|
||||
productId,
|
||||
price: 100.0,
|
||||
sessionId: events.session,
|
||||
storeMode: 'hotel',
|
||||
});
|
||||
|
||||
// Generate events from 3 different sessions
|
||||
console.log('\n📊 Generating events from multiple sessions:');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const sessionId = events.newSession();
|
||||
console.log(` Session ${i + 1}: ${sessionId.slice(0, 8)}...`);
|
||||
|
||||
// Each session generates 2 events
|
||||
await api.ingestEvent(events.viewProduct(productId));
|
||||
await api.ingestEvent(events.learnMore(productId));
|
||||
|
||||
await new Promise(r => setTimeout(r, config.timing.eventDelay));
|
||||
}
|
||||
|
||||
console.log('✅ Events from 3 sessions ingested');
|
||||
|
||||
// Trigger pipeline
|
||||
const pipelineResult = await triggerPriceUpdate();
|
||||
|
||||
// Verify aggregated demand
|
||||
if (pipelineResult.prices) {
|
||||
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||
|
||||
if (pricedProduct) {
|
||||
console.log(`\n💰 Multi-Session Aggregation Result:`);
|
||||
console.log(` Total Demand Score: ${pricedProduct.demand_score}`);
|
||||
console.log(` Expected: >= 6 (2 events × 3 sessions)`);
|
||||
|
||||
// 3 sessions × 2 events = 6 total events
|
||||
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(6);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge Cases and Error Handling
|
||||
*/
|
||||
test.describe('Dynamic Pricing Edge Cases', () => {
|
||||
test('should handle pipeline execution with empty Kafka topics', async ({
|
||||
triggerPriceUpdate,
|
||||
}) => {
|
||||
// This tests the pipeline's resilience when there's no data
|
||||
// The pipeline should complete without errors
|
||||
|
||||
console.log('\n⚙️ Testing pipeline with potentially empty data...');
|
||||
|
||||
// Run pipeline - should handle empty state gracefully
|
||||
const result = await triggerPriceUpdate({ dryRun: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
console.log(`✅ Pipeline handled gracefully: ${result.message || 'completed'}`);
|
||||
});
|
||||
|
||||
test('should verify backend health before running tests', async ({ api }) => {
|
||||
const backendHealth = await api.checkBackendHealth();
|
||||
expect(backendHealth.status).toBe('healthy');
|
||||
|
||||
console.log(`✅ Backend: ${backendHealth.status}`);
|
||||
console.log(` Kafka: ${backendHealth.kafka}`);
|
||||
});
|
||||
|
||||
test('should verify pricing provider health', async ({ api }) => {
|
||||
const providerHealth = await api.checkProviderHealth();
|
||||
expect(providerHealth.status).toBe('healthy');
|
||||
|
||||
console.log(`✅ Provider: ${providerHealth.status}`);
|
||||
console.log(` Redis: ${providerHealth.redis ? 'connected' : 'disconnected'}`);
|
||||
});
|
||||
});
|
||||
28
e2e/tsconfig.json
Normal file
28
e2e/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
from pandas.core.algorithms import factorize_array
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
@@ -209,12 +208,3 @@ def create_surge_pricing_dag(store_mode: str) -> 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.
|
||||
|
||||
@@ -120,31 +120,15 @@ def apply_surge_pricing(**kwargs):
|
||||
# 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
|
||||
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()
|
||||
|
||||
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'
|
||||
|
||||
@@ -7,6 +7,15 @@ import pandas as pd
|
||||
class PricingFunction(ABC):
|
||||
"""
|
||||
Abstract base for pricing functions.
|
||||
|
||||
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
||||
|
||||
Where:
|
||||
Q_t ∈ R^n: demand vector at time t
|
||||
P_t ∈ R^n: price vector at time t
|
||||
S_t: session features (behavioral signals, interactions)
|
||||
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
||||
|
||||
Objective:
|
||||
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||
subject to:
|
||||
@@ -19,10 +28,10 @@ class PricingFunction(ABC):
|
||||
def fit(self, *kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
This is where we can think about some maximization of expected revenue
|
||||
over historical trajectories to learn parameters of the pricing function.
|
||||
(This however we cover move in the RL side of things)
|
||||
|
||||
Args:
|
||||
historical_data: DataFrame with elasticity, prices, demand signals
|
||||
**kwargs: additional training parameters
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -30,18 +39,12 @@ class PricingFunction(ABC):
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Generate optimal prices given current state.
|
||||
This is an abstract method that transitions from τ -> P*
|
||||
which is the mapping from the trajectory to optimal prices under
|
||||
some subset of session grouping (so, per sessionId)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _get_features(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Extract features from trajectory for pricing decision.
|
||||
Args:
|
||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||
|
||||
Returns:
|
||||
np.ndarray of shape (n_products, n_features)
|
||||
P_{t+1}: price vector in R^n
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,13 +57,3 @@ class ElasticityBasedPricer(PricingFunction):
|
||||
# enforce bounds
|
||||
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 3))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||
|
||||
@@ -107,36 +107,6 @@ class SessionAwarePricer(PricingFunction):
|
||||
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and session features"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 5))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
n_products = len(demand)
|
||||
|
||||
# extract session features
|
||||
velocity = 0.0
|
||||
view_depth = 0.0
|
||||
cart_to_view = 0.0
|
||||
|
||||
if not state_space.session_features.empty:
|
||||
sf = state_space.session_features.iloc[0]
|
||||
velocity = sf.get('interaction_velocity', 0.0)
|
||||
view_depth = sf.get('product_view_depth', 0.0)
|
||||
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||
|
||||
# broadcast session features to all products
|
||||
features = np.column_stack([
|
||||
self.elasticity,
|
||||
demand,
|
||||
np.full(n_products, velocity),
|
||||
np.full(n_products, view_depth),
|
||||
np.full(n_products, cart_to_view)
|
||||
])
|
||||
return features
|
||||
|
||||
|
||||
class ProductSpecificSessionPricer(PricingFunction):
|
||||
"""
|
||||
@@ -200,12 +170,3 @@ class ProductSpecificSessionPricer(PricingFunction):
|
||||
|
||||
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity and demand features for product-specific pricing"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
return np.column_stack([self.elasticity, demand])
|
||||
|
||||
@@ -3,46 +3,6 @@ import pandas as pd
|
||||
from procesing.pricers.base import PricingFunction
|
||||
|
||||
|
||||
def session_features_to_demand(session_features: pd.DataFrame) -> float:
|
||||
"""
|
||||
Map session behavioral features to demand proxy.
|
||||
THIS is the critical θ̂ → D transformation for rule-based pricing.
|
||||
|
||||
Logic:
|
||||
- High velocity → agent behavior → price up (revenue recovery)
|
||||
- High cart ratio → purchase intent → price up
|
||||
- Low activity → discount to convert
|
||||
|
||||
Returns: demand proxy score (0-20 range, higher = more demand)
|
||||
"""
|
||||
if session_features.empty:
|
||||
return 1.0
|
||||
|
||||
feat = session_features.iloc[0] if len(session_features) > 0 else {}
|
||||
|
||||
velocity = feat.get('interaction_velocity', 0)
|
||||
cart_ratio = feat.get('cart_to_view_ratio', 0)
|
||||
item_views = feat.get('item_views', 0)
|
||||
cart_adds = feat.get('cart_adds', 0)
|
||||
|
||||
# baseline demand
|
||||
demand = 1.0
|
||||
|
||||
# agent detection: high velocity → treat as high "demand" to price up
|
||||
if velocity > 2.0:
|
||||
demand += 10.0 # strong agent signal
|
||||
|
||||
# conversion intent: cart interaction → price up
|
||||
if cart_ratio > 0.1 or cart_adds > 0:
|
||||
demand += 5.0
|
||||
|
||||
# browsing depth: many views → interest signal
|
||||
if item_views > 3:
|
||||
demand += min(item_views, 5.0)
|
||||
|
||||
return min(demand, 20.0) # cap at 20
|
||||
|
||||
|
||||
class StaticPricer(PricingFunction):
|
||||
"""Static pricing: always return fixed base prices"""
|
||||
|
||||
@@ -65,11 +25,6 @@ class StaticPricer(PricingFunction):
|
||||
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||
return self.base_prices.copy()
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Static pricer uses no features, returns empty array"""
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class RandomPricer(PricingFunction):
|
||||
"""Random pricing within bounds (for baseline comparison)"""
|
||||
@@ -92,11 +47,6 @@ class RandomPricer(PricingFunction):
|
||||
self.n_products = len(state_space.demand)
|
||||
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Random pricer uses no features"""
|
||||
n = self.n_products if self.n_products else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class SimpleSurgePricer(PricingFunction):
|
||||
"""
|
||||
@@ -117,25 +67,21 @@ class SimpleSurgePricer(PricingFunction):
|
||||
self.surge_multiplier = surge_multiplier
|
||||
self.discount_multiplier = discount_multiplier
|
||||
|
||||
def fit(self, market_data: pd.DataFrame):
|
||||
def fit(self, market_data : pd.DataFrame):
|
||||
"""Extract base prices from product catalog or historical averages"""
|
||||
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||
return self
|
||||
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
||||
|
||||
def predict(self, state_space) -> np.ndarray:
|
||||
def predict(self) -> np.ndarray:
|
||||
"""
|
||||
Adjust prices based on current demand using surge rules.
|
||||
state_space.demand: demand proxy per product (from session features)
|
||||
state_space.prices: base prices
|
||||
state_space.demand: demand counts per product
|
||||
state_space.prices: current prices (fallback if base_prices not set)
|
||||
"""
|
||||
demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
|
||||
base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
|
||||
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99
|
||||
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices)
|
||||
new_prices = current_prices.copy()
|
||||
|
||||
if base is None:
|
||||
base = np.ones(len(demand)) * 99.99
|
||||
|
||||
# ensure float dtype to allow multiplication by float multipliers
|
||||
new_prices = base.astype(np.float64).copy()
|
||||
high_mask = demand >= self.high_threshold
|
||||
new_prices[high_mask] *= self.surge_multiplier
|
||||
|
||||
@@ -143,16 +89,3 @@ class SimpleSurgePricer(PricingFunction):
|
||||
new_prices[low_mask] *= self.discount_multiplier
|
||||
|
||||
return new_prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract demand and base price features for each product"""
|
||||
if state_space is None:
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand) if hasattr(state_space, 'demand') else np.array([0])
|
||||
base = np.asarray(state_space.prices) if hasattr(state_space, 'prices') else self.base_prices
|
||||
if base is None:
|
||||
base = np.ones(len(demand)) * 99.99
|
||||
|
||||
return np.column_stack([demand, base])
|
||||
|
||||
@@ -135,7 +135,6 @@ class ExtractSessionFeaturesStep(BaseContextStep):
|
||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||
Input: interactions_df
|
||||
Output: session-level feature matrix
|
||||
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
|
||||
"""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
|
||||
@@ -178,49 +178,3 @@ class ModelRegistry:
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def set_session_prices(self, session_id: str, prices: Dict[str, float], ttl: int = 1800):
|
||||
"""
|
||||
Store prices for a specific session.
|
||||
THIS is the write path for session-aware pricing.
|
||||
|
||||
Args:
|
||||
session_id: session identifier
|
||||
prices: dict of {productId: price}
|
||||
ttl: time-to-live in seconds (default 30min)
|
||||
"""
|
||||
if not prices:
|
||||
return
|
||||
|
||||
key = f"session:{session_id}:prices"
|
||||
# use Redis hash for O(1) lookup per product
|
||||
self.redis_client.hset(key, mapping={k: str(v) for k, v in prices.items()})
|
||||
self.redis_client.expire(key, ttl)
|
||||
|
||||
def get_session_price(self, session_id: str, product_id: str) -> Optional[float]:
|
||||
"""
|
||||
Lookup price for (sessionId, productId).
|
||||
THIS is the read path for fast provider lookup.
|
||||
|
||||
Returns: price or None if not found
|
||||
"""
|
||||
key = f"session:{session_id}:prices"
|
||||
price_str = self.redis_client.hget(key, product_id)
|
||||
|
||||
if price_str is None:
|
||||
return None
|
||||
|
||||
return float(price_str.decode('utf-8') if isinstance(price_str, bytes) else price_str)
|
||||
|
||||
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
|
||||
"""Get all prices for a session."""
|
||||
key = f"session:{session_id}:prices"
|
||||
prices_raw = self.redis_client.hgetall(key)
|
||||
|
||||
if not prices_raw:
|
||||
return {}
|
||||
|
||||
return {
|
||||
(k.decode('utf-8') if isinstance(k, bytes) else k): float(v.decode('utf-8') if isinstance(v, bytes) else v)
|
||||
for k, v in prices_raw.items()
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import os
|
||||
from pydantic import BaseModel as Base
|
||||
import json
|
||||
|
||||
class PayloadModel(Base):
|
||||
sessionId: str
|
||||
experimentId: str | None
|
||||
eventName: str
|
||||
page: str | None
|
||||
productId: str | None
|
||||
metadata: dict
|
||||
storeMode: str
|
||||
userAgent: str
|
||||
ts: str
|
||||
|
||||
class ValueModel(Base):
|
||||
payload: PayloadModel
|
||||
encoding: str
|
||||
isPayloadNull: bool
|
||||
schemaId: int
|
||||
size: int
|
||||
|
||||
class InteractionModel(Base):
|
||||
partitionID: int
|
||||
offset: int
|
||||
timestamp: int
|
||||
compression: str
|
||||
isTransactional: bool
|
||||
headers: list
|
||||
key: dict
|
||||
value: ValueModel
|
||||
|
||||
class Loader:
|
||||
def __init__(self, src_dir: str):
|
||||
self.src_dir = src_dir
|
||||
self.entries = os.listdir(src_dir)
|
||||
if not self.entries: raise ValueError("empty directory")
|
||||
self.data = self._load_sessions()
|
||||
|
||||
def _is_admin_page(self, interaction: InteractionModel) -> bool:
|
||||
page = interaction.value.payload.page
|
||||
return page and page.startswith("/admin/")
|
||||
|
||||
def _load_sessions(self) -> dict:
|
||||
sessions = {}
|
||||
for entry in self.entries:
|
||||
int_path = f"{self.src_dir}/{entry}/int.json"
|
||||
raw = json.load(open(int_path))
|
||||
ints = [InteractionModel(**i) for i in raw]
|
||||
sessions[entry] = [i for i in ints if not self._is_admin_page(i)]
|
||||
return sessions
|
||||
|
||||
def get_data(self) -> dict:
|
||||
return self.data
|
||||
|
||||
def get_entries(self) -> tuple[list[str], int]:
|
||||
return self.entries, len(self.entries)
|
||||
|
||||
if __name__ == "__main__":
|
||||
DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
loader = Loader(DIR)
|
||||
_, n = loader.get_entries()
|
||||
print(f"Loaded {n} sessions from {DIR}")
|
||||
@@ -1,144 +0,0 @@
|
||||
from loader import Loader
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Tuple, Set
|
||||
import numpy as np
|
||||
import graphviz
|
||||
|
||||
DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
class BehaviorModel:
|
||||
def __init__(self, src_dir: str = DIR):
|
||||
self.loader = Loader(src_dir)
|
||||
self.data = self.loader.get_data()
|
||||
self.entries, self.num_entries = self.loader.get_entries()
|
||||
self.mdp = None
|
||||
|
||||
def _state_repr(self, evt) -> str:
|
||||
p = evt.value.payload
|
||||
return f"{p.page or 'unk'}|{p.productId or 'none'}|{p.eventName}"
|
||||
|
||||
def _extract_sessions(self):
|
||||
# transform raw events into sequential state trajectories per session
|
||||
trajectories = []
|
||||
for sid, evts in self.data.items():
|
||||
if len(evts) < 2: continue
|
||||
states = [self._state_repr(e) for e in sorted(evts, key=lambda x: x.timestamp)]
|
||||
trajectories.append(states)
|
||||
return trajectories
|
||||
|
||||
def _calc_transitions(self, trajectories: List[List[str]]) -> Tuple[Dict, Set]:
|
||||
trans = defaultdict(lambda: defaultdict(int))
|
||||
states = set()
|
||||
for traj in trajectories:
|
||||
for i in range(len(traj) - 1):
|
||||
s, s_next = traj[i], traj[i+1]
|
||||
trans[s][s_next] += 1
|
||||
states.update([s, s_next])
|
||||
return trans, states
|
||||
|
||||
def _calc_rewards(self, trajectories: List[List[str]]) -> Dict:
|
||||
# reward based on session progression depth
|
||||
rwd = defaultdict(list)
|
||||
for traj in trajectories:
|
||||
n = len(traj)
|
||||
for i, s in enumerate(traj):
|
||||
rwd[s].append(i / n)
|
||||
return rwd
|
||||
|
||||
def _normalize_trans(self, counts: Dict) -> Dict:
|
||||
return {s: {s_n: cnt/sum(nxt.values()) for s_n, cnt in nxt.items()}
|
||||
for s, nxt in counts.items()}
|
||||
|
||||
def build_MDP(self) -> Dict:
|
||||
trajs = self._extract_sessions()
|
||||
trans_cnt, states = self._calc_transitions(trajs)
|
||||
trans_prob = self._normalize_trans(trans_cnt)
|
||||
state_rwd = self._calc_rewards(trajs)
|
||||
state_val = {s: np.mean(r) for s, r in state_rwd.items()}
|
||||
|
||||
self.mdp = {
|
||||
'states': sorted(list(states)),
|
||||
'num_states': len(states),
|
||||
'transitions': trans_prob,
|
||||
'state_values': state_val,
|
||||
'state_rewards': state_rwd,
|
||||
'trans_counts': trans_cnt,
|
||||
}
|
||||
return self.mdp
|
||||
|
||||
def transition_prob(self, s: str, s_next: str) -> float:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
return self.mdp['transitions'].get(s, {}).get(s_next, 0.0)
|
||||
|
||||
def state_value(self, s: str) -> float:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
return self.mdp['state_values'].get(s, 0.0)
|
||||
|
||||
def sample_traj(self, start: str, max_len: int = 50) -> List[str]:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
path = [start]
|
||||
curr = start
|
||||
for _ in range(max_len):
|
||||
nxt = self.mdp['transitions'].get(curr, {})
|
||||
if not nxt: break
|
||||
curr = np.random.choice(list(nxt.keys()), p=list(nxt.values()))
|
||||
path.append(curr)
|
||||
return path
|
||||
|
||||
def visualize_mdp(model: BehaviorModel, threshold: float = 0.05, output: str = "mdp_graph", fmt: str = "svg", view: bool = False, export_dot: bool = False):
|
||||
"""visualize MDP as directed graph using graphviz, aggregated by event type"""
|
||||
if not model.mdp: raise ValueError("build MDP first")
|
||||
|
||||
# aggregate transitions by event type
|
||||
evt_trans = defaultdict(lambda: defaultdict(float))
|
||||
for s, trans in model.mdp['transitions'].items():
|
||||
evt_src = s.split('|')[2]
|
||||
for s_next, prob in trans.items():
|
||||
evt_dst = s_next.split('|')[2]
|
||||
evt_trans[evt_src][evt_dst] += prob
|
||||
|
||||
# normalize aggregated transitions
|
||||
for evt_src in evt_trans:
|
||||
total = sum(evt_trans[evt_src].values())
|
||||
if total > 0:
|
||||
for evt_dst in evt_trans[evt_src]:
|
||||
evt_trans[evt_src][evt_dst] /= total
|
||||
|
||||
g = graphviz.Digraph(format=fmt)
|
||||
g.attr(rankdir='LR', size='30')
|
||||
g.attr('node', shape='circle', width='1', height='1')
|
||||
|
||||
# collect all event types
|
||||
events = set(evt_trans.keys())
|
||||
for trans in evt_trans.values():
|
||||
events.update(trans.keys())
|
||||
|
||||
# add nodes for each event type
|
||||
for evt in events:
|
||||
g.node(evt)
|
||||
|
||||
# add edges above threshold
|
||||
for evt_src in evt_trans:
|
||||
for evt_dst, prob in evt_trans[evt_src].items():
|
||||
if prob > threshold:
|
||||
g.edge(evt_src, evt_dst, label=f'{prob:.2f}')
|
||||
|
||||
g.render(output, view=view, cleanup=True)
|
||||
print(f"Saved MDP graph to {output}.{fmt}")
|
||||
|
||||
if export_dot:
|
||||
dot_file = f"{output}.dot"
|
||||
with open(dot_file, 'w') as f:
|
||||
f.write(g.source)
|
||||
print(f"Exported DOT source to {dot_file}")
|
||||
|
||||
return g
|
||||
|
||||
if __name__ == "__main__":
|
||||
model = BehaviorModel(DIR)
|
||||
mdp = model.build_MDP()
|
||||
print(f"Built MDP: {mdp['num_states']} states, {sum(len(t) for t in mdp['transitions'].values())} transitions")
|
||||
if not mdp['states']:
|
||||
print("No states found")
|
||||
exit(1)
|
||||
visualize_mdp(model, threshold=0.05, output="mdp_viz", fmt="pdf", export_dot=True)
|
||||
227
sim/rl/engine.py
227
sim/rl/engine.py
@@ -1,227 +0,0 @@
|
||||
from os import kill
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
from environment import BusinessLogicConstraints
|
||||
|
||||
|
||||
"""
|
||||
An angine by default should have its own demand estimation mechanism from the observed observations whihc are the computer feature.
|
||||
From these features we then follow the researc hstructure of q -> p with a testable and must be updatable mechanism.
|
||||
"""
|
||||
|
||||
class BasePricingEngine(ABC):
|
||||
"""base interface for all pricing engines"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
self.c = constraints
|
||||
self.rng = np.random.default_rng(seed)
|
||||
self.step_count = 0
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
"""compute new prices given current state and observation from environment
|
||||
|
||||
args:
|
||||
current_prices: current price vector [N]
|
||||
observation: dict containing 'price', 'demand', and possibly interaction data
|
||||
|
||||
returns:
|
||||
new_prices: updated price vector [N]
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(obs, reward, done, info):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def reset(self):
|
||||
"""reset engine state for new episode"""
|
||||
self.step_count = 0
|
||||
|
||||
|
||||
class WildPricingEngine(BasePricingEngine):
|
||||
"""production-like pricing using online elasticity estimation via EWMA regression"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
# per-product unit costs (unknown to customers; known to platform)
|
||||
self.unit_cost = self.rng.uniform(8.0, 40.0, size=self.c.product_catelogue_size).astype(np.float32)
|
||||
# online elasticity estimate (start moderately elastic)
|
||||
self.e_hat = np.full((self.c.product_catelogue_size,), -1.3, dtype=np.float32)
|
||||
# EWMA state for log-log regression
|
||||
self.mu_logp = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.mu_logq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.cov_pq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.var_p = np.ones(self.c.product_catelogue_size, dtype=np.float32)
|
||||
# knobs typical in production
|
||||
self.lr = 0.08
|
||||
self.ewma = 0.05
|
||||
self.eps_explore = 0.03
|
||||
self.explore_scale = 0.03
|
||||
|
||||
def _safe_elasticity(self, e: np.ndarray) -> np.ndarray:
|
||||
return np.clip(e, -5.0, -1.05)
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.e_hat = np.full((self.c.product_catelogue_size,), -1.3, dtype=np.float32)
|
||||
self.mu_logp = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.mu_logq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.cov_pq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
self.var_p = np.ones(self.c.product_catelogue_size, dtype=np.float32)
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
# extract demand signal (from env observation) as proxy for sales
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||
return self._update_from_demand(current_prices, demand)
|
||||
|
||||
def _update_from_demand(self, prices: np.ndarray, sold: np.ndarray) -> np.ndarray:
|
||||
# log transforms (add 1 to handle zeros)
|
||||
logp = np.log(np.clip(prices, 1e-3, None)).astype(np.float32)
|
||||
logq = np.log(sold + 1.0).astype(np.float32)
|
||||
# EWMA moments for per-product regression: logq ≈ a + e*logp
|
||||
a = self.ewma
|
||||
dp = logp - self.mu_logp
|
||||
dq = logq - self.mu_logq
|
||||
self.mu_logp = (1 - a) * self.mu_logp + a * logp
|
||||
self.mu_logq = (1 - a) * self.mu_logq + a * logq
|
||||
self.cov_pq = (1 - a) * self.cov_pq + a * (dp * dq)
|
||||
self.var_p = (1 - a) * self.var_p + a * (dp * dp + 1e-6)
|
||||
e_new = self.cov_pq / (self.var_p + 1e-6)
|
||||
self.e_hat = self._safe_elasticity(0.9 * self.e_hat + 0.1 * e_new)
|
||||
# profit-optimal price for isoelastic demand (if e < -1)
|
||||
e = self.e_hat
|
||||
p_star = self.unit_cost * (e / (e + 1.0))
|
||||
# smooth toward p_star
|
||||
new_prices = (1 - self.lr) * prices + self.lr * p_star
|
||||
# exploration (small random perturbations)
|
||||
if self.rng.random() < self.eps_explore:
|
||||
noise = self.rng.normal(0.0, self.explore_scale, size=new_prices.shape).astype(np.float32)
|
||||
new_prices = new_prices * (1.0 + noise)
|
||||
# apply business guardrails (max change + bounds)
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = prices * ratio
|
||||
new_prices = np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
return new_prices
|
||||
|
||||
|
||||
class StaticPricingEngine(BasePricingEngine):
|
||||
"""baseline: fixed prices throughout episode"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.fixed_prices = None
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.fixed_prices = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.fixed_prices is None:
|
||||
self.fixed_prices = current_prices.copy()
|
||||
return self.fixed_prices.copy()
|
||||
|
||||
|
||||
class SimpleDemandEngine(BasePricingEngine):
|
||||
"""demand-driven pricing: increase price when demand rises, decrease when it falls"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.prev_demand = None
|
||||
self.lr = 0.05
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.prev_demand = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||
if self.prev_demand is None:
|
||||
self.prev_demand = demand.copy()
|
||||
return current_prices.copy()
|
||||
# simple rule: if demand increases, raise price; if decreases, lower price
|
||||
delta_d = demand - self.prev_demand
|
||||
price_adj = self.lr * np.sign(delta_d) * np.abs(delta_d) / (np.abs(self.prev_demand) + 1.0)
|
||||
new_prices = current_prices * (1.0 + price_adj)
|
||||
self.prev_demand = demand.copy()
|
||||
# apply constraints
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = current_prices * ratio
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
|
||||
|
||||
class RandomWalkEngine(BasePricingEngine):
|
||||
"""random walk pricing with mean reversion"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.target_price = None
|
||||
self.volatility = 0.02
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.target_price = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.target_price is None:
|
||||
self.target_price = current_prices.copy()
|
||||
# random walk with mean reversion toward target
|
||||
noise = self.rng.normal(0.0, self.volatility, size=current_prices.shape).astype(np.float32)
|
||||
reversion = 0.01 * (self.target_price - current_prices)
|
||||
new_prices = current_prices * (1.0 + noise) + reversion
|
||||
# apply constraints
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = current_prices * ratio
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
|
||||
|
||||
class ThompsonSamplingEngine(BasePricingEngine):
|
||||
"""bayesian bandit approach per product treating price as discrete action"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.n_price_levels = 5
|
||||
self.alpha = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.beta = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.price_grid = None
|
||||
self.last_actions = None
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.alpha = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.beta = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.price_grid = None
|
||||
self.last_actions = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.price_grid is None:
|
||||
# define price grid per product
|
||||
lo = current_prices * 0.7
|
||||
hi = current_prices * 1.3
|
||||
self.price_grid = np.linspace(lo, hi, self.n_price_levels).T
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||
# update beliefs based on last action
|
||||
if self.last_actions is not None:
|
||||
for i in range(self.c.product_catelogue_size):
|
||||
a = self.last_actions[i]
|
||||
reward = demand[i]
|
||||
if reward > 0.5:
|
||||
self.alpha[i, a] += reward
|
||||
else:
|
||||
self.beta[i, a] += 1.0
|
||||
# thompson sampling: sample from posterior, pick best
|
||||
new_prices = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||
actions = np.zeros(self.c.product_catelogue_size, dtype=int)
|
||||
for i in range(self.c.product_catelogue_size):
|
||||
theta = self.rng.beta(self.alpha[i], self.beta[i]).astype(np.float32)
|
||||
actions[i] = int(np.argmax(theta))
|
||||
new_prices[i] = self.price_grid[i, actions[i]]
|
||||
self.last_actions = actions
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
@@ -1,320 +0,0 @@
|
||||
from sys import intern
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
from matplotlib import interactive
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
import pandas as pd
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
|
||||
# "learner" agent learning to optimize pricing
|
||||
# "agent" part of environment creating demand signals that learner processes
|
||||
|
||||
@dataclass
|
||||
class BusinessLogicConstraints():
|
||||
max_price_adjustment: float = 0.30
|
||||
system_max_price: float = 500.0
|
||||
system_min_price: float = 1.0
|
||||
product_catelogue_size: int = 100
|
||||
episode_length: int = 200
|
||||
sessions_per_step: int = 250
|
||||
agent_share: float = 0.25
|
||||
agent_recon_multiplier: float = 6.0
|
||||
agent_purchase_probability: float = 0.20
|
||||
coi_strength: float = 0.25
|
||||
coi_threshold: float = 4.0
|
||||
coi_sigmoid_temp: float = 1.25
|
||||
base_human_demand: float = 0.08
|
||||
base_agent_demand: float = 0.05
|
||||
human_price_elasticity: float = -1.2 # assumptions here
|
||||
agent_price_elasticity: float = -0.6
|
||||
w_agent_loss: float = 1.0
|
||||
w_volatility: float = 5.0
|
||||
w_estimation_error: float = 0.25
|
||||
seed: int = 7
|
||||
|
||||
|
||||
def _sigmoid(x: np.ndarray) -> np.ndarray:
|
||||
return 1.0 / (1.0 + np.exp(-x))
|
||||
|
||||
class CommercePlatform:
|
||||
"""
|
||||
This is just an extension of the state management for the environment, it does not implement anything dynamic just helps us simulate demand.
|
||||
"""
|
||||
def __init__(self,
|
||||
product_catelogue_size: int,
|
||||
max_price: float,
|
||||
min_price: float,
|
||||
constraints: BusinessLogicConstraints):
|
||||
self.product_catelogue_size = product_catelogue_size
|
||||
self.product_supply = np.random.uniform(low=10, high=50, size=(self.product_catelogue_size,))
|
||||
self.max_price = max_price
|
||||
self.min_price = min_price
|
||||
self.constraints = constraints
|
||||
self.simulation_history: List[Dict[str, Any]] = []
|
||||
self._rng = np.random.default_rng(constraints.seed)
|
||||
self._last_interaction_df: pd.DataFrame = pd.DataFrame()
|
||||
|
||||
|
||||
def setup_true_demand(self, prices: np.ndarray) -> Dict[str, np.ndarray]:
|
||||
# ground truth purchase propensities
|
||||
p = np.clip(prices, self.min_price, self.max_price)
|
||||
pn = p / self.max_price
|
||||
human_prob = self.constraints.base_human_demand * (pn ** self.constraints.human_price_elasticity)
|
||||
agent_prob = self.constraints.base_agent_demand * (pn ** self.constraints.agent_price_elasticity)
|
||||
return {
|
||||
"human_purchase_prob": np.clip(human_prob, 0.0, 0.95),
|
||||
"agent_purchase_prob": np.clip(agent_prob, 0.0, 0.95)
|
||||
}
|
||||
|
||||
def _load_behavioral_profile(actor : str, demand_forcing):
|
||||
"""
|
||||
This returns a markov chain with average weights which we get from interaction data of our experiments.
|
||||
This defines transition probabilities between different events:
|
||||
search -> view_item_price_binN: 0.7
|
||||
view_item_price_binN -> add_to_cart: 0.2
|
||||
we also must reweight with the demand_forcing vector or purchase probabilities per-product
|
||||
"""
|
||||
|
||||
|
||||
def _simulate_sessions(self, base_prices: np.ndarray) -> pd.DataFrame:
|
||||
demand = self.setup_true_demand(base_prices)
|
||||
human_pprob = demand["human_purchase_prob"]
|
||||
agent_pprob = demand["agent_purchase_prob"]
|
||||
events: List[Dict[str, Any]] = []
|
||||
T = self.constraints.sessions_per_step
|
||||
n_agent_sessions = int(round(T * self.constraints.agent_share))
|
||||
n_human_sessions = T - n_agent_sessions
|
||||
n_agent_ids = max(1, n_agent_sessions // 2)
|
||||
session_map = {
|
||||
'humans': n_human_sessions,
|
||||
'agents': n_agent_ids
|
||||
}
|
||||
pprob_map = {
|
||||
'humans': human_pprob,
|
||||
'agents': agent_pprob
|
||||
}
|
||||
joint_events = []
|
||||
for actor, n_sessions in session_map.items():
|
||||
bp = _load_behavioral_profile(actor, pprob_map[actor])
|
||||
counter = 0
|
||||
events = []
|
||||
while counter < n_sessions:
|
||||
session_events = []
|
||||
while len(session_events) == 0 or session_events[-1]['action'] == 'checkout':
|
||||
interaction_event = bp.sample(self._rng)
|
||||
interaction_event['session_id'] = f'{actor}_{counter:06d}'
|
||||
# TODO any other assignments
|
||||
session_events.append(interaction_event)
|
||||
events.extend(session_events)
|
||||
counter += 1
|
||||
joint_events.extend(events)
|
||||
|
||||
return pd.DataFrame(joint_events)
|
||||
|
||||
def compute_interaction_features(self, interaction_df: pd.DataFrame) -> Dict[str, float]:
|
||||
if interaction_df.empty:
|
||||
return {"mean_sale_price": 0.0, "look_to_book": 0.0}
|
||||
purchases = interaction_df[interaction_df["action"] == "purchase"]
|
||||
mean_sale_price = float(purchases["price_paid"].mean()) if not purchases.empty else 0.0
|
||||
views = float((interaction_df["action"] == "view").sum())
|
||||
buys = float((interaction_df["action"] == "purchase").sum())
|
||||
return {"mean_sale_price": mean_sale_price, "look_to_book": float(views / (buys + 1e-6))}
|
||||
|
||||
def _session_feature_table(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
# TODO: adapt this
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
g = df.groupby("session_id", sort=False)
|
||||
session_duration = g["t"].max() - g["t"].min()
|
||||
total_interactions = g.size()
|
||||
avg_time_between = g["t"].apply(lambda x: float(np.diff(np.sort(x.to_numpy())).mean()) if len(x) > 1 else 0.0)
|
||||
interaction_velocity = total_interactions / (session_duration + 1e-6)
|
||||
views = g.apply(lambda x: int((x["action"] == "view").sum()), include_groups=False)
|
||||
cart_adds = g.apply(lambda x: int((x["action"] == "cart").sum()), include_groups=False)
|
||||
purchases = g.apply(lambda x: int((x["action"] == "purchase").sum()), include_groups=False)
|
||||
conversion_rate = purchases / (views + 1e-6)
|
||||
is_agent = g["actor"].apply(lambda s: bool((s == "agent").any()), include_groups=False)
|
||||
|
||||
return pd.DataFrame({
|
||||
"session_duration_sec": session_duration.astype(float),
|
||||
"avg_time_between_events": avg_time_between.astype(float),
|
||||
"total_interactions": total_interactions.astype(int),
|
||||
"interaction_velocity": interaction_velocity.astype(float),
|
||||
"item_views": views.astype(int),
|
||||
"cart_adds": cart_adds.astype(int),
|
||||
"purchases": purchases.astype(int),
|
||||
"conversion_rate": conversion_rate.astype(float),
|
||||
"is_agent": is_agent.astype(bool),
|
||||
}).reset_index()
|
||||
|
||||
def get_interaction_data(self) -> np.ndarray:
|
||||
if self._last_interaction_df.empty:
|
||||
return np.array([], dtype=object)
|
||||
return self._last_interaction_df.to_dict(orient="records")
|
||||
|
||||
|
||||
class PHANTOMEnv(gym.Env):
|
||||
metadata = {"render_modes": []}
|
||||
|
||||
def __init__(self, constraints):
|
||||
super().__init__()
|
||||
self.constraints = BusinessLogicConstraints()
|
||||
self.action_space = spaces.Box(low=-self.constraints.max_price_adjustment,
|
||||
high=self.constraints.max_price_adjustment,
|
||||
shape=(self.constraints.product_catelogue_size,), dtype=np.float32)
|
||||
self.observation_space = spaces.Dict({
|
||||
"elasticity": spaces.Dict({
|
||||
"price": spaces.Box(
|
||||
low=np.full((self.constraints.product_catelogue_size,), self.constraints.system_min_price, dtype=np.float32),
|
||||
high=np.full((self.constraints.product_catelogue_size,), self.constraints.system_max_price, dtype=np.float32),
|
||||
dtype=np.float32),
|
||||
"demand": spaces.Box(
|
||||
low=np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32),
|
||||
high=np.full((self.constraints.product_catelogue_size,), 1e6, dtype=np.float32),
|
||||
dtype=np.float32),
|
||||
})
|
||||
# TODO: define more features that we compute from the interaction data
|
||||
})
|
||||
self.commerce_platform = CommercePlatform(
|
||||
product_catelogue_size=self.constraints.product_catelogue_size,
|
||||
max_price=self.constraints.system_max_price,
|
||||
min_price=self.constraints.system_min_price,
|
||||
constraints=self.constraints)
|
||||
self._rng = np.random.default_rng(self.constraints.seed)
|
||||
self.t = 0
|
||||
self._prev_prices: Optional[np.ndarray] = None
|
||||
self.state: Dict[str, Any] = {}
|
||||
|
||||
def reset(self, seed: Optional[int] = None, options: Optional[dict] = None):
|
||||
super().reset(seed=seed)
|
||||
if seed is not None:
|
||||
self._rng = np.random.default_rng(seed)
|
||||
self.commerce_platform._rng = np.random.default_rng(seed)
|
||||
self.t = 0
|
||||
init_prices = self._rng.uniform(low=60.0, high=140.0, size=(self.constraints.product_catelogue_size,)).astype(np.float32)
|
||||
self._prev_prices = init_prices.copy()
|
||||
self.state = {
|
||||
"elasticity": {
|
||||
"price": init_prices,
|
||||
"demand": np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32),
|
||||
}
|
||||
}
|
||||
return self.state, {}
|
||||
|
||||
def step(self, action: np.ndarray):
|
||||
self.t += 1
|
||||
base_prices = self.state["elasticity"]["price"].astype(np.float32)
|
||||
new_prices = np.clip(base_prices * (1.0 + action.astype(np.float32)),
|
||||
self.constraints.system_min_price,
|
||||
self.constraints.system_max_price).astype(np.float32)
|
||||
|
||||
self.state["elasticity"]["price"] = new_prices
|
||||
# TODO: use the commerce platform to simulate sessions
|
||||
interactions_df = self.commerce_platform._simulate_sessions(new_prices)
|
||||
result = self.commerce_platform.compute_interaction_features(interactions_df)
|
||||
# TODO: implement COI computation to use in reward
|
||||
COI = 0.0
|
||||
|
||||
volatility = 0.0 if self._prev_prices is None else \
|
||||
float(np.mean(np.abs((new_prices - self._prev_prices) / (self._prev_prices + 1e-6))))
|
||||
self._prev_prices = new_prices.copy()
|
||||
|
||||
revenue_observed = float(result["revenue_observed"])
|
||||
agent_loss = float(result["agent_loss"])
|
||||
|
||||
reward = (revenue_observed
|
||||
- COI
|
||||
- self.constraints.w_agent_loss * agent_loss
|
||||
- self.constraints.w_volatility * volatility
|
||||
- self.constraints.w_estimation_error
|
||||
)
|
||||
|
||||
terminated = self.t >= self.constraints.episode_length
|
||||
info = {
|
||||
"t": self.t,
|
||||
"revenue_observed": revenue_observed,
|
||||
"revenue_oracle": float(result["revenue_oracle"]),
|
||||
"agent_loss": agent_loss,
|
||||
"ux_volatility": volatility,
|
||||
"mean_internal_error": err_mean,
|
||||
"look_to_book": float(result["interaction_features"].get("look_to_book", 0.0)),
|
||||
"mean_sale_price": float(result["interaction_features"].get("mean_sale_price", 0.0)),
|
||||
"true_human_purchases_total": float(np.sum(result["true_human_demand"])),
|
||||
"true_agent_purchases_total": float(np.sum(result["true_agent_purchases"])),
|
||||
}
|
||||
return self.state, float(reward), terminated, False, info
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import matplotlib.pyplot as plt
|
||||
from collections import defaultdict
|
||||
|
||||
runs = {}
|
||||
for use_defense in (False, True):
|
||||
env = PHANTOMEnv(use_defense=use_defense)
|
||||
obs, _ = env.reset(seed=42)
|
||||
metrics = defaultdict(list)
|
||||
total_reward = 0.0
|
||||
done = False
|
||||
|
||||
while not done:
|
||||
action = env.action_space.sample()
|
||||
obs, reward, done, _, info = env.step(action)
|
||||
total_reward += reward
|
||||
p_mean = float(np.mean(obs["elasticity"]["price"]))
|
||||
q_mean = float(np.mean(obs["elasticity"]["demand"]))
|
||||
p_std = float(np.std(obs["elasticity"]["price"]))
|
||||
|
||||
metrics['t'].append(info['t'])
|
||||
metrics['price_mean'].append(p_mean)
|
||||
metrics['price_std'].append(p_std)
|
||||
metrics['demand_mean'].append(q_mean)
|
||||
metrics['revenue_observed'].append(info['revenue_observed'])
|
||||
metrics['revenue_oracle'].append(info['revenue_oracle'])
|
||||
metrics['agent_loss'].append(info['agent_loss'])
|
||||
metrics['ux_volatility'].append(info['ux_volatility'])
|
||||
metrics['look_to_book'].append(info['look_to_book'])
|
||||
metrics['reward'].append(reward)
|
||||
metrics['human_purchases'].append(info['true_human_purchases_total'])
|
||||
metrics['agent_purchases'].append(info['true_agent_purchases_total'])
|
||||
|
||||
if info['t'] % 20 == 0 or done:
|
||||
print(f"defense={'ON ' if use_defense else 'OFF'} t={info['t']:03d} p={p_mean:6.2f}±{p_std:4.2f} "
|
||||
f"q={q_mean:6.2f} rev={info['revenue_observed']:7.2f} oracle={info['revenue_oracle']:7.2f} "
|
||||
f"loss={info['agent_loss']:6.2f} ux={info['ux_volatility']:.3f} "
|
||||
f"ltb={info['look_to_book']:5.2f} r={reward:7.2f}")
|
||||
|
||||
runs[use_defense] = metrics
|
||||
print(f"defense={'ON ' if use_defense else 'OFF'} total_reward={total_reward:.2f}\n")
|
||||
|
||||
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
|
||||
fig.suptitle('PHANTOM Environment: Defense OFF vs ON', fontsize=14, fontweight='bold')
|
||||
|
||||
plot_configs = [
|
||||
('price_mean', 'Mean Price', 'Price'),
|
||||
('demand_mean', 'Mean Demand Estimate', 'Demand'),
|
||||
('revenue_observed', 'Revenue (Observed)', 'Revenue'),
|
||||
('agent_loss', 'Agent Loss (Oracle - Observed)', 'Loss'),
|
||||
('ux_volatility', 'UX Volatility (Price Change)', 'Volatility'),
|
||||
('look_to_book', 'Look-to-Book Ratio', 'Ratio'),
|
||||
('reward', 'Step Reward', 'Reward'),
|
||||
('human_purchases', 'Human Purchases', 'Count'),
|
||||
('agent_purchases', 'Agent Purchases', 'Count'),
|
||||
]
|
||||
|
||||
for idx, (key, title, ylabel) in enumerate(plot_configs):
|
||||
ax = axes[idx // 3, idx % 3]
|
||||
for use_defense, label, color in [(False, 'No Defense', 'red'), (True, 'With Defense', 'blue')]:
|
||||
m = runs[use_defense]
|
||||
ax.plot(m['t'], m[key], label=label, color=color, alpha=0.7, linewidth=1.5)
|
||||
ax.set_xlabel('Step')
|
||||
ax.set_ylabel(ylabel)
|
||||
ax.set_title(title, fontsize=10, fontweight='bold')
|
||||
ax.legend(loc='best', fontsize=8)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig('phantom_env_comparison.png', dpi=150, bbox_inches='tight')
|
||||
print("Plot saved to phantom_env_comparison.png")
|
||||
plt.show()
|
||||
149
sim/rl/train.py
149
sim/rl/train.py
@@ -1,149 +0,0 @@
|
||||
import numpy as np
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Type, Optional
|
||||
import pickle
|
||||
from torch import neg_
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from environment import PHANTOMEnv, FastTrainingConstraints, BusinessLogicConstraints
|
||||
from engine import (BasePricingEngine, WildPricingEngine, StaticPricingEngine,
|
||||
SimpleDemandEngine, RandomWalkEngine, ThompsonSamplingEngine)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
"""
|
||||
Target training loop:
|
||||
have base prices p0 from env reset and run the env step, collect reward and metrics
|
||||
pass this to the pricing engine which computes the price action to take based on previous reward by learning
|
||||
the new action gets passed to the step
|
||||
so we alternate, step -> reward -> engine (produces price delta) -> step with price delta -> reward
|
||||
to make sure the reinforcement learning inside the engine can learn we need to have trajectory of prices
|
||||
CURRENT SOLUTION BELOW does not implement correct learning or updates.
|
||||
"""
|
||||
|
||||
class EngineTrainer:
|
||||
"""wrapper to run pricing engines through episodes and collect metrics"""
|
||||
def __init__(self, engine: BasePricingEngine, env: PHANTOMEnv,
|
||||
tb_writer: Optional[SummaryWriter] = None):
|
||||
self.engine = engine
|
||||
self.env = env
|
||||
self.episode_metrics = []
|
||||
self.tb_writer = tb_writer
|
||||
self.global_step = 0
|
||||
|
||||
def train(self, n_episodes: int, seed: int = 42):
|
||||
|
||||
obs, _ = self.env.reset(seed=seed)
|
||||
prices = None
|
||||
for ep in range(n_episodes):
|
||||
prices = self.engine.compute_prices(prices, obs)
|
||||
obs, reward, done, _, info = self.env.step(prices)
|
||||
self.engine.update(obs, reward, done, info)
|
||||
return self
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return self.episode_metrics
|
||||
|
||||
def evaluate(self, n_episodes: int = 10, seed: int = 100) -> Dict:
|
||||
"""evaluate trained engine"""
|
||||
results = {k: [] for k in ['total_reward', 'revenue_observed', 'revenue_oracle',
|
||||
'agent_loss', 'ux_volatility', 'look_to_book']}
|
||||
for ep in range(n_episodes):
|
||||
metrics = self.run_episode(seed=seed + ep)
|
||||
for k in results: results[k].append(metrics[k])
|
||||
return {k: (np.mean(v), np.std(v)) for k, v in results.items()}
|
||||
|
||||
|
||||
def make_env(fast: bool = True):
|
||||
constraints = FastTrainingConstraints() if fast else BusinessLogicConstraints()
|
||||
return PHANTOMEnv(constraints=constraints)
|
||||
|
||||
|
||||
def train_engine(engine_cls: Type[BasePricingEngine], env: PHANTOMEnv,
|
||||
n_episodes: int, seed: int = 42,
|
||||
tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
|
||||
constraints = env.constraints
|
||||
engine = engine_cls(constraints=constraints, seed=seed)
|
||||
trainer = EngineTrainer(engine, env, tb_writer=tb_writer)
|
||||
trainer.train(n_episodes, seed=seed)
|
||||
return trainer
|
||||
|
||||
|
||||
def save_trainer(trainer: EngineTrainer, path: Path):
|
||||
"""save engine state and metrics"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, 'wb') as f:
|
||||
pickle.dump({
|
||||
'engine': trainer.engine,
|
||||
'metrics': trainer.episode_metrics
|
||||
}, f)
|
||||
logger.info(f"Saved trainer to {path}")
|
||||
|
||||
|
||||
def load_trainer(path: Path, env: PHANTOMEnv,
|
||||
tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
|
||||
"""load saved engine"""
|
||||
with open(path, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
trainer = EngineTrainer(data['engine'], env, tb_writer=tb_writer)
|
||||
trainer.episode_metrics = data['metrics']
|
||||
return trainer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
base_dir = Path("./runs")
|
||||
base_dir.mkdir(exist_ok=True)
|
||||
|
||||
engines = {
|
||||
"Wild": WildPricingEngine,
|
||||
"Static": StaticPricingEngine,
|
||||
# "SimpleDemand": SimpleDemandEngine,
|
||||
"RandomWalk": RandomWalkEngine,
|
||||
"ThompsonSampling": ThompsonSamplingEngine,
|
||||
}
|
||||
defenses = [False, True]
|
||||
n_train_episodes = 50
|
||||
n_eval_episodes = 10
|
||||
seed = 42
|
||||
fast_mode = True
|
||||
|
||||
logger.info(f"Training config: {n_train_episodes} episodes per engine, fast_mode={fast_mode}")
|
||||
|
||||
trained_trainers = {}
|
||||
|
||||
for engine_name, engine_cls in engines.items():
|
||||
for use_defense in defenses:
|
||||
defense_label = "defense_on" if use_defense else "defense_off"
|
||||
run_name = f"{engine_name}_{defense_label}"
|
||||
log_dir = base_dir / run_name
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"Training {engine_name} with defense={use_defense}")
|
||||
logger.info(f"Log directory: {log_dir}")
|
||||
|
||||
env = make_env(fast=fast_mode)
|
||||
tb_writer = SummaryWriter(log_dir=str(log_dir))
|
||||
trainer = train_engine(engine_cls, env, n_train_episodes, seed, tb_writer=tb_writer)
|
||||
tb_writer.close()
|
||||
|
||||
save_path = log_dir / "trainer.pkl"
|
||||
save_trainer(trainer, save_path)
|
||||
|
||||
trained_trainers[run_name] = (trainer, env)
|
||||
|
||||
logger.info("Starting evaluation")
|
||||
|
||||
for run_name, (trainer, env) in trained_trainers.items():
|
||||
logger.info(f"Evaluating {run_name}")
|
||||
results = trainer.evaluate(n_episodes=n_eval_episodes, seed=seed + 1000)
|
||||
for metric, (mean, std) in results.items():
|
||||
logger.info(f" {metric:20s}: {mean:10.2f} ± {std:6.2f}")
|
||||
|
||||
logger.info(f"Results saved to: {base_dir}")
|
||||
@@ -1 +0,0 @@
|
||||
"""E2E test suite for PHANTOM dynamic pricing pipeline."""
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
type TestFixtures = {
|
||||
backendUrl: string;
|
||||
pricingUrl: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures>({
|
||||
backendUrl: async ({}, use) => {
|
||||
await use(process.env.BACKEND_URL || 'http://localhost:5000');
|
||||
},
|
||||
pricingUrl: async ({}, use) => {
|
||||
await use(process.env.PRICING_PROVIDER_URL || 'http://localhost:5001');
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -1,69 +0,0 @@
|
||||
interface PriceResponse {
|
||||
price: number;
|
||||
base_price: number;
|
||||
markup: number;
|
||||
model_version?: string;
|
||||
}
|
||||
|
||||
export async function fetchPrice(
|
||||
baseUrl: string,
|
||||
productId: string,
|
||||
mode: string = 'simple_surge',
|
||||
sessionId?: string
|
||||
): Promise<PriceResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (sessionId) params.set('sessionId', sessionId);
|
||||
|
||||
const url = `${baseUrl}/api/pricing?mode=${mode}&productId=${productId}&${params}`;
|
||||
const resp = await fetch(url);
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Price fetch failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function waitForPriceChange(
|
||||
baseUrl: string,
|
||||
productId: string,
|
||||
baselinePrice: number,
|
||||
mode: string,
|
||||
sessionId?: string,
|
||||
maxRetries: number = 10,
|
||||
pollInterval: number = 500
|
||||
): Promise<PriceResponse> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const priceResp = await fetchPrice(baseUrl, productId, mode, sessionId);
|
||||
if (Math.abs(priceResp.price - baselinePrice) > 0.01) {
|
||||
return priceResp;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollInterval));
|
||||
}
|
||||
|
||||
throw new Error(`Price did not change after ${maxRetries} retries`);
|
||||
}
|
||||
|
||||
export async function ingestEvent(
|
||||
baseUrl: string,
|
||||
sessionId: string,
|
||||
event: string,
|
||||
productId?: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const resp = await fetch(`${baseUrl}/api/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
event,
|
||||
productId,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Event ingest failed: ${resp.status}`);
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export async function getSessionId(page: Page): Promise<string | null> {
|
||||
const cookies = await page.context().cookies();
|
||||
const sessionCookie = cookies.find(c => c.name === 'phantom_session_id');
|
||||
return sessionCookie?.value || null;
|
||||
}
|
||||
|
||||
export async function verifySessionConsistency(page: Page, expectedSessionId: string): Promise<boolean> {
|
||||
const currentSessionId = await getSessionId(page);
|
||||
return currentSessionId === expectedSessionId;
|
||||
}
|
||||
|
||||
export async function createFreshSession(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string> {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sid = await getSessionId(page);
|
||||
if (!sid) throw new Error('Session not created');
|
||||
return sid;
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
destination?: string;
|
||||
checkIn?: string;
|
||||
guests?: number;
|
||||
rooms?: number;
|
||||
origin?: string;
|
||||
departure?: string;
|
||||
adults?: number;
|
||||
}
|
||||
|
||||
export async function performSearch(page: Page, params: SearchParams, storeType: 'hotel' | 'airline' = 'hotel' ): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (storeType === 'hotel') {
|
||||
const destInput = page.locator('input#destination');
|
||||
await destInput.fill(params.destination || 'New York');
|
||||
|
||||
const checkInInput = page.locator('input#checkIn');
|
||||
const checkInDate = params.checkIn || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
|
||||
await checkInInput.fill(checkInDate);
|
||||
|
||||
const searchBtn = page.locator('button:has-text("Search Rooms")');
|
||||
await searchBtn.click();
|
||||
} else {
|
||||
const originDropdown = page.locator('button:has-text("Select origin")').or(
|
||||
page.locator('[id="origin"]').locator('button').first()
|
||||
);
|
||||
await originDropdown.click();
|
||||
await page.waitForTimeout(200);
|
||||
const originOption = page.locator(`button:has-text("${params.origin || 'JFK'}")`).first();
|
||||
await originOption.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const destDropdown = page.locator('button:has-text("Select destination")').or(
|
||||
page.locator('[id="destination"]').locator('button').first()
|
||||
);
|
||||
await destDropdown.click();
|
||||
await page.waitForTimeout(200);
|
||||
const destOption = page.locator(`button:has-text("${params.destination || 'LAX'}")`).first();
|
||||
await destOption.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const departInput = page.locator('input#departDate');
|
||||
const departDate = params.departure || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
|
||||
await departInput.fill(departDate);
|
||||
|
||||
const searchBtn = page.locator('button:has-text("Search Flights")');
|
||||
await searchBtn.click();
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export async function selectRandomProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cardClass = storeType === 'hotel' ? '.hotel-card' : '.flight-card';
|
||||
const productCards = page.locator(cardClass);
|
||||
|
||||
const count = await productCards.count();
|
||||
if (count === 0) throw new Error('No products found on listing page');
|
||||
|
||||
const randomIdx = Math.floor(Math.random() * count);
|
||||
return randomIdx.toString();
|
||||
}
|
||||
|
||||
export async function openProductFromListing(page: Page, productId?: string): Promise<string> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hotelCards = page.locator('.hotel-card');
|
||||
const flightCards = page.locator('.flight-card');
|
||||
|
||||
const hotelCount = await hotelCards.count();
|
||||
const flightCount = await flightCards.count();
|
||||
|
||||
let productCards;
|
||||
if (hotelCount > 0) {
|
||||
productCards = hotelCards;
|
||||
} else if (flightCount > 0) {
|
||||
productCards = flightCards;
|
||||
} else {
|
||||
throw new Error('No products found on listing page');
|
||||
}
|
||||
|
||||
const count = await productCards.count();
|
||||
const randomIdx = productId ? 0 : Math.floor(Math.random() * count);
|
||||
await productCards.nth(randomIdx).click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const url = page.url();
|
||||
const match = url.match(/\/products\/([^/?]+)/);
|
||||
if (!match) throw new Error('Cannot parse product ID from URL after navigation');
|
||||
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export async function getPriceFromDOM(page: Page): Promise<number> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.waitForSelector('.price-amount', { timeout: 15000 }).catch(() => null);
|
||||
|
||||
const priceSelectors = [
|
||||
'.price-amount',
|
||||
'.price-display',
|
||||
'[data-testid="price"]',
|
||||
'[data-price]',
|
||||
];
|
||||
|
||||
for (const selector of priceSelectors) {
|
||||
const priceEl = page.locator(selector).first();
|
||||
if (await priceEl.count() > 0) {
|
||||
const text = await priceEl.textContent();
|
||||
if (!text) continue;
|
||||
|
||||
const match = text.match(/[\$]?\s*([\d,]+(?:\.\d{2})?)/);
|
||||
if (match) {
|
||||
const priceStr = match[1].replace(/,/g, '');
|
||||
return parseFloat(priceStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataPrice = await page.locator('[data-price]').first().getAttribute('data-price').catch(() => null);
|
||||
if (dataPrice) return parseFloat(dataPrice);
|
||||
|
||||
throw new Error('Cannot extract price from DOM');
|
||||
}
|
||||
|
||||
export async function navigateToProduct(page: Page,productId: string,storeType: 'hotel' | 'airline' = 'hotel'): Promise<void> {
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export async function viewProductViaFlow(page: Page, storeType: 'hotel' | 'airline' = 'hotel', searchParams?: SearchParams): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('dateIndex', '7');
|
||||
|
||||
if (storeType === 'hotel') {
|
||||
params.set('destination', searchParams?.destination || 'New York');
|
||||
params.set('adults', '2');
|
||||
params.set('rooms', '1');
|
||||
} else {
|
||||
params.set('origin', searchParams?.origin || 'JFK');
|
||||
params.set('destination', searchParams?.destination || 'LAX');
|
||||
params.set('adults', '1');
|
||||
params.set('children', '0');
|
||||
params.set('infants', '0');
|
||||
}
|
||||
|
||||
await page.goto(`/products?${params.toString()}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const productId = await openProductFromListing(page);
|
||||
await page.waitForTimeout(500);
|
||||
return productId;
|
||||
}
|
||||
|
||||
export async function rapidViewProductViaFlow(page: Page, count: number, delayMs: number = 100, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string[]> {
|
||||
const productIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const productId = await viewProductViaFlow(page, storeType);
|
||||
productIds.push(productId);
|
||||
|
||||
await page.waitForTimeout(delayMs);
|
||||
}
|
||||
|
||||
return productIds;
|
||||
}
|
||||
|
||||
export async function humanLikeViewProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel'
|
||||
): Promise<string> {
|
||||
const productId = await viewProductViaFlow(page, storeType);
|
||||
|
||||
await page.hover('h1');
|
||||
await page.waitForTimeout(800 + Math.random() * 400);
|
||||
|
||||
await page.mouse.wheel(0, 200);
|
||||
await page.waitForTimeout(500 + Math.random() * 300);
|
||||
|
||||
const paragraphs = await page.locator('p').all();
|
||||
if (paragraphs.length > 0) {
|
||||
await paragraphs[0].hover();
|
||||
await page.waitForTimeout(600 + Math.random() * 400);
|
||||
}
|
||||
|
||||
return productId;
|
||||
}
|
||||
|
||||
export async function addToCart(page: Page): Promise<void> {
|
||||
const addBtn = page.locator('button:has-text("Add to Cart")');
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
interface InteractionEvent {
|
||||
sessionId: string;
|
||||
event: string;
|
||||
productId?: string;
|
||||
timestamp: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
const dumpKafkaTopic = async (backendUrl: string, topic: string) => {
|
||||
const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`);
|
||||
if (!resp.ok) throw new Error(`Kafka dump failed: ${resp.status}`);
|
||||
const { data = [] } = await resp.json();
|
||||
return data as any[];
|
||||
};
|
||||
|
||||
export const waitForInteractionEvent = async (
|
||||
backendUrl: string,
|
||||
sessionId: string,
|
||||
eventType: string,
|
||||
maxRetries = 10,
|
||||
pollInterval = 500
|
||||
): Promise<InteractionEvent | null> => {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const msgs = await dumpKafkaTopic(backendUrl, "user-interactions");
|
||||
const hit = msgs.find(m => m.sessionId === sessionId && m.event === eventType);
|
||||
if (hit) return hit as InteractionEvent;
|
||||
await new Promise<void>(r => setTimeout(r, pollInterval));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const countProductViews = async (backendUrl: string, productId: string) =>
|
||||
(await dumpKafkaTopic(backendUrl, "user-interactions")).reduce(
|
||||
(n, m) => n + (m.productId === productId && m.event === "view_item_page" ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
export const getSessionEvents = async (backendUrl: string, sessionId: string) =>
|
||||
(await dumpKafkaTopic(backendUrl, "user-interactions")).filter(m => m.sessionId === sessionId);
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "e2e",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:debug": "playwright test --debug"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/node": "^25.0.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './scenarios',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.WEB_URL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
timeout: 180000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import {
|
||||
createFreshSession,
|
||||
viewProductViaFlow,
|
||||
rapidViewProductViaFlow,
|
||||
humanLikeViewProduct,
|
||||
getPriceFromDOM,
|
||||
verifySessionConsistency,
|
||||
addToCart,
|
||||
} from '../helpers/interactions';
|
||||
import { getSessionEvents } from '../helpers/kafka';
|
||||
import { runSessionPricing } from '../helpers/airflow';
|
||||
|
||||
test.describe('SessionAwarePricer E2E', () => {
|
||||
const STORE_TYPE = 'hotel';
|
||||
|
||||
test('baseline: human-like behavior maintains base price', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId1 = await humanLikeViewProduct(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const productId2 = await humanLikeViewProduct(page, STORE_TYPE);
|
||||
|
||||
await runSessionPricing(STORE_TYPE);
|
||||
|
||||
const secondPrice = await getPriceFromDOM(page);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
|
||||
expect(Math.abs(secondPrice - baselinePrice) / baselinePrice).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
test('agent detection: rapid robot-like behavior increases price', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await rapidViewProductViaFlow(page, 8, 100, STORE_TYPE);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const events = await getSessionEvents(backendUrl, sessionId);
|
||||
expect(events.length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
await runSessionPricing(STORE_TYPE);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const agentPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(agentPrice).toBeGreaterThan(baselinePrice);
|
||||
expect((agentPrice - baselinePrice) / baselinePrice).toBeGreaterThan(0.01);
|
||||
});
|
||||
|
||||
test('velocity threshold: high event rate triggers detection', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 10, 80, STORE_TYPE);
|
||||
|
||||
const events = await getSessionEvents(backendUrl, sessionId);
|
||||
expect(events.length).toBeGreaterThanOrEqual(10);
|
||||
|
||||
await runSessionPricing(STORE_TYPE);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const agentPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(agentPrice).toBeGreaterThan(baselinePrice);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('cart ratio: high cart/view ratio signals intent', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await addToCart(page);
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const cartPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(cartPrice).toBeGreaterThanOrEqual(baselinePrice);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mixed behavior: occasional fast actions tolerated', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId1 = await humanLikeViewProduct(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await rapidViewProductViaFlow(page, 2, 150, STORE_TYPE);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await humanLikeViewProduct(page, STORE_TYPE);
|
||||
|
||||
await runSessionPricing(STORE_TYPE);
|
||||
|
||||
const finalPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(Math.abs(finalPrice - baselinePrice) / baselinePrice).toBeLessThan(0.3);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('session isolation: agent behavior in one session does not affect others', async ({
|
||||
page,
|
||||
context,
|
||||
backendUrl,
|
||||
}) => {
|
||||
const sessionIdA = await createFreshSession(page, STORE_TYPE);
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const basePrice = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 10, 100, STORE_TYPE);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const agentPrice = await getPriceFromDOM(page);
|
||||
expect(agentPrice).toBeGreaterThan(basePrice * 0.99);
|
||||
|
||||
const page2 = await context.newPage();
|
||||
const sessionIdB = await createFreshSession(page2, STORE_TYPE);
|
||||
|
||||
await page2.goto(`/products/${productId}`);
|
||||
await page2.waitForLoadState('networkidle');
|
||||
const cleanPrice = await getPriceFromDOM(page2);
|
||||
|
||||
expect(Math.abs(cleanPrice - basePrice) / basePrice).toBeLessThan(0.1);
|
||||
expect(sessionIdA).not.toBe(sessionIdB);
|
||||
});
|
||||
|
||||
test('session persistence: session ID maintained across views', async ({ page }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
await viewProductViaFlow(page, STORE_TYPE);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
|
||||
await viewProductViaFlow(page, STORE_TYPE);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
|
||||
await viewProductViaFlow(page, STORE_TYPE);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import {
|
||||
createFreshSession,
|
||||
viewProductViaFlow,
|
||||
rapidViewProductViaFlow,
|
||||
getPriceFromDOM,
|
||||
verifySessionConsistency,
|
||||
} from '../helpers/interactions';
|
||||
import { waitForInteractionEvent, countProductViews } from '../helpers/kafka';
|
||||
import { runSurgePricing } from '../helpers/airflow';
|
||||
|
||||
test.describe('SimpleSurgePricer E2E', () => {
|
||||
const STORE_TYPE = 'hotel';
|
||||
|
||||
test('baseline: initial price equals base price', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const price = await getPriceFromDOM(page);
|
||||
|
||||
expect(price).toBeGreaterThan(0);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('surge: rapid views trigger price increase', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const evt = await waitForInteractionEvent(backendUrl, sessionId, 'view_item_page');
|
||||
expect(evt).not.toBeNull();
|
||||
|
||||
const viewCount = await countProductViews(backendUrl, productId);
|
||||
expect(viewCount).toBeGreaterThanOrEqual(5);
|
||||
|
||||
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const surgedPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(surgedPrice).toBeGreaterThan(baselinePrice);
|
||||
expect((surgedPrice - baselinePrice) / baselinePrice).toBeGreaterThan(0.01);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('threshold: price unchanged below threshold', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 2, 300, STORE_TYPE);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const currentPrice = await getPriceFromDOM(page);
|
||||
|
||||
expect(Math.abs(currentPrice - baselinePrice) / baselinePrice).toBeLessThan(0.05);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('window: surge decays after window expires', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const baselinePrice = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 5, 150, STORE_TYPE);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const surgedPrice = await getPriceFromDOM(page);
|
||||
expect(surgedPrice).toBeGreaterThan(baselinePrice);
|
||||
|
||||
await page.waitForTimeout(12000);
|
||||
|
||||
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const decayedPrice = await getPriceFromDOM(page);
|
||||
expect(decayedPrice).toBeLessThan(surgedPrice);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('isolation: different products have independent surge', async ({ page, backendUrl }) => {
|
||||
const sessionId = await createFreshSession(page, STORE_TYPE);
|
||||
|
||||
const productIdA = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const basePriceA = await getPriceFromDOM(page);
|
||||
|
||||
await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.goto(`/products/${productIdA}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const surgedPriceA = await getPriceFromDOM(page);
|
||||
|
||||
const productIdB = await viewProductViaFlow(page, STORE_TYPE);
|
||||
const priceB = await getPriceFromDOM(page);
|
||||
|
||||
expect(surgedPriceA).toBeGreaterThan(basePriceA * 0.99);
|
||||
expect(productIdA).not.toBe(productIdB);
|
||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -30,8 +30,6 @@ export async function GET(req: NextRequest) {
|
||||
const providerUrl = process.env.PRICING_PROVIDER_URL || 'http://localhost:5001';
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
// THIS is our entry point into the dynamic pricing where we reference the context of the sesion and experiment and ask for a price to assign to the trajectory which is expressed
|
||||
// The whole pipeline gets triggered from here.
|
||||
if (sessionId) queryParams.append('sessionId', sessionId);
|
||||
if (experimentId) queryParams.append('experimentId', experimentId);
|
||||
|
||||
@@ -57,26 +55,25 @@ export async function GET(req: NextRequest) {
|
||||
price = Math.round(randomBase * 100) / 100;
|
||||
}
|
||||
|
||||
// log price to kafka asynchronously (non-blocking)
|
||||
// log price to kafka for elasticity computation
|
||||
if (sessionId) {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
// fire and forget - don't await to avoid blocking response
|
||||
fetch(`${backendUrl}/api/kafka/price-log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
price,
|
||||
sessionId,
|
||||
experimentId: experimentId || undefined,
|
||||
storeMode,
|
||||
ts: timestamp,
|
||||
}),
|
||||
}).catch(err => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('[price-log-error]', err);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
price,
|
||||
sessionId,
|
||||
experimentId: experimentId || undefined,
|
||||
storeMode,
|
||||
ts: timestamp,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[price-log-error]', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
||||
@@ -32,8 +32,7 @@ export default function CartPage() {
|
||||
{itemCount > 0 && (
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: 'var(--accent-warning)' }}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Clear cart
|
||||
</button>
|
||||
@@ -43,7 +42,7 @@ export default function CartPage() {
|
||||
{itemCount === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
||||
<a href="/" className="hover:underline" style={{ color: 'var(--text-accent)' }}>Browse our selection</a>
|
||||
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -55,11 +54,15 @@ export default function CartPage() {
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
{item.type}
|
||||
</span>
|
||||
<h3 className="font-semibold">{item.name}</h3>
|
||||
</div>
|
||||
|
||||
{item.type === 'hotel' && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>{String(item.metadata.roomType)}</p>
|
||||
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
||||
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
@@ -78,8 +81,7 @@ export default function CartPage() {
|
||||
<p className="text-xl font-bold mb-2">${item.price}</p>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id, item.type)}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: 'var(--accent-warning)' }}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -98,7 +100,7 @@ export default function CartPage() {
|
||||
dispatchInteraction('checkout_start', undefined, { total, itemCount });
|
||||
window.location.href = '/checkout';
|
||||
}}
|
||||
className="btn-primary w-full"
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
--bg-secondary: #f5f5f5;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--accent-primary: #007aff;
|
||||
--accent-primary-hover: #0051d5;
|
||||
--accent-primary-light: #e6f2ff;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 32px;
|
||||
|
||||
@@ -15,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Travel Booking Platform",
|
||||
description: "Book flights and hotels with dynamic pricing",
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { EventName } from '@/lib/events';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import { getHotelImageUrl } from '@/lib/hotel-utils';
|
||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
@@ -48,6 +47,8 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
window.location.href = `/hotel/products/${hotel.id}`;
|
||||
};
|
||||
|
||||
const imageUrl = `https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=400&h=300&fit=crop`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hotel-card cursor-pointer"
|
||||
@@ -55,7 +56,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
>
|
||||
<div className="hotel-image relative overflow-hidden">
|
||||
<img
|
||||
src={getHotelImageUrl(hotel.id, { w: 400, h: 300 })}
|
||||
src={imageUrl}
|
||||
alt={hotel.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import { getHotelImageUrl } from '@/lib/hotel-utils';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
interface HotelDetailsProps {
|
||||
@@ -44,11 +43,13 @@ const PriceTotalDisplay = ({ productId, nights }: { productId: string; nights: n
|
||||
};
|
||||
|
||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||
const imageUrl = `https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&h=600&fit=crop`;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||
<div className="w-full lg:w-1/2 rounded-lg aspect-[4/3] overflow-hidden shrink-0">
|
||||
<img
|
||||
src={getHotelImageUrl(product.id, { w: 800, h: 600 })}
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const NavLink = ({ href, children }: { href: string; children: React.ReactNode }
|
||||
href={href}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||
? 'bg-[var(--accent-primary)] font-semibold'
|
||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Flight {
|
||||
availability: number;
|
||||
}
|
||||
|
||||
import { dateToDaysFromToday, dateToIndex, todayIndex } from './date-utils';
|
||||
const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: AirlineProduct): Flight => {
|
||||
const { id, flight_type, date_index, metadata, availability } = p;
|
||||
@@ -52,4 +52,24 @@ export const transformProduct = (p: AirlineProduct): Flight => {
|
||||
};
|
||||
};
|
||||
|
||||
export { dateToDaysFromToday, dateToIndex, todayIndex };
|
||||
// convert date string to days from today
|
||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// convert date string to date_index (days since epoch)
|
||||
export const dateToIndex = (dateStr: string): number => {
|
||||
const d = new Date(dateStr);
|
||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
// get current date_index
|
||||
export const todayIndex = (): number => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
const EPOCH = new Date(0);
|
||||
const MS_PER_DAY = 86400000;
|
||||
|
||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const dateToIndex = (dateStr: string): number => {
|
||||
const d = new Date(dateStr);
|
||||
return Math.floor((d.getTime() - EPOCH.getTime()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export const todayIndex = (): number => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now.getTime() - EPOCH.getTime()) / MS_PER_DAY);
|
||||
};
|
||||
|
||||
export { EPOCH, MS_PER_DAY };
|
||||
@@ -25,7 +25,7 @@ export interface Hotel {
|
||||
nights: number;
|
||||
}
|
||||
|
||||
import { EPOCH, MS_PER_DAY, dateToDaysFromToday, dateToIndex, todayIndex } from './date-utils';
|
||||
const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
||||
const { id, room_type, date_index, metadata } = p;
|
||||
@@ -37,14 +37,14 @@ export const transformProduct = (p: HotelProduct): Hotel => {
|
||||
// legacy: treat as offset from today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
checkIn = new Date(today.getTime() + date_index * MS_PER_DAY);
|
||||
checkIn = new Date(today.getTime() + date_index * 86400000);
|
||||
} else {
|
||||
// proper: days since epoch
|
||||
checkIn = new Date(EPOCH.getTime() + date_index * MS_PER_DAY);
|
||||
checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
||||
}
|
||||
|
||||
const nights = 1;
|
||||
const checkOut = new Date(checkIn.getTime() + nights * MS_PER_DAY);
|
||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
||||
|
||||
const formatOpts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
@@ -65,34 +65,24 @@ export const transformProduct = (p: HotelProduct): Hotel => {
|
||||
};
|
||||
};
|
||||
|
||||
const hotelImagePool = [
|
||||
'photo-1566073771259-6a8506099945',
|
||||
'photo-1551882547-ff40c63fe5fa',
|
||||
'photo-1590490360182-c33d57733427',
|
||||
'photo-1582719478250-c89cae4dc85b',
|
||||
'photo-1596701062351-8c2c14d1fdd0',
|
||||
'photo-1631049307264-da0ec9d70304',
|
||||
'photo-1578683010236-d716f9a3f461',
|
||||
'photo-1540518614846-7eded433c457',
|
||||
'photo-1505693416388-ac5ce068fe85',
|
||||
'photo-1522771739844-6a9f6d5f14af',
|
||||
'photo-1562438668-bcf0ca6578f0',
|
||||
'photo-1595576508898-0ad5c879a061',
|
||||
];
|
||||
|
||||
const hashString = (s: string): number => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = ((h << 5) - h) + s.charCodeAt(i);
|
||||
h = h & h;
|
||||
}
|
||||
return Math.abs(h);
|
||||
// convert date string to days from today
|
||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
export const getHotelImageUrl = (hotelId: string, size: { w: number; h: number } = { w: 400, h: 300 }): string => {
|
||||
const idx = hashString(hotelId) % hotelImagePool.length;
|
||||
const photoId = hotelImagePool[idx];
|
||||
return `https://images.unsplash.com/${photoId}?w=${size.w}&h=${size.h}&fit=crop`;
|
||||
// convert date string to date_index (days since epoch)
|
||||
export const dateToIndex = (dateStr: string): number => {
|
||||
const d = new Date(dateStr);
|
||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
export { dateToDaysFromToday, dateToIndex, todayIndex };
|
||||
// get current date_index
|
||||
export const todayIndex = (): number => {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user