mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
25 Commits
paper-lit-
...
airflow-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ed9057105 | ||
| dd33f83e10 | |||
| 5d5795b212 | |||
| d0d18927cf | |||
| c8a69f0e3b | |||
| 8fae7851a6 | |||
| 73e46200c7 | |||
| e9d9c0e319 | |||
| b5c71e713b | |||
| e79edf2ef3 | |||
| f3bc81e0ed | |||
| 1054fe7720 | |||
| bdd72b5a85 | |||
| 33c20ec715 | |||
| 505c4fcd42 | |||
| eb30b04271 | |||
| 519b3b7f93 | |||
| b38f2b0c66 | |||
| f749bd749c | |||
| d8a3131d3c | |||
| a3ac3fba59 | |||
| cc841ae0a5 | |||
| 1bbfc699c2 | |||
| 219370cd95 | |||
| de7a386fc7 |
48
.github/workflows/latex.yml
vendored
48
.github/workflows/latex.yml
vendored
@@ -19,56 +19,10 @@ jobs:
|
||||
with:
|
||||
root_file: main.tex
|
||||
working_directory: paper/src
|
||||
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
||||
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
|
||||
pre_compile: bash ../concat_code.sh
|
||||
- name: Upload PDF
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thesis-pdf
|
||||
path: paper/build/main.pdf
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to Cloudflare R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
|
||||
DATE: ${{ steps.date.outputs.date }}
|
||||
BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
pip install boto3
|
||||
python3 << 'EOF'
|
||||
import boto3
|
||||
import os
|
||||
|
||||
s3 = boto3.client('s3',
|
||||
endpoint_url=os.environ['AWS_ENDPOINT_URL'],
|
||||
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
||||
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY']
|
||||
)
|
||||
|
||||
date = os.environ['DATE']
|
||||
bucket = os.environ['BUCKET_NAME']
|
||||
|
||||
# upload dated version
|
||||
dated_filename = f"thesis-{date}.pdf"
|
||||
s3.upload_file(
|
||||
'paper/build/main.pdf',
|
||||
bucket,
|
||||
dated_filename,
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded {dated_filename}")
|
||||
|
||||
# upload latest version
|
||||
s3.upload_file(
|
||||
'paper/build/main.pdf',
|
||||
bucket,
|
||||
'thesis-latest.pdf',
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded thesis-latest.pdf")
|
||||
EOF
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -11,12 +11,3 @@ paper/src/bib/auto
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
experiments/collected_data/*
|
||||
|
||||
paper/src/auto/*
|
||||
lib/
|
||||
docs/goals/*.md
|
||||
PHANTOM.wiki/
|
||||
tests/e2e/node_modules/**
|
||||
**/auto/*.el
|
||||
*.old
|
||||
|
||||
69
Makefile
69
Makefile
@@ -11,85 +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) -f \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
.PHONY: pdf.watch
|
||||
pdf.watch: $(BUILDDIR)
|
||||
watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||
$(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
|
||||
@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)
|
||||
@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: wordcount
|
||||
wordcount:
|
||||
@echo "Counting words in main text (excluding appendix)..."
|
||||
@texcount -nosub -total -sum -1 \
|
||||
$(SRCDIR)/chapters/01-intro.tex \
|
||||
$(SRCDIR)/chapters/02-literature-review.tex \
|
||||
$(SRCDIR)/chapters/03-methodology.tex \
|
||||
$(SRCDIR)/chapters/04-results.tex \
|
||||
$(SRCDIR)/chapters/05-discussion.tex \
|
||||
$(SRCDIR)/chapters/06-conclusion.tex
|
||||
|
||||
|
||||
.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
|
||||
|
||||
93
README.md
93
README.md
@@ -1,94 +1,5 @@
|
||||
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||
|
||||
### PHANTOM
|
||||
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||
[](https://sites.research.google/trc/faq/)
|
||||
[](https://phantom-hotel.vercel.app)
|
||||
[](https://phantom-airline.vercel.app)
|
||||
|
||||
- https://phantom-hotel.vercel.app/
|
||||
- https://phantom-airline.vercel.app/
|
||||
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
PHANTOM((PHANTOM Project))
|
||||
North Star
|
||||
Study how automated actors change markets
|
||||
Build an experimentation platform for real-world-like commerce
|
||||
Two-loop learning system
|
||||
Online observation loop
|
||||
Offline "defense gym" loop
|
||||
Core Economic Questions
|
||||
Price Discovery
|
||||
How prices respond to demand signals
|
||||
How signal quality changes with bots/agents
|
||||
Demand & Elasticity
|
||||
Shifts in willingness-to-pay
|
||||
Short-run vs long-run elasticity
|
||||
Market Efficiency & Welfare
|
||||
Consumer surplus vs producer surplus
|
||||
Deadweight loss from frictions/manipulation
|
||||
Price Discrimination & Segmentation
|
||||
Behavioral feature-based segmentation
|
||||
Fairness vs profitability tradeoffs
|
||||
Information Asymmetry
|
||||
Agents amplify search and arbitrage
|
||||
Sellers infer more about buyers; buyers infer more about sellers
|
||||
Strategic Interaction
|
||||
Consumers vs firms vs agents
|
||||
Feedback loops: policy ↔ behavior ↔ price
|
||||
Market Power & Competition
|
||||
Algorithmic pricing as competitive tool
|
||||
Risks: tacit coordination / "algorithmic collusion"
|
||||
Externalities
|
||||
Congestion and attention costs
|
||||
Spillovers: one segment’s behavior affects others’ prices
|
||||
System-Level View
|
||||
Participants
|
||||
Humans
|
||||
Agents (automated buyers/actors)
|
||||
Firms (pricing decision-makers)
|
||||
Platform (measurement + control layer)
|
||||
Markets Simulated
|
||||
Repeated transactions
|
||||
Limited inventory / capacity constraints (conceptually)
|
||||
Time dynamics (learning over time)
|
||||
Interventions
|
||||
Pricing policies
|
||||
Experiment assignment / randomized exposure
|
||||
Agent behavioral policies (task-driven)
|
||||
Measurement & Causal Inference
|
||||
What is observed
|
||||
Actions (search, click, purchase intent)
|
||||
Context (product attributes, time, exposure)
|
||||
Outcomes (conversion, revenue, churn proxies)
|
||||
Identification strategy
|
||||
A/B tests and randomization
|
||||
Counterfactual baselines
|
||||
Robustness checks (offline replay)
|
||||
Key metrics
|
||||
Revenue / profit proxies
|
||||
Conversion & bounce
|
||||
Price volatility / stability
|
||||
Welfare proxies (e.g., dispersion, access)
|
||||
Risk, Governance, and Ethics
|
||||
Manipulation & Integrity
|
||||
Bot-driven demand distortion
|
||||
Measurement contamination
|
||||
Fairness & Transparency
|
||||
Differential pricing concerns
|
||||
Explainability and auditability
|
||||
Safety Constraints
|
||||
Guardrails on price moves
|
||||
Monitoring for runaway feedback loops
|
||||
Outputs
|
||||
Insights
|
||||
When do agents raise/lower prices via behavior shifts?
|
||||
Which market designs are robust to automation?
|
||||
Defenses
|
||||
Agent-aware pricing policies (robust control)
|
||||
Detection + mitigation strategies (feature-level separability)
|
||||
Platform Value
|
||||
Reusable testbed for market + AI-agent research
|
||||
```
|
||||
|
||||
@@ -19,11 +19,11 @@ from procesing.pricers import (
|
||||
ElasticityBasedPricer
|
||||
)
|
||||
from procesing.steps import (
|
||||
StateSpace,
|
||||
PredictPricesStep
|
||||
)
|
||||
from procesing import PipelineContext
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
# Config
|
||||
@@ -53,12 +53,20 @@ def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Opti
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
# fetch pre-computed prices from registry
|
||||
prices_df = registry.get_prices('latest')
|
||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self, backend_url: str):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
||||
|
||||
context = PipelineContext(
|
||||
provider=Provider(backend_url=os.getenv("BACKEND_URL")),
|
||||
store_mode=mode
|
||||
)
|
||||
|
||||
pricing_model = registry.get_pricing_model('latest')
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
if prices_df is None:
|
||||
# fallback: no pre-computed prices available
|
||||
if pricing_model is None or elasticity_df is None:
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
@@ -67,26 +75,87 @@ def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Opti
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
# lookup pre-computed price for this product
|
||||
products = context.products
|
||||
if products.empty:
|
||||
raise HTTPException(500, "No products available in catalog")
|
||||
|
||||
# merge elasticity with product base prices
|
||||
products_with_meta = products.copy()
|
||||
products_with_meta['base_price'] = products_with_meta['metadata'].apply(
|
||||
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
|
||||
)
|
||||
|
||||
merged = products_with_meta[['id', 'base_price']].rename(
|
||||
columns={'id': 'productId'}
|
||||
).merge(
|
||||
elasticity_df[['productId', 'elasticity']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'elasticity': 0.0})
|
||||
|
||||
# compute demand: use pricer's mean_demand if available, else default
|
||||
demand_values = (pricing_model.mean_demand
|
||||
if hasattr(pricing_model, 'mean_demand') and pricing_model.mean_demand is not None
|
||||
else np.ones(len(merged)) * 10.0)
|
||||
|
||||
# build state space with session features if sessionId provided
|
||||
session_features = pd.DataFrame()
|
||||
if sessionId:
|
||||
try:
|
||||
# fetch recent session interactions from backend
|
||||
from procesing.steps.session import ExtractSessionFeaturesStep
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
t_end = datetime.utcnow()
|
||||
t_start = t_end - timedelta(hours=1)
|
||||
backend_url = os.getenv("BACKEND_URL")
|
||||
print(backend_url)
|
||||
|
||||
resp = requests.get(
|
||||
f"{os.getenv('BACKEND_URL')}/api/kafka/dump", # TODO: THIS IS SHIT, must fix this
|
||||
params={'topic': 'user-interactions', 't_start': t_start.isoformat(), 't_end': t_end.isoformat()},
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
msgs = resp.json().get('messages', [])
|
||||
interactions_df = pd.DataFrame(msgs)
|
||||
|
||||
if not interactions_df.empty and 'sessionId' in interactions_df.columns:
|
||||
session_interactions = interactions_df[interactions_df['sessionId'] == sessionId]
|
||||
|
||||
if not session_interactions.empty:
|
||||
extractor = ExtractSessionFeaturesStep(context=context)
|
||||
session_features_df = extractor.transform(session_interactions)
|
||||
|
||||
if not session_features_df.empty:
|
||||
session_features = session_features_df.drop(columns=['sessionId'])
|
||||
except Exception as e:
|
||||
print(f"[session-features-error] {e}")
|
||||
# continue without session features
|
||||
|
||||
state = StateSpace(
|
||||
demand=demand_values,
|
||||
prices=merged['base_price'].values,
|
||||
session_features=session_features,
|
||||
product_ids=merged['productId'].values,
|
||||
elasticity=merged['elasticity'].values,
|
||||
metadata={'sessionId': sessionId, 'experimentId': experimentId}
|
||||
)
|
||||
|
||||
oracle = PredictPricesStep(context=context)
|
||||
prices_df = oracle.transform((pricing_model, state))
|
||||
|
||||
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
|
||||
)
|
||||
raise HTTPException(404, f"No pricing available for product {productId}")
|
||||
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
||||
optimal_price = float(product_price_row['predicted_price'].iloc[0])
|
||||
|
||||
# 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])
|
||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||
product_elasticity = (float(product_elasticity_row['elasticity'].iloc[0])
|
||||
if not product_elasticity_row.empty else None)
|
||||
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
|
||||
@@ -12,5 +12,4 @@ graphviz
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
typing-extensions>=4.8.0
|
||||
pypickle
|
||||
pymc
|
||||
pickle5>=0.0.11; python_version < '3.8'
|
||||
|
||||
@@ -290,7 +290,6 @@ async def get_products(
|
||||
query = supabase.table(table).select('*')
|
||||
|
||||
# filter by exact date_index if provided
|
||||
# dateIndex from frontend is days from today, convert to days since epoch
|
||||
if dateIndex is not None:
|
||||
query = query.eq('date_index', dateIndex)
|
||||
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
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:
|
||||
image: tensorflow/tensorflow:latest
|
||||
container_name: "PHANTOM-tensorboard-ml"
|
||||
ports:
|
||||
- "6006:6006"
|
||||
volumes:
|
||||
- ./experiments/ml/runs:/logs
|
||||
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
@@ -123,6 +103,12 @@ services:
|
||||
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins
|
||||
- ./experiments/procesing:/opt/airflow/procesing
|
||||
- ./lib:/opt/airflow/lib
|
||||
command: version
|
||||
restart: "no"
|
||||
|
||||
@@ -143,7 +129,6 @@ services:
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
@@ -153,6 +138,12 @@ services:
|
||||
- REDIS_PORT=6379
|
||||
ports:
|
||||
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
||||
- ./lib:/opt/airflow/lib:ro
|
||||
command: webserver
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
@@ -179,7 +170,6 @@ services:
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
@@ -187,6 +177,12 @@ services:
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
||||
- ./lib:/opt/airflow/lib:ro
|
||||
command: scheduler
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
@@ -212,9 +208,13 @@ services:
|
||||
- KAFKA_PORT=29092
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- BACKEND_URL=http://localhost:5000
|
||||
ports:
|
||||
- "${PROVIDER_PORT:-5001}:5001"
|
||||
volumes:
|
||||
- ./lib:/app/lib:ro
|
||||
- ./experiments/procesing:/app/procesing:ro
|
||||
- ./backend/provider:/app/provider:ro
|
||||
command: python -m uvicorn provider.app:app --host 0.0.0.0 --port 5001
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -21,10 +21,3 @@ RUN pip install --no-cache-dir \
|
||||
|
||||
# set airflow home
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
# create logs and plugins dirs (airflow expects them)
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
FROM apache/airflow:2.7.3-python3.11
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
supervisor \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER airflow
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
psycopg2-binary \
|
||||
apache-airflow-providers-postgres
|
||||
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
|
||||
# copy all code into image (standalone - no volume mounts needed)
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
|
||||
# copy entrypoint script
|
||||
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||
USER root
|
||||
RUN chmod +x /entrypoint.sh
|
||||
USER airflow
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY backend/provider/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code into image
|
||||
COPY lib/ /app/lib/
|
||||
COPY experiments/procesing/ /app/procesing/
|
||||
COPY backend/provider/ /app/provider/
|
||||
# Structure will be mounted via volumes:
|
||||
# /app/lib -> lib/
|
||||
# /app/procesing -> experiments/procesing/
|
||||
# /app/provider -> backend/provider/
|
||||
|
||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||
|
||||
WORKDIR /app/provider
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
CMD ["python", "-m", "uvicorn", "provider.app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# init db and create admin user on first run
|
||||
airflow db migrate
|
||||
|
||||
# create admin user if not exists
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||
--firstname Admin \
|
||||
--lastname User \
|
||||
--role Admin \
|
||||
--email admin@example.com || true
|
||||
|
||||
# start scheduler in background
|
||||
airflow scheduler &
|
||||
|
||||
# start webserver in foreground (Railway needs one foreground process)
|
||||
exec airflow webserver --port ${PORT:-8080}
|
||||
@@ -1,21 +0,0 @@
|
||||
store_mode,task_name,task_description,definition_of_done
|
||||
airline,The Indecisive Executive (SEA-LAX),"You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the ""Flexible"" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket.","Booking for SEA-LAX Business Class is completed."
|
||||
airline,The Cross-Country Splurge (LAX-JFK),"You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class.","Booking for LAX-JFK First Class is completed."
|
||||
airline,The Budget Student (DFW-ORD),"You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the ""Premium"" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket.","Booking for DFW-ORD Economy Class is completed."
|
||||
airline,The Quick Hop Commuter (LAX-SFO),"You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it.","Booking for LAX-SFO is completed."
|
||||
airline,The Status Chaser (SFO-SEA),"You are trying to earn airline points and need a ""Premium"" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the ""Premium"" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat.","Booking for SFO-SEA Premium Economy is completed."
|
||||
airline,The Family Reunion (MIA-ATL),"You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option.","Booking for MIA-ATL (Premium) is completed."
|
||||
airline,The Red Eye Skeptic (LAX-JFK),"You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time.","Booking for LAX-JFK is completed."
|
||||
airline,The Refundable Requirement (ATL-DFW),"Your meeting in Dallas might get cancelled, so you strictly need a ""Refundable"" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists ""Refundable"". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind.","Booking for ATL-DFW First Class is completed."
|
||||
airline,The Hub Connector (ORD-MIA),"You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the ""stops"" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy.","Booking for ORD-MIA Economy is completed."
|
||||
airline,The West Coast Hopper (SEA-LAX Business),"You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat.","Booking for SEA-LAX Business is completed."
|
||||
hotel,The Honeymoon Suite (Presidential),"It is your honeymoon. You want the best room available, specifically one with a ""jacuzzi"". 1) Search for a room for 2 people. 2) Identify the ""Presidential Suite"". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the ""Executive Suite"" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it.","Booking for the Presidential Suite is completed."
|
||||
hotel,The Digital Nomad (Executive),"You are working remotely and strictly need a ""workspace"". 1) Search for a room. 2) Check the ""Executive Suite"" details for a workspace. 3) Check the ""Deluxe Room"" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it.","Booking for the Executive Suite is completed."
|
||||
hotel,The Safety First (Superior),"You are traveling with valuables and need a ""safe"" in the room. 1) Search for a room. 2) Look at the ""Standard Room"" amenities. Does it have a safe? 3) Look at the ""Superior Room"". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room.","Booking for the Superior Room is completed."
|
||||
hotel,The Bachelor Party (Max Occupancy),"You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a ""Standard Room"". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite.","Booking for the Presidential Suite is completed."
|
||||
hotel,The Budget Refundable (Junior),"You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the ""Standard"" and ""Superior"" rooms. Notice they are likely Non-Refundable. 4) Find the ""Junior Suite"" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility.","Booking for the Junior Suite is completed."
|
||||
hotel,The View Hunter (Executive),"You want a room with a ""city_view"" or balcony. 1) Search for a room. 2) Check the amenities of the ""Deluxe Room"". 3) Check the amenities of the ""Executive Suite"". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it.","Booking for the Executive Suite is completed."
|
||||
hotel,The Just-A-Bed (Standard),"You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has ""wifi"". 4) Briefly glance at the ""Superior Room"" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately.","Booking for the Standard Room is completed."
|
||||
hotel,The Family Vacation (Deluxe),"You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the ""Deluxe Room"". 3) Check the amenities for ""coffee_maker"" (parents need coffee). 4) Compare it with the ""Junior Suite"". 5) Decide the Deluxe Room is sufficient value and book it.","Booking for the Deluxe Room is completed."
|
||||
hotel,The Long Stay (Junior),"You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the ""Junior Suite"". 3) Check the amenities for a ""mini_fridge"" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the ""Standard Room"" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it.","Booking for the Junior Suite is completed."
|
||||
hotel,The Last Minute Panic (Superior),"It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the ""Superior Room"" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible.","Booking for the Superior Room is completed."
|
||||
|
@@ -47,7 +47,7 @@
|
||||
<meta name="citation_author" content="Rösel, Daniel">
|
||||
<meta name="citation_publication_date" content="2025">
|
||||
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
||||
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
||||
<meta name="citation_pdf_url" content="TODO">
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
@@ -233,13 +233,14 @@
|
||||
|
||||
<div class="is-size-5 publication-authors">
|
||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
||||
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
||||
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
|
||||
</div>
|
||||
|
||||
<div class="column has-text-centered">
|
||||
<div class="publication-links">
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-pdf"></i>
|
||||
@@ -314,10 +315,7 @@
|
||||
<h2 class="title is-3">Abstract</h2>
|
||||
<div class="content has-text-justified">
|
||||
<p>
|
||||
This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model to prove separability as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
||||
</p>
|
||||
<p>
|
||||
This work develops behavioral signature models using recommendation system techniques to profile session-level interaction, temporal engagement, and cross-session correlation. The AI Agent market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030, raising the question of how these systems should be designed for future robustness and how to maintain a competitive edge in the analytical components of e-commerce platforms.
|
||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,7 +433,8 @@
|
||||
<div class="container">
|
||||
<h2 class="title">Poster</h2>
|
||||
|
||||
<iframe src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
||||
<!-- TODO: Replace with your poster PDF -->
|
||||
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
|
||||
</iframe>
|
||||
|
||||
</div>
|
||||
|
||||
346
experiments/airflow/dags/elasticity_pricing_dag.py
Normal file
346
experiments/airflow/dags/elasticity_pricing_dag.py
Normal file
@@ -0,0 +1,346 @@
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
import io
|
||||
|
||||
# add parent dir to path so procesing package can be imported
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
CreatePriceBucketsStep,
|
||||
AugmentEventNamesStep,
|
||||
ChunkByTimeWindowStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep,
|
||||
BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
|
||||
default_args = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
def get_provider():
|
||||
"""Factory to create composite provider"""
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
return CompositeProvider()
|
||||
|
||||
def get_context(**kwargs):
|
||||
"""Build pipeline context from Airflow config"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return PipelineContext(
|
||||
provider=get_provider(),
|
||||
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||
window_size=dag_conf.get('window_size', '30s'),
|
||||
n_price_buckets=dag_conf.get('n_price_buckets', 5),
|
||||
elasticity_method=dag_conf.get('elasticity_method', 'point'),
|
||||
min_observations=dag_conf.get('min_observations', 2),
|
||||
)
|
||||
|
||||
# atomic task functions (each wraps one sklearn step)
|
||||
def fetch_interactions(**kwargs):
|
||||
"""Task: Fetch interaction data from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchInteractionsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
"""Task: Fetch price logs from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchPriceLogsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def create_price_buckets(**kwargs):
|
||||
"""Task: Create price buckets for interactions"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = CreatePriceBucketsStep(context)
|
||||
df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interactions_bucketed', value=pickle.dumps(df))
|
||||
logging.info(f"Created price buckets for {len(df)} interactions")
|
||||
return len(df)
|
||||
|
||||
def augment_event_names(**kwargs):
|
||||
"""Task: Augment event names with product and price schema"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_bucketed'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AugmentEventNamesStep(context)
|
||||
df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interactions_final', value=pickle.dumps(df))
|
||||
logging.info(f"Augmented event names for {len(df)} interactions")
|
||||
return len(df)
|
||||
|
||||
def chunk_interactions(**kwargs):
|
||||
"""Task: Chunk interactions into time windows"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_final'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ChunkByTimeWindowStep(context)
|
||||
chunks = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interaction_chunks', value=pickle.dumps(chunks))
|
||||
logging.info(f"Generated {len(chunks)} interaction chunks")
|
||||
return len(chunks)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
"""Task: Compute demand vectors for all chunks"""
|
||||
ti = kwargs['ti']
|
||||
chunks = pickle.loads(ti.xcom_pull(key='interaction_chunks'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeDemandForChunksStep(context)
|
||||
demand_chunks = step.transform(chunks)
|
||||
|
||||
ti.xcom_push(key='demand_chunks', value=pickle.dumps(demand_chunks))
|
||||
logging.info(f"Computed demand for {len(demand_chunks)} chunks")
|
||||
return len(demand_chunks)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
"""Task: Aggregate price logs into time windows """
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AggregatePriceLogsStep(context)
|
||||
price_chunks = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='price_chunks', value=pickle.dumps(price_chunks))
|
||||
logging.info(f"Aggregated {len(price_chunks)} price chunks")
|
||||
return len(price_chunks)
|
||||
|
||||
def compute_elasticity(**kwargs):
|
||||
"""Task: Compute price elasticity from demand and price chunks"""
|
||||
ti = kwargs['ti']
|
||||
demand_chunks = pickle.loads(ti.xcom_pull(key='demand_chunks'))
|
||||
price_chunks = pickle.loads(ti.xcom_pull(key='price_chunks'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeElasticityStep(context)
|
||||
elasticity_df = step.transform((demand_chunks, price_chunks))
|
||||
|
||||
ti.xcom_push(key='elasticity_results', value=pickle.dumps(elasticity_df))
|
||||
logging.info(f"Computed elasticity for {len(elasticity_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(elasticity_df),
|
||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
||||
'median_elasticity': float(elasticity_df['elasticity'].median())
|
||||
}
|
||||
|
||||
def build_state_space(**kwargs):
|
||||
"""Task: Build state space from elasticity"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = BuildStateSpaceStep(context)
|
||||
state_space = step.transform(elasticity_df)
|
||||
|
||||
ti.xcom_push(key='state_space', value=pickle.dumps(state_space))
|
||||
logging.info("Built state space for pricing")
|
||||
return True
|
||||
|
||||
def fit_pricing_function(**kwargs):
|
||||
"""Task: Fit pricing function using elasticity"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = FitPricingFunctionStep(context)
|
||||
pricer = step.transform(elasticity_df)
|
||||
|
||||
ti.xcom_push(key='pricer', value=pickle.dumps(pricer))
|
||||
logging.info("Fitted pricing function")
|
||||
return True
|
||||
|
||||
def predict_prices(**kwargs):
|
||||
"""Task: Predict optimal prices"""
|
||||
ti = kwargs['ti']
|
||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
||||
state_space = pickle.loads(ti.xcom_pull(key='state_space'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = PredictPricesStep(context)
|
||||
prices_df = step.transform((pricer, state_space))
|
||||
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"Predicted prices for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
"""Task: Publish elasticity and pricing results to model registry"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'window_size': dag_conf.get('window_size', '30s'),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual'
|
||||
}
|
||||
|
||||
registry.publish_elasticity(elasticity_df, model_name='latest', metadata=metadata)
|
||||
|
||||
# get fitted pricer from XCom
|
||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
||||
registry.publish_pricing_model(
|
||||
pricer,
|
||||
model_name='latest',
|
||||
metadata={**metadata, 'model_type': type(pricer).__name__}
|
||||
)
|
||||
|
||||
logging.info(f"Published elasticity + pricing for {len(elasticity_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(elasticity_df),
|
||||
'registry_status': 'success',
|
||||
'elasticity_mean': float(elasticity_df['elasticity'].mean())
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'elasticity_pricing_pipeline',
|
||||
default_args=default_args,
|
||||
description='E2E refactored pipeline: atomic steps with proper separation',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'elasticity', 'research', 'refactored'],
|
||||
) as dag:
|
||||
|
||||
# parallel data fetching
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=fetch_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=fetch_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# interaction processing branch
|
||||
t_create_buckets = PythonOperator(
|
||||
task_id='create_price_buckets',
|
||||
python_callable=create_price_buckets,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_augment_events = PythonOperator(
|
||||
task_id='augment_event_names',
|
||||
python_callable=augment_event_names,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_chunk_interactions = PythonOperator(
|
||||
task_id='chunk_interactions',
|
||||
python_callable=chunk_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=compute_demand,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# price processing branch (VECTORIZED)
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=aggregate_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# convergence: compute elasticity
|
||||
t_compute_elasticity = PythonOperator(
|
||||
task_id='compute_elasticity',
|
||||
python_callable=compute_elasticity,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# pricing tasks
|
||||
t_build_state = PythonOperator(
|
||||
task_id='build_state_space',
|
||||
python_callable=build_state_space,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fit_pricer = PythonOperator(
|
||||
task_id='fit_pricing_function',
|
||||
python_callable=fit_pricing_function,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_predict_prices = PythonOperator(
|
||||
task_id='predict_prices',
|
||||
python_callable=predict_prices,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# publish to registry
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=publish_results,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# dependency graph (clear atomic flow)
|
||||
# parallel fetches
|
||||
[t_fetch_interactions, t_fetch_price_logs]
|
||||
|
||||
# interaction branch: fetch -> bucket -> augment -> chunk -> demand
|
||||
t_fetch_interactions >> t_create_buckets >> t_augment_events >> t_chunk_interactions >> t_compute_demand
|
||||
|
||||
# price branch: fetch -> aggregate (vectorized)
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
|
||||
# convergence: both branches -> elasticity
|
||||
[t_compute_demand, t_aggregate_prices] >> t_compute_elasticity
|
||||
|
||||
# pricing: elasticity -> state + fit -> predict -> publish
|
||||
t_compute_elasticity >> [t_build_state, t_fit_pricer] >> t_predict_prices >> t_publish
|
||||
@@ -1,115 +0,0 @@
|
||||
from airflow import DAG, Dataset
|
||||
from airflow.decorators import task
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
ValidateDataStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
)
|
||||
|
||||
TRAINING_DATASET = Dataset('phantom://ml/training-data')
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
|
||||
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
|
||||
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
|
||||
|
||||
|
||||
with DAG(
|
||||
'ml_training_pipeline',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description='ML training data pipeline: fetch -> validate -> extract features -> label -> publish',
|
||||
schedule=None,
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['ml', 'training', 'features', 'research'],
|
||||
) as dag:
|
||||
|
||||
@task
|
||||
def fetch_interactions(**kwargs) -> bytes:
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
df = FetchInteractionsStep(ctx).transform(None)
|
||||
logging.info(f"Fetched {len(df)} interactions, {df['sessionId'].nunique()} sessions")
|
||||
return pickle.dumps(df)
|
||||
|
||||
@task
|
||||
def validate_data(raw_data: bytes, **kwargs) -> bytes:
|
||||
df = pickle.loads(raw_data)
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
validated = ValidateDataStep(ctx).transform(df)
|
||||
report = ctx.get_cached('validation_report') or {}
|
||||
logging.info(f"Validation: {report.get('status')}, {report.get('sessions', 0)} sessions")
|
||||
return pickle.dumps(validated)
|
||||
|
||||
@task
|
||||
def extract_session_features(validated_data: bytes, **kwargs) -> bytes:
|
||||
df = pickle.loads(validated_data)
|
||||
if df.empty:
|
||||
logging.warning("Empty input, skipping feature extraction")
|
||||
return pickle.dumps(pd.DataFrame())
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
features = ExtractSessionFeaturesStep(ctx).transform(df)
|
||||
logging.info(f"Extracted {len(features.columns)} features for {len(features)} sessions")
|
||||
return pickle.dumps(features)
|
||||
|
||||
@task
|
||||
def join_labels(features_data: bytes, **kwargs) -> bytes:
|
||||
features_df = pickle.loads(features_data)
|
||||
if features_df.empty:
|
||||
logging.warning("Empty features, skipping label join")
|
||||
return pickle.dumps(pd.DataFrame())
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
labeled = JoinLabelsStep(ctx).transform(features_df)
|
||||
n_agents = labeled['is_agent'].sum() if 'is_agent' in labeled.columns else 0
|
||||
logging.info(f"Labeled {len(labeled)} sessions: {n_agents} agents")
|
||||
return pickle.dumps(labeled)
|
||||
|
||||
@task(outlets=[TRAINING_DATASET])
|
||||
def publish_training_data(labeled_data: bytes, **kwargs) -> dict:
|
||||
labeled_df = pickle.loads(labeled_data)
|
||||
if labeled_df.empty:
|
||||
return {'status': 'skipped', 'reason': 'empty_data'}
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return {
|
||||
'status': 'success',
|
||||
'n_sessions': len(labeled_df),
|
||||
'n_features': len([c for c in labeled_df.columns if c not in ['sessionId', 'experimentId', 'is_agent']]),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
}
|
||||
|
||||
raw = fetch_interactions()
|
||||
validated = validate_data(raw)
|
||||
features = extract_session_features(validated)
|
||||
labeled = join_labels(features)
|
||||
publish_training_data(labeled)
|
||||
@@ -1,210 +0,0 @@
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
def _get_provider():
|
||||
return CompositeProvider()
|
||||
|
||||
def _make_task_callables(store_mode: str):
|
||||
"""Generate task callables bound to a specific store_mode."""
|
||||
|
||||
def get_context(**kwargs):
|
||||
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
|
||||
|
||||
def fetch_interactions(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchInteractionsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchPriceLogsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
demand_df = ComputeDemandStep(ctx).transform(df)
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
price_df = AggregatePriceLogsStep(ctx).transform(df)
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
ctx = get_context(**kwargs)
|
||||
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price', 'demand': 'demand_score'
|
||||
})
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': store_mode,
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
|
||||
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'store_mode': store_mode,
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
return {
|
||||
'fetch_interactions': fetch_interactions,
|
||||
'fetch_price_logs': fetch_price_logs,
|
||||
'compute_demand': compute_demand,
|
||||
'aggregate_price_logs': aggregate_price_logs,
|
||||
'join_product_features': join_product_features,
|
||||
'apply_surge_pricing': apply_surge_pricing,
|
||||
'publish_results': publish_results,
|
||||
}
|
||||
|
||||
|
||||
def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||
"""Factory: generates a surge pricing DAG for a given store_mode."""
|
||||
callables = _make_task_callables(store_mode)
|
||||
|
||||
dag = DAG(
|
||||
f'surge_pricing_{store_mode}',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description=f'Surge pricing pipeline for {store_mode} store mode',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', store_mode],
|
||||
)
|
||||
|
||||
with dag:
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=callables['fetch_interactions'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=callables['fetch_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=callables['compute_demand'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=callables['aggregate_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=callables['join_product_features'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=callables['apply_surge_pricing'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=callables['publish_results'],
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
|
||||
return dag
|
||||
|
||||
|
||||
# instantiate DAGs for Airflow to discover
|
||||
dag_airline = create_surge_pricing_dag('airline')
|
||||
dag_hotel = create_surge_pricing_dag('hotel')
|
||||
@@ -1,237 +0,0 @@
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
import io
|
||||
|
||||
# add parent dir to path so procesing package can be imported
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
default_args = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
def get_provider():
|
||||
"""Factory to create composite provider"""
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
return CompositeProvider()
|
||||
|
||||
def get_context(**kwargs):
|
||||
"""Build pipeline context from Airflow config"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return PipelineContext(
|
||||
provider=get_provider(),
|
||||
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||
)
|
||||
|
||||
# atomic task functions (each wraps one sklearn step)
|
||||
def fetch_interactions(**kwargs):
|
||||
"""Task: Fetch interaction data from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchInteractionsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
"""Task: Fetch price logs from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchPriceLogsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
"""Task: Compute demand scores from interactions"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeDemandStep(context)
|
||||
demand_df = step.transform(df)
|
||||
# TODO: clear the xcom
|
||||
|
||||
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
"""Task: Aggregate price logs"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AggregatePriceLogsStep(context)
|
||||
price_df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
"""Task: Join demand and price data"""
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = JoinProductFeaturesStep(context)
|
||||
joined_df = step.transform((demand_df, price_df))
|
||||
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
"""Task: Apply surge pricing rules to generate optimal prices"""
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
# rename demand_score to demand for pricer compatibility
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price',
|
||||
'demand': 'demand_score'
|
||||
})
|
||||
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
"""Task: Publish surge pricing results to registry"""
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
|
||||
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||
|
||||
logging.info(f"Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'surge_pricing_pipeline',
|
||||
default_args=default_args,
|
||||
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', 'simplified'],
|
||||
) as dag:
|
||||
|
||||
# parallel data fetching
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=fetch_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=fetch_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# compute demand from interactions
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=compute_demand,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# aggregate price logs
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=aggregate_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# join demand and prices
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=join_product_features,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# apply surge pricing
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=apply_surge_pricing,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# publish to registry
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=publish_results,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# dependency graph: parallel fetch -> process -> join -> surge -> publish
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
@@ -1,11 +0,0 @@
|
||||
from .evals import evaluate
|
||||
from .arch import (
|
||||
XGBoostAgentClassifier,
|
||||
LightGBMAgentClassifier
|
||||
)
|
||||
|
||||
__all__ =[
|
||||
'evaluate',
|
||||
'XGBoostAgentClassifier',
|
||||
'LightGBMAgentClassifier'
|
||||
]
|
||||
@@ -1,122 +0,0 @@
|
||||
# sklearn compatible models for agent detection
|
||||
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||
from procesing.context import PipelineContext
|
||||
from typing import Any, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgb
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
TASK = 'classification'
|
||||
LABELS = ['human', 'agent']
|
||||
|
||||
|
||||
class BaseAgentClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||
"""Base class for tree-based agent detection classifiers with common logic"""
|
||||
|
||||
def __init__(self, context: Optional[PipelineContext] = None, n_estimators: int = 200,
|
||||
max_depth: int = 6, learning_rate: float = 0.05,
|
||||
early_stopping_rounds: int = 20):
|
||||
self.context = context
|
||||
self.n_estimators = n_estimators
|
||||
self.max_depth = max_depth
|
||||
self.learning_rate = learning_rate
|
||||
self.early_stopping_rounds = early_stopping_rounds
|
||||
self.model_ = None
|
||||
self.feature_names_ = None
|
||||
|
||||
def _to_array(self, X):
|
||||
"""Convert pandas structures to numpy arrays"""
|
||||
return X.values if isinstance(X, (pd.DataFrame, pd.Series)) else X
|
||||
|
||||
def _compute_pos_weight(self, y_arr):
|
||||
"""Calculate scale_pos_weight for class imbalance handling"""
|
||||
n_neg, n_pos = (y_arr == 0).sum(), (y_arr == 1).sum()
|
||||
return n_neg / n_pos if n_pos > 0 else 1.0
|
||||
|
||||
def _prepare_eval_set(self, eval_set):
|
||||
"""Convert eval_set to numpy arrays if needed"""
|
||||
if not eval_set:
|
||||
return None
|
||||
X_val, y_val = eval_set[0]
|
||||
return [(self._to_array(X_val), self._to_array(y_val))]
|
||||
|
||||
@abstractmethod
|
||||
def _build_model(self, scale_pos: float):
|
||||
"""Build the underlying model instance (must be implemented by subclasses)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
"""Fit model with evaluation set (must be implemented by subclasses)"""
|
||||
pass
|
||||
|
||||
def fit(self, X, y, eval_set=None):
|
||||
X_arr, y_arr = self._to_array(X), self._to_array(y)
|
||||
|
||||
if isinstance(X, pd.DataFrame):
|
||||
self.feature_names_ = X.columns.tolist()
|
||||
|
||||
scale_pos = self._compute_pos_weight(y_arr)
|
||||
self.model_ = self._build_model(scale_pos)
|
||||
|
||||
eval_arr = self._prepare_eval_set(eval_set)
|
||||
if eval_arr:
|
||||
self._fit_with_eval(X_arr, y_arr, eval_arr)
|
||||
else:
|
||||
self.model_.fit(X_arr, y_arr)
|
||||
|
||||
return self
|
||||
|
||||
def predict(self, X):
|
||||
return self.model_.predict(self._to_array(X))
|
||||
|
||||
def predict_proba(self, X):
|
||||
return self.model_.predict_proba(self._to_array(X))
|
||||
|
||||
@property
|
||||
def feature_importances_(self):
|
||||
return self.model_.feature_importances_ if self.model_ else None
|
||||
|
||||
|
||||
class XGBoostAgentClassifier(BaseAgentClassifier):
|
||||
"""XGBoost binary classifier for agent detection with class imbalance handling"""
|
||||
|
||||
def _build_model(self, scale_pos: float):
|
||||
return xgb.XGBClassifier(
|
||||
n_estimators=self.n_estimators,
|
||||
max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate,
|
||||
scale_pos_weight=scale_pos,
|
||||
eval_metric='auc',
|
||||
early_stopping_rounds=self.early_stopping_rounds,
|
||||
random_state=42,
|
||||
tree_method='hist',
|
||||
enable_categorical=False
|
||||
)
|
||||
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
self.model_.fit(X_arr, y_arr, eval_set=eval_arr, verbose=False)
|
||||
|
||||
|
||||
class LightGBMAgentClassifier(BaseAgentClassifier):
|
||||
"""LightGBM binary classifier for agent detection with class imbalance handling"""
|
||||
|
||||
def _build_model(self, scale_pos: float):
|
||||
return lgb.LGBMClassifier(
|
||||
n_estimators=self.n_estimators,
|
||||
max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate,
|
||||
scale_pos_weight=scale_pos,
|
||||
metric='auc',
|
||||
random_state=42,
|
||||
verbosity=-1
|
||||
)
|
||||
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
self.model_.fit(
|
||||
X_arr, y_arr,
|
||||
eval_set=eval_arr,
|
||||
callbacks=[lgb.early_stopping(self.early_stopping_rounds, verbose=False)]
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
|
||||
f1_score, roc_auc_score, confusion_matrix, roc_curve)
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from logging import getLogger
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def log_feature_importance(writer, model, feature_names, epoch):
|
||||
"""Visualize and log feature importance to TensorBoard"""
|
||||
if not hasattr(model, 'feature_importances_') or model.feature_importances_ is None:
|
||||
return
|
||||
|
||||
importance = model.feature_importances_
|
||||
indices = np.argsort(importance)[::-1][:20] # top 20
|
||||
top_features = [feature_names[i] for i in indices]
|
||||
top_importance = importance[indices]
|
||||
|
||||
for i, (feat, imp) in enumerate(zip(top_features, top_importance)):
|
||||
writer.add_scalar(f'FeatureImportance/{feat}', imp, epoch)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 8))
|
||||
ax.barh(range(len(top_features)), top_importance, align='center')
|
||||
ax.set_yticks(range(len(top_features)))
|
||||
ax.set_yticklabels(top_features)
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel('Importance')
|
||||
ax.set_title(f'Top 20 Feature Importance (Epoch {epoch})')
|
||||
ax.grid(axis='x', alpha=0.3)
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.tight_layout()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
img = Image.open(buf)
|
||||
img_arr = np.array(img)
|
||||
writer.add_image('FeatureImportance/Chart', img_arr, epoch, dataformats='HWC')
|
||||
plt.close()
|
||||
|
||||
def evaluate(perdicted_class, predicted_proba, true_class, writer: SummaryWriter, epoch: int):
|
||||
accuracy = accuracy_score(true_class, perdicted_class)
|
||||
precision = precision_score(true_class, perdicted_class, zero_division=0)
|
||||
recall = recall_score(true_class, perdicted_class, zero_division=0)
|
||||
f1 = f1_score(true_class, perdicted_class, zero_division=0)
|
||||
roc_auc = roc_auc_score(true_class, predicted_proba)
|
||||
|
||||
writer.add_scalar('Eval/Accuracy', accuracy, epoch)
|
||||
writer.add_scalar('Eval/Precision', precision, epoch)
|
||||
writer.add_scalar('Eval/Recall', recall, epoch)
|
||||
writer.add_scalar('Eval/F1_Score', f1, epoch)
|
||||
writer.add_scalar('Eval/ROC_AUC', roc_auc, epoch)
|
||||
|
||||
# confusion matrix
|
||||
cm = confusion_matrix(true_class, perdicted_class)
|
||||
tn, fp, fn, tp = cm.ravel()
|
||||
writer.add_scalar('Eval/TrueNeg', tn, epoch)
|
||||
writer.add_scalar('Eval/FalsePos', fp, epoch)
|
||||
writer.add_scalar('Eval/FalseNeg', fn, epoch)
|
||||
writer.add_scalar('Eval/TruePos', tp, epoch)
|
||||
|
||||
# specificity and sensitivity
|
||||
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
|
||||
sensitivity = recall # same as recall/TPR
|
||||
writer.add_scalar('Eval/Specificity', specificity, epoch)
|
||||
writer.add_scalar('Eval/Sensitivity', sensitivity, epoch)
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
|
||||
ax1.matshow(cm, cmap='Blues', alpha=0.7)
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
ax1.text(j, i, str(cm[i, j]), ha='center', va='center', fontsize=14)
|
||||
ax1.set_xlabel('Predicted')
|
||||
ax1.set_ylabel('True')
|
||||
ax1.set_title(f'Confusion Matrix (Epoch {epoch})')
|
||||
ax1.set_xticks([0, 1])
|
||||
ax1.set_yticks([0, 1])
|
||||
ax1.set_xticklabels(['Human', 'Agent'])
|
||||
ax1.set_yticklabels(['Human', 'Agent'])
|
||||
|
||||
# ROC curve
|
||||
fpr, tpr, _ = roc_curve(true_class, predicted_proba)
|
||||
ax2.plot(fpr, tpr, label=f'AUC={roc_auc:.3f}', linewidth=2)
|
||||
ax2.plot([0, 1], [0, 1], 'k--', label='Random')
|
||||
ax2.set_xlabel('False Positive Rate')
|
||||
ax2.set_ylabel('True Positive Rate')
|
||||
ax2.set_title('ROC Curve')
|
||||
ax2.legend()
|
||||
ax2.grid(alpha=0.3)
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.tight_layout()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
img = Image.open(buf)
|
||||
img_arr = np.array(img)
|
||||
writer.add_image('Eval/Metrics', img_arr, epoch, dataformats='HWC')
|
||||
plt.close()
|
||||
|
||||
logger.info(f"Eval {epoch}: Acc={accuracy:.4f} Prec={precision:.4f} Rec={recall:.4f} F1={f1:.4f} AUC={roc_auc:.4f}")
|
||||
@@ -1,6 +0,0 @@
|
||||
torch
|
||||
tensorboard
|
||||
fastparquet
|
||||
pyarrow
|
||||
xgboost
|
||||
lightgbm
|
||||
@@ -1,137 +0,0 @@
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from sklearn.model_selection import train_test_split
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import joblib
|
||||
from datetime import datetime
|
||||
from ml.evals import evaluate, log_feature_importance
|
||||
from ml.arch import XGBoostAgentClassifier, LightGBMAgentClassifier, LABELS
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
FEATURE_COLS_EXCLUDE = ['sessionId', 'experimentId', 'is_agent', 'xp_human_only', 'xp_market_mode', 'browser_family']
|
||||
RUNS_DIR = Path('ml/runs')
|
||||
CHECKPOINTS_DIR = Path('ml/checkpoints')
|
||||
|
||||
|
||||
def prepare_data(df):
|
||||
"""
|
||||
Prepare feature matrix and labels from raw dataframe
|
||||
Handles missing labels, feature selection, and categorical encoding
|
||||
Returns: (X, y, feature_cols)
|
||||
"""
|
||||
# drop rows with missing labels
|
||||
n_before = len(df)
|
||||
df = df[df['is_agent'].notna()].copy()
|
||||
n_dropped = n_before - len(df)
|
||||
if n_dropped > 0:
|
||||
logger.warning(f"Dropped {n_dropped} sessions with missing labels")
|
||||
|
||||
if len(df) == 0:
|
||||
logger.error("No labeled data available")
|
||||
return None, None, None
|
||||
|
||||
feature_cols = [c for c in df.columns if c not in FEATURE_COLS_EXCLUDE]
|
||||
|
||||
# handle categorical browser_family via one-hot encoding
|
||||
if 'browser_family' in df.columns:
|
||||
browser_dummies = pd.get_dummies(df['browser_family'], prefix='browser', drop_first=True)
|
||||
df = pd.concat([df, browser_dummies], axis=1)
|
||||
feature_cols.extend(browser_dummies.columns.tolist())
|
||||
|
||||
X = df[feature_cols].fillna(0)
|
||||
y = df['is_agent'].astype(int)
|
||||
|
||||
return X, y, feature_cols
|
||||
|
||||
|
||||
def train(data_path=None, model_type='xgboost', test_size=0.2, random_state=42,
|
||||
n_estimators=200, max_depth=6, learning_rate=0.05):
|
||||
"""
|
||||
Train agent detection classifier
|
||||
Args:
|
||||
data_path: path to labeled feature matrix CSV or parquet
|
||||
model_type: 'xgboost' or 'lightgbm'
|
||||
test_size: fraction for test split
|
||||
random_state: seed for reproducibility
|
||||
"""
|
||||
RUNS_DIR.mkdir(exist_ok=True)
|
||||
CHECKPOINTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
run_name = f"{model_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
writer = SummaryWriter(log_dir=RUNS_DIR / run_name)
|
||||
logger.info(f"Starting training run: {run_name}")
|
||||
|
||||
# load data
|
||||
if data_path is None:
|
||||
logger.error("data_path required")
|
||||
return
|
||||
df = pd.read_parquet(data_path)
|
||||
logger.info(f"Loaded {len(df)} sessions from {data_path}")
|
||||
|
||||
# prepare features and labels
|
||||
if 'is_agent' not in df.columns:
|
||||
logger.error("Missing is_agent column")
|
||||
return
|
||||
|
||||
X, y, feature_cols = prepare_data(df)
|
||||
if X is None:
|
||||
return
|
||||
|
||||
# class distribution
|
||||
n_agents = y.sum()
|
||||
n_humans = (y == 0).sum()
|
||||
logger.info(f"Class distribution: {n_humans} humans, {n_agents} agents" + (f" (ratio {n_humans / n_agents:.2f})" if n_agents > 0 else ""))
|
||||
|
||||
# train/test split with stratification
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=test_size, random_state=random_state, stratify=y
|
||||
)
|
||||
logger.info(f"Train: {len(X_train)}, Test: {len(X_test)}")
|
||||
|
||||
# init model
|
||||
if model_type == 'xgboost':
|
||||
model = XGBoostAgentClassifier(
|
||||
n_estimators=n_estimators,
|
||||
max_depth=max_depth,
|
||||
learning_rate=learning_rate
|
||||
)
|
||||
elif model_type == 'lightgbm':
|
||||
model = LightGBMAgentClassifier(
|
||||
n_estimators=n_estimators,
|
||||
max_depth=max_depth,
|
||||
learning_rate=learning_rate
|
||||
)
|
||||
else:
|
||||
logger.error(f"Unknown model type: {model_type}")
|
||||
return
|
||||
|
||||
# train with eval set for early stopping
|
||||
model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
|
||||
logger.info("Training complete")
|
||||
|
||||
# evaluate on test set
|
||||
y_pred = model.predict(X_test)
|
||||
y_prob = model.predict_proba(X_test)[:, 1]
|
||||
|
||||
evaluate(y_pred, y_prob, y_test, writer, epoch=0)
|
||||
|
||||
# log feature importance
|
||||
log_feature_importance(writer, model, X.columns.tolist(), epoch=0)
|
||||
|
||||
# save model
|
||||
model_path = CHECKPOINTS_DIR / f"{run_name}.pkl"
|
||||
joblib.dump({'model': model, 'feature_cols': X.columns.tolist(), 'run_name': run_name}, model_path)
|
||||
logger.info(f"Model saved to {model_path}")
|
||||
|
||||
writer.close()
|
||||
return model, X.columns.tolist()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
data_path = sys.argv[1]
|
||||
model_type = sys.argv[2] if len(sys.argv) > 2 else 'xgboost'
|
||||
train(data_path, model_type=model_type)
|
||||
@@ -12,14 +12,16 @@ from procesing.steps import (
|
||||
ComputeDemandStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
# StateSpace,
|
||||
# BuildStateSpaceStep,
|
||||
ComputeElasticityStep,
|
||||
StateSpace,
|
||||
BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
from procesing.pipelines import (
|
||||
interaction_extraction_pipeline,
|
||||
price_extraction_pipeline,
|
||||
elasticity_computation_pipeline,
|
||||
pricing_pipeline,
|
||||
full_pipeline,
|
||||
)
|
||||
@@ -40,12 +42,14 @@ __all__ = [
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
# 'StateSpace',
|
||||
# 'BuildStateSpaceStep',
|
||||
'ComputeElasticityStep',
|
||||
'StateSpace',
|
||||
'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'interaction_extraction_pipeline',
|
||||
'price_extraction_pipeline',
|
||||
'elasticity_computation_pipeline',
|
||||
'pricing_pipeline',
|
||||
'full_pipeline',
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ from sklearn.pipeline import Pipeline
|
||||
import pandas as pd
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
import os
|
||||
from typing import Union
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
@@ -13,15 +13,11 @@ from procesing.steps import (
|
||||
ChunkByTimeWindowStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep,
|
||||
BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
ComputeDemandStep,
|
||||
JoinProductFeaturesStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
ValidateDataStep,
|
||||
)
|
||||
from procesing.pricers import SimpleSurgePricer
|
||||
|
||||
def interaction_extraction_pipeline(context: PipelineContext):
|
||||
"""Pipeline for extracting and augmenting interaction data"""
|
||||
@@ -39,136 +35,104 @@ def price_extraction_pipeline(context: PipelineContext):
|
||||
])
|
||||
|
||||
|
||||
def product_features_pipeline(context: PipelineContext,
|
||||
def elasticity_computation_pipeline(context: PipelineContext,
|
||||
interactions_df: pd.DataFrame,
|
||||
price_logs_df: pd.DataFrame):
|
||||
demand_step = ComputeDemandStep(context)
|
||||
"""
|
||||
Compute elasticity from interactions and price logs.
|
||||
Manual orchestration needed for branching logic.
|
||||
"""
|
||||
# branch 1: chunk interactions and compute demand
|
||||
chunk_step = ChunkByTimeWindowStep(context)
|
||||
interaction_chunks = chunk_step.transform(interactions_df)
|
||||
|
||||
demand_step = ComputeDemandForChunksStep(context)
|
||||
demand_chunks = demand_step.transform(interaction_chunks)
|
||||
|
||||
# branch 2: aggregate price logs
|
||||
price_step = AggregatePriceLogsStep(context)
|
||||
join_step = JoinProductFeaturesStep(context)
|
||||
price_chunks = price_step.transform(price_logs_df)
|
||||
|
||||
# convergence: compute elasticity
|
||||
elasticity_step = ComputeElasticityStep(context)
|
||||
elasticity_df = elasticity_step.transform((demand_chunks, price_chunks))
|
||||
|
||||
return elasticity_df
|
||||
|
||||
|
||||
demand_data = demand_step.transform(interactions_df)
|
||||
price_data= price_step.transform(price_logs_df)
|
||||
joined_data = join_step.transform((demand_data, price_data))
|
||||
|
||||
return joined_data
|
||||
|
||||
|
||||
|
||||
def pricing_pipeline(context: "PipelineContext",
|
||||
data: pd.DataFrame,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9) -> pd.DataFrame:
|
||||
|
||||
if data.empty or 'productId' not in data.columns:
|
||||
return pd.DataFrame()
|
||||
|
||||
surge_pricer = SimpleSurgePricer()
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
return data
|
||||
|
||||
|
||||
def full_pipeline(context: PipelineContext,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9):
|
||||
def pricing_pipeline(context: PipelineContext, elasticity_df: pd.DataFrame):
|
||||
"""
|
||||
Complete end-to-end pipeline: data extraction -> demand/price aggregation -> surge pricing
|
||||
|
||||
Args:
|
||||
context: Pipeline context
|
||||
high_threshold: Demand threshold for surge pricing
|
||||
low_threshold: Demand threshold for discounts
|
||||
surge_multiplier: Price multiplier for high demand
|
||||
discount_multiplier: Price multiplier for low demand
|
||||
|
||||
Returns:
|
||||
tuple: (product_features_df, optimal_prices_df)
|
||||
- product_features_df: [productId, demand_score, price]
|
||||
- optimal_prices_df: [productId, current_price, optimal_price, demand_score]
|
||||
Generate optimal prices from elasticity estimates.
|
||||
"""
|
||||
# build state space
|
||||
state_step = BuildStateSpaceStep(context)
|
||||
state_space = state_step.transform(elasticity_df)
|
||||
|
||||
# fit pricing function
|
||||
fit_step = FitPricingFunctionStep(context)
|
||||
pricer = fit_step.transform(elasticity_df)
|
||||
|
||||
# predict prices
|
||||
predict_step = PredictPricesStep(context)
|
||||
prices_df = predict_step.transform((pricer, state_space))
|
||||
|
||||
return prices_df
|
||||
|
||||
|
||||
def full_pipeline(context: PipelineContext):
|
||||
"""
|
||||
Complete end-to-end pipeline: data extraction -> elasticity -> pricing
|
||||
Returns: (elasticity_df, prices_df)
|
||||
"""
|
||||
# extract interactions
|
||||
interaction_pipe = interaction_extraction_pipeline(context)
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
|
||||
interactions_df = interaction_pipe.fit_transform(None)
|
||||
|
||||
# extract price logs
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
price_logs_df = price_pipe.fit_transform(None)
|
||||
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||
print(product_features_df.to_string())
|
||||
|
||||
# generate optimal prices using surge rules
|
||||
optimal_prices_df = pricing_pipeline(context, product_features_df,
|
||||
high_threshold=high_threshold,
|
||||
low_threshold=low_threshold,
|
||||
surge_multiplier=surge_multiplier,
|
||||
discount_multiplier=discount_multiplier)
|
||||
if interactions_df.empty or price_logs_df.empty:
|
||||
return None, None
|
||||
|
||||
return product_features_df, optimal_prices_df
|
||||
# compute elasticity
|
||||
elasticity_df = elasticity_computation_pipeline(
|
||||
context,
|
||||
interactions_df,
|
||||
price_logs_df
|
||||
)
|
||||
|
||||
if elasticity_df is None or elasticity_df.empty:
|
||||
return elasticity_df, None
|
||||
|
||||
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||
"""
|
||||
Build labeled session-level feature matrix for ML model training.
|
||||
Pipeline: fetch -> validate -> extract features -> join labels
|
||||
|
||||
Returns:
|
||||
DataFrame with ~25 features per session + is_agent label
|
||||
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||
"""
|
||||
# fetch raw interactions
|
||||
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||
|
||||
# validate data quality (report cached in context)
|
||||
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||
if interactions_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# extract vectorized session features
|
||||
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||
if features_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# join experiment labels (is_agent = ~xp_human_only)
|
||||
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||
|
||||
return labeled_df
|
||||
|
||||
# generate prices
|
||||
prices_df = pricing_pipeline(context, elasticity_df)
|
||||
|
||||
return elasticity_df, prices_df
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
class ExperimentsProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||
base_path = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/" # os.path.join(os.path.dirname(__file__), "collected_data")
|
||||
if not os.path.isdir(base_path):
|
||||
return pd.DataFrame()
|
||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self, backend_url: str):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
||||
# example run
|
||||
context = PipelineContext(
|
||||
provider=Provider(backend_url="http://localhost:5000"),
|
||||
store_mode='hotel',
|
||||
)
|
||||
|
||||
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||
file_to_read = files.get(topic, files["user-interactions"])
|
||||
frames = []
|
||||
elasticity_df, prices_df = full_pipeline(context)
|
||||
|
||||
for d in os.listdir(base_path):
|
||||
full_path = os.path.join(base_path, d, file_to_read)
|
||||
if not os.path.isfile(full_path):
|
||||
continue
|
||||
try:
|
||||
data = pd.read_json(full_path)
|
||||
payloads = pd.DataFrame([r['payload'] for r in data['value'].to_list()])
|
||||
frames.append(payloads)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not process {full_path}: {e}")
|
||||
if elasticity_df is not None and not elasticity_df.empty:
|
||||
print("Elasticity Estimates:")
|
||||
print(elasticity_df.to_string(index=False))
|
||||
else:
|
||||
print("No elasticity estimates computed.")
|
||||
|
||||
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
|
||||
# demo: run ML training pipeline
|
||||
context = PipelineContext(provider=ExperimentsProvider(), store_mode='hotel')
|
||||
features = ml_training_pipeline(context)
|
||||
print(f"Feature matrix: {features.shape}")
|
||||
print(features.head())
|
||||
print(features.info())
|
||||
|
||||
features.to_parquet("features.parquet")
|
||||
if prices_df is not None and not prices_df.empty:
|
||||
print("\nPredicted Prices:")
|
||||
print(prices_df.to_string(index=False))
|
||||
else:
|
||||
print("No prices predicted.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from procesing.pricers.base import PricingFunction
|
||||
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer
|
||||
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||
|
||||
__all__ = [
|
||||
@@ -8,7 +8,6 @@ __all__ = [
|
||||
'ElasticityBasedPricer',
|
||||
'StaticPricer',
|
||||
'RandomPricer',
|
||||
'SimpleSurgePricer',
|
||||
'SessionAwarePricer',
|
||||
'ProductSpecificSessionPricer'
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ class PricingFunction(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fit(self, *kwargs):
|
||||
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
|
||||
@@ -36,7 +36,7 @@ class PricingFunction(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
def predict(self, state_space) -> np.ndarray:
|
||||
"""
|
||||
Generate optimal prices given current state.
|
||||
|
||||
|
||||
@@ -46,46 +46,3 @@ class RandomPricer(PricingFunction):
|
||||
if self.n_products is None:
|
||||
self.n_products = len(state_space.demand)
|
||||
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||
|
||||
|
||||
class SimpleSurgePricer(PricingFunction):
|
||||
"""
|
||||
Rule-based surge pricer adjusting prices via demand thresholds.
|
||||
Logic: if demand > high_threshold -> surge, if demand < low_threshold -> discount.
|
||||
Simpler and more controllable than curve fitting approaches.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_prices: np.ndarray = None,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9):
|
||||
self.base_prices = base_prices
|
||||
self.high_threshold = high_threshold
|
||||
self.low_threshold = low_threshold
|
||||
self.surge_multiplier = surge_multiplier
|
||||
self.discount_multiplier = discount_multiplier
|
||||
|
||||
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
|
||||
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
||||
|
||||
def predict(self) -> np.ndarray:
|
||||
"""
|
||||
Adjust prices based on current demand using surge rules.
|
||||
state_space.demand: demand counts per product
|
||||
state_space.prices: current prices (fallback if base_prices not set)
|
||||
"""
|
||||
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()
|
||||
|
||||
high_mask = demand >= self.high_threshold
|
||||
new_prices[high_mask] *= self.surge_multiplier
|
||||
|
||||
low_mask = demand <= self.low_threshold
|
||||
new_prices[low_mask] *= self.discount_multiplier
|
||||
|
||||
return new_prices
|
||||
|
||||
@@ -18,17 +18,10 @@ class SupabaseProvider(DataProvider):
|
||||
self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
|
||||
|
||||
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||
if not resp.data:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(resp.data)
|
||||
# normalize type column: hotel has room_type, airline has flight_type
|
||||
if 'room_type' in df.columns:
|
||||
df['product_type'] = df['room_type']
|
||||
elif 'flight_type' in df.columns:
|
||||
df['product_type'] = df['flight_type']
|
||||
return df
|
||||
resp = self.supabase.table(f'{store_mode}_products').select(
|
||||
"id, room_type, date_index, metadata, availability"
|
||||
).execute()
|
||||
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||
|
||||
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||
if not experiment_ids:
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
from procesing.steps.base import BaseContextStep
|
||||
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||
from procesing.steps.join import JoinExperimentsStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep
|
||||
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||
from procesing.steps.session import (
|
||||
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||
_extract_features_for_session
|
||||
)
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep, ComputeElasticityStep
|
||||
from procesing.steps.pricing import StateSpace, BuildStateSpaceStep, FitPricingFunctionStep, PredictPricesStep
|
||||
|
||||
__all__ = [
|
||||
'BaseContextStep',
|
||||
@@ -18,22 +13,15 @@ __all__ = [
|
||||
'FetchPriceLogsStep',
|
||||
'FetchExperimentsStep',
|
||||
'JoinExperimentsStep',
|
||||
'JoinProductFeaturesStep',
|
||||
'CreatePriceBucketsStep',
|
||||
'AugmentEventNamesStep',
|
||||
'AugmentInteractionsStep',
|
||||
'ChunkByTimeWindowStep',
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
'ComputeElasticityStep',
|
||||
'StateSpace',
|
||||
'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'ExtractSessionFeaturesStep',
|
||||
'JoinLabelsStep',
|
||||
'ValidateDataStep',
|
||||
'TemporalFeatureStep',
|
||||
'BehavioralFeatureStep',
|
||||
'ProductFeatureStep',
|
||||
'UserAgentFeatureStep',
|
||||
'_extract_features_for_session',
|
||||
]
|
||||
|
||||
@@ -2,93 +2,6 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
|
||||
class AugmentInteractionsStep(BaseContextStep):
|
||||
"""
|
||||
Consolidated step: create price buckets, augment event names, join experiments.
|
||||
Input: (interactions_df, price_logs_df)
|
||||
Output: enriched interactions_df
|
||||
"""
|
||||
|
||||
def transform(self, data: tuple):
|
||||
interactions_df, price_logs_df = data
|
||||
|
||||
if interactions_df.empty:
|
||||
return interactions_df
|
||||
|
||||
# Step 1: Create price buckets
|
||||
interactions_df = self._create_price_buckets(interactions_df)
|
||||
|
||||
# Step 2: Augment event names
|
||||
interactions_df = self._augment_event_names(interactions_df)
|
||||
|
||||
# Step 3: Join experiments (optional)
|
||||
if 'experimentId' in interactions_df.columns:
|
||||
interactions_df = self._join_experiments(interactions_df)
|
||||
|
||||
return interactions_df
|
||||
|
||||
def _create_price_buckets(self, df: pd.DataFrame):
|
||||
"""Create price bucket labels from price data"""
|
||||
if 'metadata_price' not in df.columns:
|
||||
df['price_bucket'] = ""
|
||||
return df
|
||||
|
||||
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||
|
||||
if df['metadata_price'].notnull().sum() > 0:
|
||||
try:
|
||||
price_buckets = pd.qcut(
|
||||
df['metadata_price'],
|
||||
q=n_buckets,
|
||||
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||
duplicates='drop'
|
||||
)
|
||||
except ValueError:
|
||||
# fallback for insufficient unique values
|
||||
price_buckets = df['metadata_price'].apply(
|
||||
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||
)
|
||||
else:
|
||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||
|
||||
df['price_bucket'] = price_buckets
|
||||
return df
|
||||
|
||||
def _augment_event_names(self, df: pd.DataFrame):
|
||||
"""Augment event names with product and price bucket schema"""
|
||||
# Create schema: _productId@price_bucket
|
||||
has_product = df.get('productId', pd.Series()).notnull()
|
||||
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||
|
||||
df['metadata_schema'] = np.where(
|
||||
has_product & has_bucket,
|
||||
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||
""
|
||||
)
|
||||
|
||||
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||
return df
|
||||
|
||||
def _join_experiments(self, df: pd.DataFrame):
|
||||
"""Join experiment metadata if experimentId present"""
|
||||
exp_ids = df['experimentId'].dropna().unique().tolist()
|
||||
if not exp_ids:
|
||||
return df
|
||||
|
||||
experiments_df = self.context.provider.fetch_experiments(exp_ids)
|
||||
if experiments_df.empty:
|
||||
return df
|
||||
|
||||
return df.merge(
|
||||
experiments_df,
|
||||
left_on='experimentId',
|
||||
right_on='id',
|
||||
how='left',
|
||||
suffixes=('', '_exp')
|
||||
)
|
||||
|
||||
|
||||
class CreatePriceBucketsStep(BaseContextStep):
|
||||
"""Create price bucket labels from price data"""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
from procesing.context import PipelineContext
|
||||
from typing import Any
|
||||
|
||||
class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||
"""
|
||||
@@ -17,7 +16,7 @@ class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, X) -> Any:
|
||||
def transform(self, X):
|
||||
"""Transform input using context. Must be implemented by subclass."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ class AggregatePriceLogsStep(BaseContextStep):
|
||||
"""
|
||||
Aggregate price logs into time windows using VECTORIZED operations.
|
||||
Input: price_logs_df
|
||||
Output: DataFrame with columns [productId, price]
|
||||
Output: list of price chunks with [productId, price]
|
||||
"""
|
||||
|
||||
def transform(self, price_logs_df: pd.DataFrame):
|
||||
if price_logs_df.empty:
|
||||
return pd.DataFrame(columns=['productId', 'price'])
|
||||
return []
|
||||
|
||||
df = price_logs_df.copy()
|
||||
ts_col = self.context.config.get('ts_col', 'ts')
|
||||
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||
window_size = self.context.window_size
|
||||
|
||||
# ensure datetime
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||
@@ -24,19 +24,230 @@ class AggregatePriceLogsStep(BaseContextStep):
|
||||
|
||||
df = df.sort_values([ts_col, 'productId'])
|
||||
products = self.context.products
|
||||
# get base price from metadata if available 1) read the metadata col as json and get the base_price
|
||||
products['base_price'] = products.apply(
|
||||
lambda row: row['metadata'].get('base_price', 0) if isinstance(row['metadata'], dict) else 0,
|
||||
axis=1
|
||||
)
|
||||
|
||||
unique_products = products['id'].unique()
|
||||
|
||||
# VECTORIZED: group by product, resample by time window, compute mean
|
||||
df_indexed = df.set_index(ts_col)
|
||||
# we return a df of average price per product over the entire period
|
||||
# TODO: maybe consider different opration to handle price aggregation over time
|
||||
avg_prices = df_indexed.groupby('productId')['price'].mean().reindex(unique_products, fill_value=0).reset_index()
|
||||
avg_prices.columns = ['productId', 'price']
|
||||
# fill 0s with base_price from products
|
||||
base_price_map = products.set_index('id')['base_price'].to_dict()
|
||||
return avg_prices
|
||||
|
||||
windowed = (
|
||||
df_indexed
|
||||
.groupby('productId')['price']
|
||||
.resample(window_size)
|
||||
.mean()
|
||||
.reset_index()
|
||||
)
|
||||
|
||||
# forward fill missing windows (carry last known price)
|
||||
windowed = windowed.sort_values([ts_col, 'productId'])
|
||||
windowed['price'] = windowed.groupby('productId')['price'].ffill()
|
||||
windowed = windowed.dropna(subset=['price'])
|
||||
|
||||
# group into chunks by window
|
||||
chunks = []
|
||||
for window_start, group in windowed.groupby(ts_col):
|
||||
price_vector = group[['productId', 'price']].copy()
|
||||
|
||||
# fill missing products with last known price before this window
|
||||
missing_products = set(unique_products) - set(price_vector['productId'])
|
||||
if missing_products:
|
||||
for pid in missing_products:
|
||||
last_price = df_indexed[
|
||||
(df_indexed['productId'] == pid) &
|
||||
(df_indexed.index < window_start)
|
||||
]['price']
|
||||
|
||||
if not last_price.empty:
|
||||
price_vector = pd.concat([
|
||||
price_vector,
|
||||
pd.DataFrame({'productId': [pid], 'price': [last_price.iloc[-1]]})
|
||||
], ignore_index=True)
|
||||
|
||||
if not price_vector.empty:
|
||||
chunks.append({
|
||||
'window_start': window_start,
|
||||
'window_end': window_start + pd.Timedelta(window_size),
|
||||
'price_vector': price_vector
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
class ComputeElasticityStep(BaseContextStep):
|
||||
"""
|
||||
Compute price elasticity from demand and price chunks.
|
||||
Input: (demand_chunks, price_chunks)
|
||||
Output: elasticity_df [productId, elasticity, std_error, n_obs]
|
||||
"""
|
||||
|
||||
def transform(self, chunk_tuple: tuple):
|
||||
demand_chunks, price_chunks = chunk_tuple
|
||||
|
||||
method = self.context.config.get('elasticity_method', 'point')
|
||||
min_obs = self.context.config.get('min_observations', 2)
|
||||
|
||||
products = self.context.products
|
||||
all_product_ids = products['id'].unique()
|
||||
|
||||
# align chunks by window_start
|
||||
aligned = self._align_chunks(demand_chunks, price_chunks)
|
||||
|
||||
if not aligned:
|
||||
return pd.DataFrame({
|
||||
'productId': all_product_ids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
|
||||
# build time series per product
|
||||
product_series = self._build_timeseries(aligned)
|
||||
|
||||
# compute elasticity per product
|
||||
elasticities = []
|
||||
for pid, series in product_series.items():
|
||||
if len(series) < min_obs:
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': len(series)
|
||||
})
|
||||
continue
|
||||
|
||||
elast = self._compute_elasticity(series, method)
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': elast['value'],
|
||||
'std_error': elast.get('std_error', 0.0),
|
||||
'n_obs': len(series)
|
||||
})
|
||||
|
||||
result_df = pd.DataFrame(elasticities)
|
||||
|
||||
# fill missing products with zero elasticity
|
||||
observed_pids = set(result_df['productId'])
|
||||
missing_pids = [p for p in all_product_ids if p not in observed_pids]
|
||||
|
||||
if missing_pids:
|
||||
missing_df = pd.DataFrame({
|
||||
'productId': missing_pids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
||||
|
||||
return result_df
|
||||
|
||||
def _align_chunks(self, demand_chunks: List[Dict], price_chunks: List[Dict]):
|
||||
"""Align demand and price chunks by window_start"""
|
||||
price_lookup = {c['window_start']: c for c in price_chunks}
|
||||
aligned = []
|
||||
|
||||
for dc in demand_chunks:
|
||||
ws = dc['window_start']
|
||||
if ws in price_lookup:
|
||||
aligned.append({
|
||||
'window_start': ws,
|
||||
'window_end': dc['window_end'],
|
||||
'demand': dc['demand_vector'],
|
||||
'prices': price_lookup[ws]['price_vector']
|
||||
})
|
||||
|
||||
return aligned
|
||||
|
||||
def _build_timeseries(self, aligned: List[Dict]):
|
||||
"""Build time series [timestamp, price, quantity] per product"""
|
||||
series_by_product = {}
|
||||
|
||||
for chunk in aligned:
|
||||
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
||||
|
||||
for _, row in merged.iterrows():
|
||||
pid = row['productId']
|
||||
if pid not in series_by_product:
|
||||
series_by_product[pid] = []
|
||||
|
||||
series_by_product[pid].append({
|
||||
'timestamp': chunk['window_start'],
|
||||
'price': row['price'],
|
||||
'quantity': row['demand_score']
|
||||
})
|
||||
|
||||
return series_by_product
|
||||
|
||||
def _compute_elasticity(self, series: List[Dict], method: str):
|
||||
"""Compute point or arc elasticity"""
|
||||
prices = np.array([s['price'] for s in series])
|
||||
quantities = np.array([s['quantity'] for s in series])
|
||||
|
||||
# filter out zero/negative values
|
||||
valid = (prices > 0) & (quantities > 0)
|
||||
if valid.sum() < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
prices = prices[valid]
|
||||
quantities = quantities[valid]
|
||||
|
||||
if method == 'point':
|
||||
return self._point_elasticity(prices, quantities)
|
||||
elif method == 'arc':
|
||||
return self._arc_elasticity(prices, quantities)
|
||||
else:
|
||||
raise ValueError(f"Unknown elasticity method: {method}")
|
||||
|
||||
def _point_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
||||
"""Point elasticity via log-log regression: log(Q) = a + b*log(P), elasticity = b"""
|
||||
if len(prices) < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
log_p = np.log(prices)
|
||||
log_q = np.log(quantities)
|
||||
|
||||
if log_p.std() == 0:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
cov = np.cov(log_p, log_q)[0, 1]
|
||||
var = np.var(log_p)
|
||||
b = cov / var
|
||||
|
||||
# std error estimate
|
||||
if len(prices) > 2:
|
||||
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
||||
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
||||
se_b = np.sqrt(mse / (len(prices) * var))
|
||||
else:
|
||||
se_b = 0.0
|
||||
|
||||
return {'value': b, 'std_error': se_b}
|
||||
|
||||
def _arc_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
||||
"""Arc elasticity: average period-over-period elasticity"""
|
||||
elasticities = []
|
||||
|
||||
for i in range(1, len(prices)):
|
||||
p1, p2 = prices[i-1], prices[i]
|
||||
q1, q2 = quantities[i-1], quantities[i]
|
||||
|
||||
p_avg = (p1 + p2) / 2
|
||||
q_avg = (q1 + q2) / 2
|
||||
|
||||
if p_avg == 0 or q_avg == 0:
|
||||
continue
|
||||
|
||||
delta_p = p2 - p1
|
||||
delta_q = q2 - q1
|
||||
|
||||
if delta_p == 0:
|
||||
continue
|
||||
|
||||
e = (delta_q / q_avg) / (delta_p / p_avg)
|
||||
elasticities.append(e)
|
||||
|
||||
if not elasticities:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
return {
|
||||
'value': np.mean(elasticities),
|
||||
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ import pandas as pd
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
class FetchInteractionsStep(BaseContextStep):
|
||||
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
"""Fetch raw interaction data from Kafka topic"""
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||
@@ -21,50 +17,19 @@ class FetchInteractionsStep(BaseContextStep):
|
||||
)
|
||||
|
||||
df = df.dropna(subset=['eventName'])
|
||||
# drop all where page has /admin/
|
||||
df = df[~df['page'].str.contains('/admin/', na=False)]
|
||||
|
||||
# filter by store_mode from context
|
||||
if 'storeMode' in df.columns:
|
||||
df = df[df['storeMode'] == self.context.store_mode]
|
||||
|
||||
# Remap dateIndex if present
|
||||
if 'metadata_dateIndex' in df.columns:
|
||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||
|
||||
# Apply time filtering if lookback specified
|
||||
if self.lookback and 'ts' in df.columns:
|
||||
df['ts'] = pd.to_datetime(df['ts'])
|
||||
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||
df = df[df['ts'] >= cutoff]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class FetchPriceLogsStep(BaseContextStep):
|
||||
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
"""Fetch price log data from Kafka topic"""
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# filter by store_mode from context
|
||||
if 'storeMode' in df.columns:
|
||||
df = df[df['storeMode'] == self.context.store_mode]
|
||||
|
||||
# Apply time filtering if lookback specified
|
||||
if self.lookback and 'ts' in df.columns:
|
||||
df['ts'] = pd.to_datetime(df['ts'])
|
||||
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||
df = df[df['ts'] >= cutoff]
|
||||
|
||||
return df
|
||||
return self.context.provider.fetch_kafka_topic('price-logs')
|
||||
|
||||
|
||||
class FetchExperimentsStep(BaseContextStep):
|
||||
|
||||
@@ -32,27 +32,3 @@ class JoinExperimentsStep(BaseContextStep):
|
||||
})
|
||||
|
||||
return interactions_df.merge(experiments_df, on='experimentId', how='left')
|
||||
|
||||
class JoinProductFeaturesStep(BaseContextStep):
|
||||
"""Join product features to interactions"""
|
||||
|
||||
def transform(self, data: tuple):
|
||||
"""
|
||||
Args:
|
||||
data: (interactions_df, products_df)
|
||||
Returns:
|
||||
merged interactions dataframe
|
||||
"""
|
||||
demand_df, price_df = data
|
||||
|
||||
# get base prices from products if available
|
||||
products = self.context.products
|
||||
products['base_price'] = products.apply(
|
||||
lambda row: float(row['metadata'].get('base_price', 0.0)) if isinstance(row['metadata'], dict) else 0,
|
||||
axis=1
|
||||
)
|
||||
products = products[['id', 'base_price']].rename(columns={'id': 'productId'})
|
||||
|
||||
if price_df.empty:
|
||||
return demand_df
|
||||
return demand_df.merge(price_df, on='productId', how='left').merge(products, on='productId', how='left')
|
||||
|
||||
@@ -2,34 +2,128 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
from procesing.pricers.simple import StaticPricer
|
||||
from procesing.steps.base import BaseContextStep
|
||||
from procesing.pricers import ElasticityBasedPricer
|
||||
|
||||
class State:
|
||||
def __init__(self,
|
||||
last_action : str,
|
||||
last_productId : str,
|
||||
last_price : float,
|
||||
session_features : np.ndarray
|
||||
):
|
||||
pass
|
||||
@dataclass
|
||||
class StateSpace:
|
||||
"""
|
||||
State representation for pricing functions.
|
||||
|
||||
Components:
|
||||
Q_t: demand ∈ R^n (current demand signal per product)
|
||||
P_t: prices ∈ R^n (current/base prices)
|
||||
S_t: session_features (behavioral signals, interaction data)
|
||||
H_t: history = {Q_{t-k}, P_{t-k}, S_{t-k}} for k in [1, history_length]
|
||||
|
||||
Additionally stores:
|
||||
- product_ids: product identifiers (n,)
|
||||
- elasticity: price elasticity per product (n,)
|
||||
- metadata: arbitrary context (experiment_id, timestamp, etc.)
|
||||
"""
|
||||
demand: np.ndarray # Q_t ∈ R^n
|
||||
prices: np.ndarray # P_t ∈ R^n
|
||||
session_features: pd.DataFrame = field(default_factory=pd.DataFrame) # S_t
|
||||
|
||||
# augmented state components
|
||||
product_ids: Optional[np.ndarray] = None
|
||||
elasticity: Optional[np.ndarray] = None
|
||||
|
||||
# historical trajectory H_t = {(Q_{t-k}, P_{t-k}, S_{t-k})}
|
||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# metadata for context
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate dimensions."""
|
||||
n = len(self.demand)
|
||||
assert len(self.prices) == n, "demand and prices must have same dimension"
|
||||
if self.elasticity is not None:
|
||||
assert len(self.elasticity) == n, "elasticity must match dimension"
|
||||
if self.product_ids is not None:
|
||||
assert len(self.product_ids) == n, "product_ids must match dimension"
|
||||
|
||||
@property
|
||||
def n_products(self) -> int:
|
||||
"""Number of products in state space."""
|
||||
return len(self.demand)
|
||||
|
||||
def add_history(self, q: np.ndarray, p: np.ndarray, s: pd.DataFrame, max_length: int = 10):
|
||||
"""Append historical state to trajectory H_t."""
|
||||
self.history.append({'demand': q, 'prices': p, 'session_features': s})
|
||||
if len(self.history) > max_length:
|
||||
self.history.pop(0)
|
||||
|
||||
def get_history_window(self, k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Retrieve last k historical states."""
|
||||
return self.history[-k:] if len(self.history) >= k else self.history
|
||||
|
||||
|
||||
class BuildStateSpaceStep(BaseContextStep):
|
||||
"""
|
||||
Build state space from elasticity, demand, and price data.
|
||||
|
||||
Input: elasticity_df [productId, elasticity, ...], optional demand_df
|
||||
Output: StateSpace instance with Q_t, P_t, elasticity, product_ids
|
||||
"""
|
||||
|
||||
def transform(self, elasticity_df: pd.DataFrame, demand_df: Optional[pd.DataFrame] = None):
|
||||
products = self.context.products
|
||||
|
||||
# extract base prices from product metadata
|
||||
products_with_prices = products.copy()
|
||||
if 'metadata' in products_with_prices.columns:
|
||||
products_with_prices['base_price'] = products_with_prices['metadata'].apply(
|
||||
lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0
|
||||
)
|
||||
else:
|
||||
products_with_prices['base_price'] = 0
|
||||
|
||||
# merge with elasticity
|
||||
merged = products_with_prices[['id', 'base_price']].rename(
|
||||
columns={'id': 'productId'}
|
||||
).merge(
|
||||
elasticity_df[['productId', 'elasticity']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'elasticity': 0.0, 'base_price': 0.0})
|
||||
|
||||
# merge with demand if provided, else use default
|
||||
if demand_df is not None and 'demand' in demand_df.columns:
|
||||
merged = merged.merge(
|
||||
demand_df[['productId', 'demand']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'demand': 0.0})
|
||||
demand_vector = merged['demand'].values
|
||||
else:
|
||||
# default: uniform demand or use elasticity as proxy
|
||||
demand_vector = np.ones(len(merged)) * 10.0
|
||||
|
||||
return StateSpace(
|
||||
demand=demand_vector,
|
||||
prices=merged['base_price'].values,
|
||||
session_features=pd.DataFrame(),
|
||||
product_ids=merged['productId'].values,
|
||||
elasticity=merged['elasticity'].values,
|
||||
metadata={'timestamp': pd.Timestamp.now().isoformat()}
|
||||
)
|
||||
|
||||
|
||||
class FitPricingFunctionStep(BaseContextStep):
|
||||
"""
|
||||
Fit pricing function using data.
|
||||
Input: pricing_data
|
||||
Fit pricing function using elasticity data.
|
||||
Input: elasticity_df
|
||||
Output: fitted pricing function instance
|
||||
"""
|
||||
|
||||
def transform(self, pricing_data: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||
def transform(self, elasticity_df: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', ElasticityBasedPricer)
|
||||
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||
|
||||
pricer = pricing_class(**pricing_params)
|
||||
pricer.fit(pricing_data)
|
||||
pricer.fit(elasticity_df)
|
||||
|
||||
return pricer
|
||||
|
||||
|
||||
@@ -1,261 +1,114 @@
|
||||
"""
|
||||
Session feature extraction for ML training pipeline.
|
||||
Session feature extraction for S_t component of state space.
|
||||
Computes behavioral signals from interaction data already in pipeline.
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from typing import Optional, Dict, Any
|
||||
from collections import Counter
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
EVENT_CATS = {
|
||||
'page_view': ['page_view'],
|
||||
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||
'cart_add': ['add_item_to_cart'],
|
||||
'purchase': ['purchase', 'checkout_complete'],
|
||||
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||
}
|
||||
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||
|
||||
|
||||
def _get_browser(s: str) -> str:
|
||||
if pd.isna(s): return 'Unknown'
|
||||
for name, pat in BROWSER_PATTERNS:
|
||||
if re.search(pat, s): return name
|
||||
return 'Other'
|
||||
|
||||
|
||||
class TemporalFeatureStep(BaseContextStep):
|
||||
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||
|
||||
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||
super().__init__(context)
|
||||
self.timeout_sec = timeout_sec
|
||||
self.velocity_window = velocity_window
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'ts' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
session_duration_sec=('active_diff', 'sum'),
|
||||
total_interactions=('sessionId', 'count'),
|
||||
avg_time_between_events=('time_diff', 'mean'),
|
||||
std_time_between_events=('time_diff', 'std'),
|
||||
min_time_between_events=('time_diff', 'min'),
|
||||
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||
).reset_index()
|
||||
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||
agg['interaction_velocity'] = np.where(
|
||||
agg['session_duration_sec'] > 0,
|
||||
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||
|
||||
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class BehavioralFeatureStep(BaseContextStep):
|
||||
"""Vectorized event counts and ratios per session."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'eventName' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
for cat, events in EVENT_CATS.items():
|
||||
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||
hover_events=('is_hover', 'sum'),
|
||||
# filter_events=('is_filter', 'sum'),
|
||||
).reset_index()
|
||||
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||
return agg
|
||||
|
||||
|
||||
class ProductFeatureStep(BaseContextStep):
|
||||
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||
|
||||
prod_df = df[df['productId'].notna()]
|
||||
if prod_df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||
|
||||
agg = prod_df.groupby('sessionId').agg(
|
||||
unique_products_viewed=('productId', 'nunique'),
|
||||
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||
max_price_seen=('price_seen', 'max'),
|
||||
).reset_index()
|
||||
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class UserAgentFeatureStep(BaseContextStep):
|
||||
"""Parse userAgent into bot-detection signals."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||
df = X.copy()
|
||||
if df.empty or 'userAgent' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||
|
||||
|
||||
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||
"""
|
||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||
Input: interactions_df
|
||||
Output: session-level feature matrix
|
||||
Extract session-level behavioral features from interaction logs.
|
||||
|
||||
Input: interactions_df (user-interactions from earlier pipeline step)
|
||||
Output: session_features DataFrame [sessionId, feature_1, feature_2, ...]
|
||||
|
||||
Features computed:
|
||||
- total_interactions: count of all events
|
||||
- page_views, item_views, searches, cart_adds: event type counts
|
||||
- hovers: hover event counts
|
||||
- unique_products_viewed: distinct product IDs
|
||||
- interaction_velocity: events per minute
|
||||
- session_duration_sec: time span of session
|
||||
- avg_time_between_events: mean inter-event time
|
||||
- product_view_depth: max views for single product (attention signal)
|
||||
"""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
if X.empty:
|
||||
def transform(self, interactions_df: pd.DataFrame) -> pd.DataFrame:
|
||||
if interactions_df.empty:
|
||||
return pd.DataFrame()
|
||||
df = X.copy()
|
||||
|
||||
# run all feature steps and merge on sessionId
|
||||
temporal = TemporalFeatureStep(self.context).transform(df)
|
||||
behavioral = BehavioralFeatureStep(self.context).transform(df)
|
||||
product = ProductFeatureStep(self.context).transform(df)
|
||||
ua = UserAgentFeatureStep(self.context).transform(df)
|
||||
# ensure timestamp column
|
||||
if 'ts' in interactions_df.columns:
|
||||
interactions_df = interactions_df.copy()
|
||||
interactions_df['ts'] = pd.to_datetime(interactions_df['ts'])
|
||||
|
||||
result = temporal
|
||||
for other in [behavioral, product, ua]:
|
||||
if not other.empty and 'sessionId' in other.columns:
|
||||
result = result.merge(other, on='sessionId', how='left')
|
||||
# group by session and compute features
|
||||
session_features = []
|
||||
for session_id, session_df in interactions_df.groupby('sessionId'):
|
||||
features = self._extract_features_for_session(session_id, session_df)
|
||||
session_features.append(features)
|
||||
|
||||
# carry forward experimentId for label joining
|
||||
if 'experimentId' in df.columns:
|
||||
exp_map = df.groupby('sessionId')['experimentId'].first()
|
||||
result = result.merge(exp_map, on='sessionId', how='left')
|
||||
return pd.DataFrame(session_features)
|
||||
|
||||
return result
|
||||
def _extract_features_for_session(self, session_id: str, session_df: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""Compute features for single session."""
|
||||
features = {'sessionId': session_id}
|
||||
|
||||
# basic counts
|
||||
features['total_interactions'] = len(session_df)
|
||||
|
||||
class JoinLabelsStep(BaseContextStep):
|
||||
"""
|
||||
Join experiment labels to session features.
|
||||
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||
Output: labeled feature matrix with is_agent column
|
||||
"""
|
||||
event_counts = session_df['eventName'].value_counts().to_dict()
|
||||
features['page_views'] = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
||||
features['item_views'] = event_counts.get('view_item_page', 0)
|
||||
features['searches'] = event_counts.get('search', 0)
|
||||
features['cart_adds'] = event_counts.get('add_item_to_cart', 0)
|
||||
|
||||
def transform(self, X : tuple) -> pd.DataFrame:
|
||||
data = X;
|
||||
if isinstance(data, tuple):
|
||||
features_df, experiments_df = data
|
||||
# hover events
|
||||
hover_events = ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button']
|
||||
features['hovers'] = sum(event_counts.get(ev, 0) for ev in hover_events)
|
||||
|
||||
# product-level signals
|
||||
product_ids = session_df['productId'].dropna()
|
||||
features['unique_products_viewed'] = product_ids.nunique()
|
||||
|
||||
if len(product_ids) > 0:
|
||||
product_view_counts = Counter(product_ids)
|
||||
features['product_view_depth'] = max(product_view_counts.values())
|
||||
else:
|
||||
features_df = data
|
||||
if 'experimentId' not in features_df.columns:
|
||||
return features_df
|
||||
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||
features['product_view_depth'] = 0
|
||||
|
||||
if features_df.empty:
|
||||
return features_df
|
||||
if experiments_df.empty:
|
||||
features_df['is_agent'] = np.nan
|
||||
return features_df
|
||||
# temporal features
|
||||
if 'ts' in session_df.columns:
|
||||
timestamps = session_df['ts'].sort_values()
|
||||
features['session_duration_sec'] = (timestamps.max() - timestamps.min()).total_seconds()
|
||||
|
||||
exp = experiments_df.copy()
|
||||
if 'id' in exp.columns:
|
||||
exp = exp.rename(columns={'id': 'experimentId'})
|
||||
if 'xp_human_only' in exp.columns:
|
||||
exp['is_agent'] = ~exp['xp_human_only']
|
||||
if features['session_duration_sec'] > 0:
|
||||
features['interaction_velocity'] = (features['total_interactions'] / features['session_duration_sec']) * 60
|
||||
else:
|
||||
features['interaction_velocity'] = 0.0
|
||||
|
||||
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||
# inter-event timing
|
||||
if len(timestamps) > 1:
|
||||
time_diffs = timestamps.diff().dropna().dt.total_seconds()
|
||||
features['avg_time_between_events'] = time_diffs.mean()
|
||||
features['std_time_between_events'] = time_diffs.std()
|
||||
else:
|
||||
features['avg_time_between_events'] = 0.0
|
||||
features['std_time_between_events'] = 0.0
|
||||
else:
|
||||
features['session_duration_sec'] = 0.0
|
||||
features['interaction_velocity'] = 0.0
|
||||
features['avg_time_between_events'] = 0.0
|
||||
features['std_time_between_events'] = 0.0
|
||||
|
||||
# cart/conversion signals
|
||||
features['cart_to_view_ratio'] = features['cart_adds'] / features['item_views'] if features['item_views'] > 0 else 0.0
|
||||
|
||||
return features
|
||||
|
||||
|
||||
class ValidateDataStep(BaseContextStep):
|
||||
class FilterSessionInteractionsStep(BaseContextStep):
|
||||
"""
|
||||
Data quality checks before training.
|
||||
Input: df
|
||||
Output: df (unchanged, but logs validation report to context)
|
||||
Filter interactions DataFrame to specific session.
|
||||
|
||||
Input: (interactions_df, session_id)
|
||||
Output: interactions_df filtered to session_id
|
||||
"""
|
||||
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||
if df.empty:
|
||||
report['status'] = 'empty'
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||
if missing:
|
||||
report['status'] = 'invalid'
|
||||
report['missing_cols'] = missing
|
||||
|
||||
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||
if 'experimentId' in df.columns:
|
||||
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
|
||||
# legacy compat - kept for backwards compatibility with existing code
|
||||
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||
"""Single-session feature extraction (legacy interface)."""
|
||||
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||
'session_duration_sec', 'interaction_velocity',
|
||||
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||
if session_df.empty:
|
||||
return defaults
|
||||
|
||||
session_df = session_df.copy()
|
||||
if 'sessionId' not in session_df.columns:
|
||||
session_df['sessionId'] = 'tmp'
|
||||
|
||||
# use a dummy context for the steps
|
||||
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||
ctx = DummyCtx()
|
||||
|
||||
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||
p = ProductFeatureStep(ctx).transform(session_df)
|
||||
|
||||
result = {}
|
||||
for df in [t, b, p]:
|
||||
if not df.empty:
|
||||
for col in df.columns:
|
||||
if col != 'sessionId':
|
||||
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||
|
||||
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||
for old, new in remap.items():
|
||||
if old in result:
|
||||
result[new] = result.pop(old)
|
||||
return result
|
||||
def transform(self, data: tuple) -> pd.DataFrame:
|
||||
interactions_df, session_id = data
|
||||
return interactions_df[interactions_df['sessionId'] == session_id].copy()
|
||||
|
||||
@@ -144,7 +144,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 162.47,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:57.967Z'
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 743.49,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:57.993Z'
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 163.87,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:58.009Z'
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 397.46,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:58.049Z'
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 401.66,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:06:08.864Z'
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ def mock_experiments():
|
||||
'created_at': pd.to_datetime(['2025-11-25T20:00:00Z', '2025-11-26T10:00:00Z']),
|
||||
'subject_name': ['Session A', 'Session B'],
|
||||
'xp_human_only': [True, False],
|
||||
'xp_market_mode': ['hotel', 'airline'],
|
||||
'xp_market_mode': ['hotel', 'shop'],
|
||||
'xp_task_id': [None, None]
|
||||
})
|
||||
|
||||
@@ -269,13 +269,3 @@ def empty_context(empty_provider):
|
||||
store_mode='hotel',
|
||||
window_size='30s'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_interactions(mock_interactions):
|
||||
"""Enriched interaction data for session feature extraction tests"""
|
||||
df = mock_interactions.copy()
|
||||
df['userAgent'] = ['Mozilla/5.0 Chrome/120', 'Mozilla/5.0 Chrome/120',
|
||||
'HeadlessChrome/120', 'HeadlessChrome/120', 'HeadlessChrome/120']
|
||||
df['metadata_base_price'] = [None, None, 150.0, 150.0, 200.0]
|
||||
return df
|
||||
|
||||
353
experiments/procesing/tests/test_elasticity.py
Normal file
353
experiments/procesing/tests/test_elasticity.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from procesing.steps import (
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep
|
||||
)
|
||||
|
||||
|
||||
def test_aggregate_price_logs_basic(pipeline_context):
|
||||
"""Test basic price aggregation into time windows"""
|
||||
step = AggregatePriceLogsStep(pipeline_context)
|
||||
|
||||
# Create price logs with known window structure
|
||||
df = pd.DataFrame({
|
||||
'ts': pd.date_range(start='2023-01-01 10:00:00', periods=100, freq='10s'),
|
||||
'productId': np.tile([
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||
], 34)[:100],
|
||||
'price': np.random.uniform(100, 200, 100)
|
||||
})
|
||||
|
||||
result = step.transform(df)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
# each chunk should have window metadata and price vector
|
||||
for chunk in result:
|
||||
assert 'window_start' in chunk
|
||||
assert 'window_end' in chunk
|
||||
assert 'price_vector' in chunk
|
||||
assert isinstance(chunk['price_vector'], pd.DataFrame)
|
||||
assert 'productId' in chunk['price_vector'].columns
|
||||
assert 'price' in chunk['price_vector'].columns
|
||||
|
||||
|
||||
def test_aggregate_price_logs_handles_gaps(pipeline_context):
|
||||
"""Test that price aggregation forward-fills missing windows"""
|
||||
step = AggregatePriceLogsStep(pipeline_context)
|
||||
|
||||
# create sparse data with gaps
|
||||
df = pd.DataFrame({
|
||||
'ts': pd.to_datetime([
|
||||
'2023-01-01 10:00:00',
|
||||
'2023-01-01 10:00:05',
|
||||
'2023-01-01 10:02:00', # gap of ~2 mins
|
||||
'2023-01-01 10:02:30'
|
||||
]),
|
||||
'productId': [
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11'
|
||||
],
|
||||
'price': [100, 102, 150, 153]
|
||||
})
|
||||
|
||||
result = step.transform(df)
|
||||
assert isinstance(result, list)
|
||||
# should have multiple windows despite gaps
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
def test_compute_elasticity_with_known_relationship(pipeline_context):
|
||||
"""Test elasticity computation with known price-demand relationship"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
# simulate elastic demand: when price ↑10%, demand ↓15% (elasticity ~ -1.5)
|
||||
base_price = 100
|
||||
base_demand = 50
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand * 0.85] # 15% decrease
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand * 0.70] # further decrease
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price * 1.10] # 10% increase
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price * 1.20] # 20% increase
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert not result.empty
|
||||
assert 'productId' in result.columns
|
||||
assert 'elasticity' in result.columns
|
||||
assert 'n_obs' in result.columns
|
||||
|
||||
# check elasticity is negative (normal good)
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['elasticity'] < 0
|
||||
# should be roughly elastic (< -1)
|
||||
assert product_elast.iloc[0]['n_obs'] == 3
|
||||
|
||||
|
||||
def test_compute_elasticity_inelastic_product(pipeline_context):
|
||||
"""Test with inelastic demand: price changes, demand barely moves"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
base_price = 150
|
||||
base_demand = 40
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'demand_score': [base_demand]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'demand_score': [base_demand * 0.98] # tiny 2% decrease
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'price': [base_price]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'price': [base_price * 1.20] # 20% increase
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
product_elast = result[result['productId'] == '51266ddb-5b07-47b7-89ee-5b5cae94bb11']
|
||||
assert len(product_elast) == 1
|
||||
# inelastic: elasticity between 0 and -1
|
||||
assert -1 < product_elast.iloc[0]['elasticity'] < 0
|
||||
|
||||
|
||||
def test_compute_elasticity_multiple_products(pipeline_context):
|
||||
"""Test elasticity computation across multiple products simultaneously"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
products = [
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||
]
|
||||
|
||||
# create 5 time windows with all 3 products
|
||||
demand_chunks = []
|
||||
price_chunks = []
|
||||
|
||||
for i in range(5):
|
||||
ts = pd.Timestamp('2023-01-01 10:00:00') + pd.Timedelta(f'{i*30}s')
|
||||
|
||||
demand_chunks.append({
|
||||
'window_start': ts,
|
||||
'window_end': ts + pd.Timedelta('30s'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': products,
|
||||
'demand_score': [
|
||||
50 * (0.9 ** i), # elastic: decreases as price rises
|
||||
40 * (0.98 ** i), # inelastic: barely changes
|
||||
30 * (0.85 ** i) # very elastic
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
price_chunks.append({
|
||||
'window_start': ts,
|
||||
'window_end': ts + pd.Timedelta('30s'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': products,
|
||||
'price': [
|
||||
100 * (1.05 ** i),
|
||||
150 * (1.10 ** i),
|
||||
120 * (1.08 ** i)
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert len(result) == 3 # all products should have elasticity
|
||||
assert set(result['productId']) == set(products)
|
||||
assert all(result['n_obs'] == 5)
|
||||
assert all(result['elasticity'] < 0) # all normal goods
|
||||
|
||||
|
||||
def test_compute_elasticity_insufficient_data(pipeline_context):
|
||||
"""Test behavior with insufficient observations"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
# only 1 observation
|
||||
demand_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [50]
|
||||
})
|
||||
}]
|
||||
|
||||
price_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
}]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
# should still return result but with low n_obs
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['n_obs'] == 1
|
||||
assert product_elast.iloc[0]['elasticity'] == 0.0 # not enough data
|
||||
|
||||
|
||||
def test_compute_elasticity_misaligned_chunks(pipeline_context):
|
||||
"""Test with non-overlapping demand and price windows"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
demand_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [50]
|
||||
})
|
||||
}]
|
||||
|
||||
price_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 11:00:00'), # different time
|
||||
'window_end': pd.Timestamp('2023-01-01 11:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
}]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
# should handle gracefully with no aligned data
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert all(result['n_obs'] == 0)
|
||||
|
||||
|
||||
def test_elasticity_arc_method(pipeline_context):
|
||||
"""Test arc elasticity computation method"""
|
||||
# configure context for arc method
|
||||
pipeline_context.config['elasticity_method'] = 'arc'
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [100]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [80]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [110]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['elasticity'] < 0
|
||||
# reset config
|
||||
pipeline_context.config['elasticity_method'] = 'point'
|
||||
@@ -26,7 +26,6 @@ class ModelRegistry:
|
||||
self.metadata_prefix = "model:meta:"
|
||||
self.data_prefix = "model:data:"
|
||||
self.elasticity_prefix = "elasticity:"
|
||||
self.prices_prefix = "prices:"
|
||||
|
||||
def publish_elasticity(self,
|
||||
elasticity_df: pd.DataFrame,
|
||||
@@ -131,46 +130,6 @@ class ModelRegistry:
|
||||
|
||||
return models
|
||||
|
||||
def publish_prices(self,
|
||||
prices_df: pd.DataFrame,
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Store predicted prices in registry.
|
||||
|
||||
Args:
|
||||
prices_df: df with [productId, predicted_price, ...]
|
||||
model_name: identifier for this price snapshot
|
||||
metadata: additional info
|
||||
"""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = prices_df.to_json(orient='records')
|
||||
|
||||
self.redis_client.set(key, data_json)
|
||||
|
||||
meta = metadata or {}
|
||||
meta.update({
|
||||
'n_products': len(prices_df),
|
||||
'model_type': 'predicted_prices'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
||||
|
||||
def get_prices(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||
"""Retrieve predicted prices from registry."""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = self.redis_client.get(key)
|
||||
|
||||
if data_json is None:
|
||||
return None
|
||||
|
||||
if isinstance(data_json, bytes):
|
||||
data_json = data_json.decode('utf-8')
|
||||
|
||||
return pd.read_json(data_json, orient='records')
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if Redis connection is alive."""
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
$pdf_mode = 1;
|
||||
$pdflatex = 'pdflatex -synctex=1 -interaction=nonstopmode -file-line-error %O %S';
|
||||
$bibtex_use = 2; # run bibtex when needed
|
||||
$aux_dir = 'build';
|
||||
$out_dir = 'build';
|
||||
$use_biber = 0; # force bibtex
|
||||
$bibtex = 'bibtex %O %B';
|
||||
$pdf_previewer = 'zathura %O %S';
|
||||
$clean_ext = 'synctex.gz bbl bcf run.xml fls fdb_latexmk glg glo gls ist blg lof lot out toc';
|
||||
|
||||
@@ -43,22 +43,22 @@ EOF
|
||||
echo "Concatenating code from source directories..."
|
||||
|
||||
# Backend
|
||||
find "$PROJECT_ROOT/backend" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||
find "$PROJECT_ROOT/backend" -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) | sort | while read -r file; do
|
||||
add_file "$file"
|
||||
done
|
||||
|
||||
# Experiments
|
||||
find "$PROJECT_ROOT/experiments" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" -o -name ".ipynb_checkpoints" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||
find "$PROJECT_ROOT/experiments" -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) | sort | while read -r file; do
|
||||
add_file "$file"
|
||||
done
|
||||
|
||||
# Docker
|
||||
find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||
find "$PROJECT_ROOT/docker" -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "Dockerfile*" \) | sort | while read -r file; do
|
||||
add_file "$file"
|
||||
done
|
||||
|
||||
# Web/src
|
||||
find "$PROJECT_ROOT/web/src" -type d \( -name "node_modules" -o -name ".next" -o -name "dist" -o -name "build" \) -prune -o -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" \) -print | sort | while read -r file; do
|
||||
find "$PROJECT_ROOT/web/src" -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" \) | sort | while read -r file; do
|
||||
add_file "$file"
|
||||
done
|
||||
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
(setq TeX-command-extra-options
|
||||
"-file-line-error -interaction=nonstopmode")
|
||||
(TeX-add-to-alist 'LaTeX-provided-class-options
|
||||
'(("report" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false" "manuscript") ("article" "12pt" "letterpaper")))
|
||||
'(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false")))
|
||||
(TeX-run-style-hooks
|
||||
"latex2e"
|
||||
"preamble"
|
||||
"chapters/01-intro"
|
||||
"chapters/02-literature-review"
|
||||
"article"
|
||||
"art12"))
|
||||
"chapters/03-methodology"
|
||||
"chapters/04-results"
|
||||
"chapters/05-discussion"
|
||||
"chapters/06-conclusion"
|
||||
"../build/concatenated_code"
|
||||
"acmart"
|
||||
"acmart10")
|
||||
(TeX-add-symbols
|
||||
'("footnotetextcopyrightpermission" 1)))
|
||||
:latex)
|
||||
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
|
||||
@article{arnoud_v_den_boer_dynamic_2015,
|
||||
title = {Dynamic pricing and learning: {Historical} origins, current research, and new directions},
|
||||
volume = {20},
|
||||
url = {https://www.sciencedirect.com/science/article/pii/S1876735415000021},
|
||||
doi = {10.1016/j.sorms.2015.03.001},
|
||||
number = {1},
|
||||
journal = {Surveys in Operations Research and Management Science},
|
||||
author = {{Arnoud V. den Boer}},
|
||||
month = jun,
|
||||
year = {2015},
|
||||
pages = {1--18},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/NUAGDYER/memo2025.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{iliou_detection_2021,
|
||||
title = {Detection of {Advanced} {Web} {Bots} by {Combining} {Web} {Logs} with {Mouse} {Behavioural} {Biometrics}},
|
||||
volume = {2},
|
||||
url = {https://dl.acm.org/doi/10.1145/3447815},
|
||||
doi = {10.1145/3447815},
|
||||
number = {3},
|
||||
journal = {Digital Threats: Research and Practice},
|
||||
author = {Iliou, Christos and Kostoulas, Theodoros and Tsikrika, Theodora and Katos, Vasilis and Vrochidis, Stefanos and Kompatsiaris, Ioannis},
|
||||
year = {2021},
|
||||
pages = {1--26},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/Q7J5EBEJ/3447815.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@phdthesis{salassa_politecnico_2024,
|
||||
title = {Politecnico di {Torino} {Algorithmic} {Pricing} in the digital age "{Ethical} considerations on its economic and social implications, and an analysis of possible solutions to overcome its critical issues" {Tutor}: {Candidate}},
|
||||
abstract = {Algorithmic pricing is an emerging business practice that uses computational algorithms to determine
|
||||
the prices of products and services based on a number of dynamic factors. The aim of this thesis is to
|
||||
draw attention to the existence of these business practices, and the ethical and social implications that
|
||||
derive from them, and then focus on what could be effective solutions to increase the well-being of
|
||||
the community.
|
||||
In Chapter 2 of the thesis, a general introduction to the topic will be made, starting from its history
|
||||
and its evolution over the years; Chapter 3 will examine the different types of pricing algorithms.
|
||||
Subsequently, in Chapter 4 we will analyze the sectors in which they are most applicable, and the
|
||||
relative advantages and disadvantages they bring with them, with a critical analysis of the trade-offs
|
||||
generated. The effect of algorithmic pricing on competition will be studied, considering how the
|
||||
ability of algorithms to adapt quickly to market conditions can foster anti-competitive practices, such
|
||||
as price discrimination. Later, in Chapter 5, we will look at the issue of price transparency and how
|
||||
the opacity of algorithms can make it difficult for consumers to understand the pricing process and
|
||||
assess whether they are receiving fair treatment.
|
||||
To address these ethical issues, several possible solutions will be brought to light, described in
|
||||
Chapter 6, which will focus on the role of the government, as a regulatory, of the end consumer, who
|
||||
must be encouraged to educate and inform himself about the use of these practices, and of the
|
||||
company, as responsible for making its customers aware and acting in compliance with government
|
||||
laws, for fair and non-discriminatory use.},
|
||||
urldate = {2025-11-12},
|
||||
school = {Politecnico di Torino},
|
||||
author = {Salassa, Fabio and Pautassi, Paolo},
|
||||
month = apr,
|
||||
year = {2024},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/L95WYQ8B/m-api-06aad998-d926-0d59-5593-82fdce5a678b.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@inproceedings{mueller_low-rank_2019,
|
||||
title = {Low-{Rank} {Bandit} {Methods} for {High}-{Dimensional} {Dynamic} {Pricing}},
|
||||
booktitle = {Advances in {Neural} {Information} {Processing} {Systems} 32 ({NeurIPS} 2019)},
|
||||
author = {Mueller, Jonas W and Syrgkanis, Vasilis and Taddy, Matt},
|
||||
year = {2019},
|
||||
pages = {15442--15452},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/IZD3C5SR/m-api-26f6207c-cc89-4aed-29b6-34629f18fe9b.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{shahidi_coasean_2025,
|
||||
title = {The {Coasean} {Singularity}? {Demand}, {Supply}, and {Market} {Design} with {AI} {Agents}},
|
||||
abstract = {AI agents—autonomous systems that perceive, reason, and act on behalf of human principals—are poised to transform digital markets by dramatically reducing transaction costs. This chapter evaluates the economic implications of this transition, adopting a consumeroriented view of agents as market participants that can search, negotiate, and transact directly. From the demand side, agent adoption reflects derived demand: users trade off decision quality against effort reduction, with outcomes mediated by agent capability and task context. On the supply side, firms will design, integrate, and monetize agents, with outcomes hinging on whether agents operate within or across platforms. At the market level, agents create efficiency gains from lower search, communication, and contracting costs, but also introduce frictions such as congestion and price obfuscation. By lowering the costs of preference elicitation, contract enforcement, and identity verification, agents expand the feasible set of market designs but also raise novel regulatory challenges. While the net welfare effects remain an empirical question, the rapid onset of AI-mediated transactions presents a unique opportunity for economic research to inform real-world policy and market design.},
|
||||
language = {en},
|
||||
author = {Shahidi, Peyman and Rusak, Gili and Manning, Benjamin S and Fradkin, Andrey and Horton, John J},
|
||||
year = {2025},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/TQCAPJDP/Shahidi et al. - The Coasean Singularity Demand, Supply, and Market Design with AI Agents.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{byrnes_intro_2025,
|
||||
title = {Intro to {Brain}-{Like}-{AGI} {Safety}},
|
||||
url = {https://osf.io/fe36n_v1},
|
||||
doi = {10.31219/osf.io/fe36n_v1},
|
||||
abstract = {Suppose we someday build an Artificial General Intelligence (AGI) algorithm using similar principles of learning and cognition as the human brain. How would we use such an algorithm safely? I argue that this is an open technical problem, and my goal is to bring readers with no prior knowledge all the way up to the front-line of unsolved problems. Chapter 1 has background and motivation; Chapters 2-7 are on neuroscience, arguing for a picture of the brain that combines large-scale learning algorithms (e.g. in the cortex) and specific evolved reflexes (e.g. in the hypothalamus and brainstem); and Chapters 8-15 apply those neuroscience ideas to AGI safety. A major theme is the idea that the brain has something like a reinforcement learning reward function, which says that pain is bad, eating-when-hungry is good, etc. I argue that this reward function is centered around the hypothalamus and brainstem, and that all human desires—even "higher" desires for things like compassion and justice—come directly or indirectly from that innate reward function. If future programmers build brain-like AGI, they will likewise have a reward function slot in their source code, in which they can put whatever they want. If they put the wrong thing, the resulting AGI will wind up callously indifferent to human welfare. How might they avoid that? That's an open technical problem, but I will review some ideas and research directions.},
|
||||
language = {en},
|
||||
urldate = {2025-12-31},
|
||||
publisher = {Open Science Framework},
|
||||
author = {Byrnes, Steven J.},
|
||||
month = mar,
|
||||
year = {2025},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/ZLJQ4DQ9/Byrnes - 2025 - Intro to Brain-Like-AGI Safety.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{shannon_mathematical_1948,
|
||||
title = {A {Mathematical} {Theory} of {Communication}},
|
||||
volume = {27},
|
||||
language = {en},
|
||||
journal = {Bell System Technical Journal},
|
||||
author = {Shannon, C E},
|
||||
month = oct,
|
||||
year = {1948},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/FJRFRWK2/Shannon - A Mathematical Theory of Communication.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{noauthor_order_stats_nodate,
|
||||
title = {order\_stats},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/D3QRGY9Z/order_stats.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{devine_nonlinear_2017,
|
||||
title = {Nonlinear {Pricing} with {Costly} {Information} {Acquisition}},
|
||||
abstract = {This paper examines a nonlinear pricing model where the firm can choose to acquire costly information prior to offering contract menus to consumers; such as paying a consultant or investing in machine learning technologies. Information provides the firm with a signal about consumers types, whose accuracy increases as the firm acquires larger amounts of information. We show that the firm chooses to acquire information, only if it can purchase a sufficient amount that could alter its initial prior beliefs. Relative to standard settings where firms cannot acquire information, we identify how information acquisition changes optimal contract offers, equilibrium profits, information rents, and welfare. A better-informed firm increases its expected profits, but it can also increase expected utility when the cost of information is intermediate. Our results recommend balanced online privacy laws.},
|
||||
language = {en},
|
||||
author = {Devine, Brett R and Munoz-Garcia, Felix},
|
||||
month = nov,
|
||||
year = {2017},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/GQ28KVBF/Devine and Munoz-Garcia - Nonlinear Pricing with Costly Information Acquisition.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{wang_learning_2025,
|
||||
title = {Learning {Optimal} {Distributionally} {Robust} {Stochastic} {Control} in {Continuous} {State} {Spaces}},
|
||||
url = {http://arxiv.org/abs/2406.11281},
|
||||
doi = {10.48550/arXiv.2406.11281},
|
||||
abstract = {We study data-driven learning of robust stochastic control for infinite-horizon systems with potentially continuous state and action spaces. In many managerial settings–supply chains, finance, manufacturing, services, and dynamic games–the state-transition mechanism is determined by system design, while available data capture the distributional properties of the stochastic inputs from the environment. For modeling and computational tractability, a decision maker often adopts a Markov control model with i.i.d. environment inputs, which can render learned policies fragile to internal dependence or external perturbations. We introduce a distributionally robust stochastic control paradigm that promotes policy reliability by introducing adaptive adversarial perturbations to the environment input, while preserving the modeling, statistical, and computational tractability of the Markovian formulation. From a modeling perspective, we examine two adversary models–current-action-aware and current-action-unaware–leading to distinct dynamic behaviors and robust optimal policies. From a statistical learning perspective, we characterize optimal finite-sample minimax rates for uniform learning of the robust value function across a continuum of states under ambiguity sets defined by the fk-divergence and Wasserstein distance. To efficiently compute the optimal robust policies, we further propose algorithms inspired by deep reinforcement learning methodologies. Finally, we demonstrate the applicability of the framework to real managerial problems.},
|
||||
language = {en},
|
||||
urldate = {2025-12-29},
|
||||
publisher = {arXiv},
|
||||
author = {Wang, Shengbo and Meng, Jason and Si, Nian and Blanchet, Jose and Zhou, Zhengyuan},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
note = {arXiv:2406.11281 [stat]},
|
||||
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/RQ8XDSSG/Wang et al. - 2025 - Learning Optimal Distributionally Robust Stochastic Control in Continuous State Spaces.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{ie_recsim_2019,
|
||||
title = {{RecSim}: {A} {Configurable} {Simulation} {Platform} for {Recommender} {Systems}},
|
||||
shorttitle = {{RecSim}},
|
||||
url = {http://arxiv.org/abs/1909.04847},
|
||||
doi = {10.48550/arXiv.1909.04847},
|
||||
abstract = {We propose RecSim, a configurable platform for authoring simulation environments for recommender systems (RSs) that naturally supports sequential interaction with users. RecSim allows the creation of new environments that reflect particular aspects of user behavior and item structure at a level of abstraction well-suited to pushing the limits of current reinforcement learning (RL) and RS techniques in sequential interactive recommendation problems. Environments can be easily configured that vary assumptions about: user preferences and item familiarity; user latent state and its dynamics; and choice models and other user response behavior. We outline how RecSim offers value to RL and RS researchers and practitioners, and how it can serve as a vehicle for academic-industrial collaboration.},
|
||||
urldate = {2025-12-29},
|
||||
publisher = {arXiv},
|
||||
author = {Ie, Eugene and Hsu, Chih-wei and Mladenov, Martin and Jain, Vihan and Narvekar, Sanmit and Wang, Jing and Wu, Rui and Boutilier, Craig},
|
||||
month = sep,
|
||||
year = {2019},
|
||||
note = {arXiv:1909.04847 [cs]},
|
||||
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Computer Science - Human-Computer Interaction, Computer Science - Information Retrieval},
|
||||
file = {Preprint PDF:/home/velocitatem/Zotero/storage/CJJI2VQF/Ie et al. - 2019 - RecSim A Configurable Simulation Platform for Recommender Systems.pdf:application/pdf;Snapshot:/home/velocitatem/Zotero/storage/8XJKJTHE/1909.html:text/html},
|
||||
}
|
||||
|
||||
@misc{kuhn_wasserstein_2024,
|
||||
title = {Wasserstein {Distributionally} {Robust} {Optimization}: {Theory} and {Applications} in {Machine} {Learning}},
|
||||
shorttitle = {Wasserstein {Distributionally} {Robust} {Optimization}},
|
||||
url = {http://arxiv.org/abs/1908.08729},
|
||||
doi = {10.48550/arXiv.1908.08729},
|
||||
abstract = {Many decision problems in science, engineering and economics are affected by uncertain parameters whose distribution is only indirectly observable through samples. The goal of data-driven decision-making is to learn a decision from finitely many training samples that will perform well on unseen test samples. This learning task is difficult even if all training and test samples are drawn from the same distribution—especially if the dimension of the uncertainty is large relative to the training sample size. Wasserstein distributionally robust optimization seeks data-driven decisions that perform well under the most adverse distribution within a certain Wasserstein distance from a nominal distribution constructed from the training samples. In this tutorial we will argue that this approach has many conceptual and computational benefits. Most prominently, the optimal decisions can often be computed by solving tractable convex optimization problems, and they enjoy rigorous out-of-sample and asymptotic consistency guarantees. We will also show that Wasserstein distributionally robust optimization has interesting ramifications for statistical learning and motivates new approaches for fundamental learning tasks such as classification, regression, maximum likelihood estimation or minimum mean square error estimation, among others.},
|
||||
language = {en},
|
||||
urldate = {2025-12-27},
|
||||
publisher = {arXiv},
|
||||
author = {Kuhn, Daniel and Esfahani, Peyman Mohajerin and Nguyen, Viet Anh and Shafieezadeh-Abadeh, Soroosh},
|
||||
month = nov,
|
||||
year = {2024},
|
||||
note = {arXiv:1908.08729 [stat]},
|
||||
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Mathematics - Optimization and Control},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/FAWJEK6J/Kuhn et al. - 2024 - Wasserstein Distributionally Robust Optimization Theory and Applications in Machine Learning.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{arunachaleswaran_learning_2025,
|
||||
title = {Learning to {Play} {Against} {Unknown} {Opponents}},
|
||||
url = {http://arxiv.org/abs/2412.18297},
|
||||
doi = {10.48550/arXiv.2412.18297},
|
||||
abstract = {We consider the problem of a learning agent who has to repeatedly play a general sum game against a strategic opponent who acts to maximize their own payoff by optimally responding against the learner’s algorithm. The learning agent knows their own payoff function, but is uncertain about the payoff of their opponent (knowing only that it is drawn from some distribution D). What learning algorithm should the agent run in order to maximize their own total utility, either in expectation or in the worst-case over D? When the learning algorithm is constrained to be a no-regret algorithm, we demonstrate how to efficiently construct an optimal learning algorithm (asymptotically achieving the optimal utility) in polynomial time for both the in-expectation and worst-case problems, independent of any other assumptions. When the learning algorithm is not constrained to no-regret, we show how to construct an ε-optimal learning algorithm (obtaining average utility within ε of the optimal utility) for both the in-expectation and worst-case problems in time polynomial in the size of the input and 1/ε, when either the size of the game or the support of D is constant. Finally, for the special case of the maximin objective, where the learner wishes to maximize their minimum payoff over all possible optimizer types, we construct a learner algorithm that runs in polynomial time in each step and guarantees convergence to the optimal learner payoff. All of these results make use of recently developed machinery that converts the analysis of learning algorithms to the study of the class of corresponding geometric objects known as menus.},
|
||||
language = {en},
|
||||
urldate = {2025-12-27},
|
||||
publisher = {arXiv},
|
||||
author = {Arunachaleswaran, Eshwar Ram and Collina, Natalie and Schneider, Jon},
|
||||
month = feb,
|
||||
year = {2025},
|
||||
note = {arXiv:2412.18297 [cs]},
|
||||
keywords = {Computer Science - Machine Learning, Computer Science - Computer Science and Game Theory},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/M6V9LLCS/Arunachaleswaran et al. - 2025 - Learning to Play Against Unknown Opponents.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{li_distributionally_2025,
|
||||
title = {Distributionally {Robust} {Optimization} with {Adversarial} {Data} {Contamination}},
|
||||
url = {http://arxiv.org/abs/2507.10718},
|
||||
doi = {10.48550/arXiv.2507.10718},
|
||||
abstract = {Distributionally Robust Optimization (DRO) provides a framework for decision-making under distributional uncertainty, yet its effectiveness can be compromised by outliers in the training data. This paper introduces a principled approach to simultaneously address both challenges. We focus on optimizing Wasserstein-1 DRO objectives for generalized linear models with convex Lipschitz loss functions, where an \$ε\$-fraction of the training data is adversarially corrupted. Our primary contribution lies in a novel modeling framework that integrates robustness against training data contamination with robustness against distributional shifts, alongside an efficient algorithm inspired by robust statistics to solve the resulting optimization problem. We prove that our method achieves an estimation error of \$O({\textbackslash}sqrtε)\$ for the true DRO objective value using only the contaminated data under the bounded covariance assumption. This work establishes the first rigorous guarantees, supported by efficient computation, for learning under the dual challenges of data contamination and distributional shifts.},
|
||||
language = {en},
|
||||
urldate = {2025-12-27},
|
||||
publisher = {arXiv},
|
||||
author = {Li, Shuyao and Diakonikolas, Ilias and Diakonikolas, Jelena},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
note = {arXiv:2507.10718 [cs]},
|
||||
keywords = {Computer Science - Machine Learning, Mathematics - Optimization and Control, Computer Science - Data Structures and Algorithms},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/H6AXDTLX/Li et al. - 2025 - Distributionally Robust Optimization with Adversarial Data Contamination.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{karten_llm_2025,
|
||||
title = {{LLM} {Economist}: {Large} {Population} {Models} and {Mechanism} {Design} in {Multi}-{Agent} {Generative} {Simulacra}},
|
||||
shorttitle = {{LLM} {Economist}},
|
||||
url = {http://arxiv.org/abs/2507.15815},
|
||||
doi = {10.48550/arXiv.2507.15815},
|
||||
abstract = {We present the LLM Economist, a novel framework that uses agent-based modeling to design and assess economic policies in strategic environments with hierarchical decision-making. At the lower level, bounded rational worker agents—instantiated as persona-conditioned prompts sampled from U.S. Census-calibrated income and demographic statistics—choose labor supply to maximize text-based utility functions learned in-context. At the upper level, a planner agent employs in-context reinforcement learning to propose piecewise-linear marginal tax schedules anchored to the current U.S. federal brackets. This construction endows economic simulacra with three capabilities requisite for credible fiscal experimentation: (i) optimization of heterogeneous utilities, (ii) principled generation of large, demographically realistic agent populations, and (iii) mechanism design—the ultimate nudging problem—expressed entirely in natural language. Experiments with populations of up to one hundred interacting agents show that the planner converges near Stackelberg equilibria that improve aggregate social welfare relative to Saez solutions, while a periodic, persona-level voting procedure furthers these gains under decentralized governance. These results demonstrate that large language model-based agents can jointly model, simulate, and govern complex economic systems, providing a tractable test bed for policy evaluation at the societal scale to help build better civilizations.},
|
||||
language = {en},
|
||||
urldate = {2025-12-27},
|
||||
publisher = {arXiv},
|
||||
author = {Karten, Seth and Li, Wenzhe and Ding, Zihan and Kleiner, Samuel and Bai, Yu and Jin, Chi},
|
||||
month = jul,
|
||||
year = {2025},
|
||||
note = {arXiv:2507.15815 [cs]},
|
||||
keywords = {Computer Science - Machine Learning, Computer Science - Multiagent Systems},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/U7A5Q78V/Karten et al. - 2025 - LLM Economist Large Population Models and Mechanism Design in Multi-Agent Generative Simulacra.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{mullapudi_reinforcement_2025,
|
||||
title = {A {Reinforcement} {Learning} {Approach} to {Dynamic} {Pricing}},
|
||||
abstract = {Dynamic pricing represents a critical strategic challenge in modern e-commerce, where firms must navigate fluctuating demand, inventory constraints, and aggressive competitor actions. Traditional static and heuristic-based pricing models often fail to capture the complex, non-linear dynamics of competitive digital markets, leading to suboptimal profitability. This paper proposes a model-free reinforcement learning (RL) framework to address this challenge. Specifically, we design, implement, and evaluate a Q-learning agent capable of learning an optimal, state-dependent pricing policy. The agent is trained and evaluated within a simulated market environment constructed from the publicly available "Retail Price Optimization" dataset from Kaggle, which provides a rich feature set including historical sales, product characteristics, seasonality, and, crucially, competitor pricing data. The problem is formulated as a Markov Decision Process (MDP), where the agent's state incorporates its price position relative to competitors, competitor price trends, and seasonal factors. The agent's performance is benchmarked against three baseline strategies: static pricing, a reactive "follow-the-leader" heuristic, and random pricing. The results demonstrate that the Q-learning agent achieves a substantial increase in total cumulative profit over the evaluation period, outperforming all baselines by learning a nuanced policy that strategically balances price adjustments in response to market conditions. This work provides a practical and reproducible blueprint for applying reinforcement learning to optimize pricing decisions in a simulated yet realistic competitive retail environment, highlighting the potential of RL to automate complex strategic decision-making.},
|
||||
author = {Mullapudi, Pavan},
|
||||
year = {2025},
|
||||
note = {Publication Title: International Journal on Science and Technology (IJSAT) IJSAT25049558
|
||||
Volume: 16
|
||||
Issue: 4},
|
||||
keywords = {Index Terms: Dynamic Pricing, Markov Decision Process, Price Optimization, Q-Learning, Reinforcement Learning, Retail Analytics},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/G95TBLF7/9558.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{roughgarden_cs364a_2013,
|
||||
title = {{CS364A}: {Algorithmic} {Game} {Theory} {Lecture} \#5: {Revenue}-{Maximizing} {Auctions} *},
|
||||
author = {Roughgarden, Tim},
|
||||
year = {2013},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/C39VM7N9/l5.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{kuhn_distributionally_2025,
|
||||
title = {Distributionally {Robust} {Optimization}},
|
||||
abstract = {Distributionally robust optimization (DRO) studies decision problems under uncertainty where the probability distribution governing the uncertain problem parameters is itself uncertain. A key component of any DRO model is its ambiguity set, that is, a family of probability distributions consistent with any available structural or statistical information. DRO seeks decisions that perform best under the worst distribution in the ambiguity set. This worst case criterion is supported by findings in psychology and neuroscience, which indicate that many decision-makers have a low tolerance for distributional ambiguity. DRO is rooted in statistics, operations research and control theory, and recent research has uncovered its deep connections to regularization techniques and adversarial training in machine learning. This survey presents the key findings of the field in a unified and self-contained manner.},
|
||||
author = {Kuhn, Daniel and Shafiee, Soroosh and Wiesemann, Wolfram},
|
||||
year = {2025},
|
||||
note = {arXiv: 2411.02549v3},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/IXTTMD7G/full-text.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{parkes_economic_2015,
|
||||
title = {Economic reasoning and artificial intelligence},
|
||||
volume = {349},
|
||||
issn = {10959203},
|
||||
doi = {10.1126/science.aaa8403},
|
||||
abstract = {The field of artificial intelligence (AI) strives to build rational agents capable of perceiving the world around them and taking actions to advance specified goals. Put another way, AI researchers aim to construct a synthetic homo economicus, the mythical perfectly rational agent of neoclassical economics.We review progress toward creating this new species of machine, machina economicus, and discuss some challenges in designing AIs that can reason effectively in economic contexts. Supposing that AI succeeds in this quest, or at least comes close enough that it is useful to think about AIs in rationalistic terms, we ask how to design the rules of interaction in multi-agent systems that come to represent an economy of AIs.Theories of normative design from economics may prove more relevant for artificial agents than human agents, with AIs that better respect idealized assumptions of rationality than people, interacting through novel rules and incentive systems quite distinct from those tailored for people.},
|
||||
number = {6245},
|
||||
journal = {Science},
|
||||
author = {Parkes, David C. and Wellman, Michael P.},
|
||||
month = jul,
|
||||
year = {2015},
|
||||
pmid = {26185245},
|
||||
note = {Publisher: American Association for the Advancement of Science},
|
||||
pages = {267--272},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/27KLNFRU/_aiEcon.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{yokoo_effect_2004,
|
||||
title = {The effect of false-name bids in combinatorial auctions: {New} fraud in internet auctions},
|
||||
volume = {46},
|
||||
issn = {08998256},
|
||||
doi = {10.1016/S0899-8256(03)00045-9},
|
||||
abstract = {We examine the effect of false-name bids on combinatorial auction protocols. False-name bids are bids submitted by a single bidder using multiple identifiers such as multiple e-mail addresses. The obtained results are summarized as follows: (1) the Vickrey-Clarke-Groves (VCG) mechanism, which is strategy-proof and Pareto efficient when there exists no false-name bid, is not false-name-proof; (2) there exists no false-name-proof combinatorial auction protocol that satisfies Pareto efficiency; (3) one sufficient condition where the VCG mechanism is false-name-proof is identified, i.e., the concavity of a surplus function over bidders. © 2003 Elsevier Inc. All rights reserved.},
|
||||
number = {1},
|
||||
journal = {Games and Economic Behavior},
|
||||
author = {Yokoo, Makoto and Sakurai, Yuko and Matsubara, Shigeo},
|
||||
year = {2004},
|
||||
note = {Publisher: Academic Press Inc.},
|
||||
keywords = {Auction, Mechanism design, Strategy-proof},
|
||||
pages = {174--188},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/LUVQV6WT/Yokoo04.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@inproceedings{feldman_free-riding_2004,
|
||||
title = {Free-riding and whitewashing in peer-to-peer systems},
|
||||
isbn = {1-58113-942-X},
|
||||
doi = {10.1145/1016527.1016539},
|
||||
abstract = {We develop a model to study the phenomenon of free-riding in peer-to-peer (P2P) systems. At the heart of our model is a user of a certain type, an intrinsic and private parameter that reflects the user's willingness to contribute resources to the system. A user decides whether to contribute or free-ride based on how the current contribution cost in the system compares to her type. When the societal generosity (i.e., the average type) is low, intervention is required in order to sustain the system. We present the effect of mechanisms that exclude low type users or, more realistic, penalize free-riders with degraded service. We also consider dynamic scenarios with arrivals and departures of users, and with whitewashers: users who leave the system and rejoin with new identities to avoid reputational penalties. We find that when penalty is imposed on all newcomers in order to avoid whitewashing, system performance degrades significantly only when the turnover rate among users is high.},
|
||||
booktitle = {Proceedings of the {ACM} {SIGCOMM} 2004 {Workshops}},
|
||||
publisher = {Association for Computing Machinery},
|
||||
author = {Feldman, Michal and Papadimitriou, Christos and Chuang, John and Stoica, Ion},
|
||||
year = {2004},
|
||||
keywords = {Cheap pseudonyms, Cooperation, Equilibrium, Exclusion, Free-riding, Identity cost, Incentives, Peer-to-peer, Whitewashing},
|
||||
pages = {228--235},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/K32WH6SB/1016527.1016539.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{calvano_artificial_2018,
|
||||
title = {Artificial {Intelligence}, {Algorithmic} {Pricing} and {Collusion}},
|
||||
url = {https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3304991},
|
||||
doi = {10.2139/ssrn.3304991},
|
||||
journal = {SSRN Electronic Journal},
|
||||
author = {Calvano, Emilio and Calzolari, Giacomo and Denicolo, Vincenzo and Pastorello, Sergio},
|
||||
year = {2018},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/WYTSSZBR/ssrn-3304991.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{varian_economic_1995,
|
||||
title = {Economic {Mechanism} {Design} for {Computerized} {Agents}},
|
||||
abstract = {The eeld of economic mechanism design has been an active area of research in economics for at least 20 years. This eld uses the tools of economics and game theory to design {\textbackslash}rules of interaction" for economic transactions that will, in principle , yield some desired outcome. In this paper I provide an overview of this subject for an audience interested in applications to electronic commerce and discuss some special problems that arise in this context.},
|
||||
author = {Varian, Hal R},
|
||||
year = {1995},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/S8635QX6/varian95a.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@book{russell_artificial_2021,
|
||||
title = {Artificial {Intelligence} {A} {Modern} {Approach} {Fourth} {Edition} {Global} {Edition}},
|
||||
isbn = {978-1-292-40117-1},
|
||||
author = {Russell, Stuart and Norvig, Peter},
|
||||
year = {2021},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/6B8W8S27/efdd4d1d4c2087fe1cbe03d9ced67f34.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{wellman_price_2004,
|
||||
title = {Price {Prediction} in a {Trading} {Agent} {Competition} {Yevgeniy} {Vorobeychik}},
|
||||
abstract = {The 2002 Trading Agent Competition (TAC) presented a challenging market game in the domain of travel shopping. One of the pivotal issues in this domain is uncertainty about hotel prices, which have a significant influence on the relative cost of alternative trip schedules. Thus, virtually all participants employ some method for predicting hotel prices. We survey approaches employed in the tournament, finding that agents apply an interesting diversity of techniques, taking into account differing sources of evidence bearing on prices. Based on data provided by entrants on their agents' actual predictions in the TAC-02 finals and semifinals, we analyze the relative efficacy of these approaches. The results show that taking into account game-specific information about flight prices is a major distinguishing factor. Machine learning methods effectively induce the relationship between flight and hotel prices from game data, and a purely analytical approach based on competitive equilibrium analysis achieves equal accuracy with no historical data. Employing a new measure of prediction quality, we relate absolute accuracy to bottom-line performance in the game.},
|
||||
author = {Wellman, Michael P and Reeves, Daniel M and Lochner, Kevin M and Edu, Yvorobey@umich},
|
||||
year = {2004},
|
||||
note = {Publication Title: Journal of Artificial Intelligence Research
|
||||
Volume: 21},
|
||||
pages = {19--36},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/N9JNXFJW/live-1333-2265-jair.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{shoham_multiagent_2009,
|
||||
title = {Multiagent {Systems}: {Algorithmic}, {Game}-{Theoretic}, and {Logical} {Foundations}},
|
||||
url = {http://www.masfoundations.org.},
|
||||
author = {Shoham, Yoav and Leyton-Brown, Kevin},
|
||||
year = {2009},
|
||||
keywords = {algorithms, auctions, communication, competition, cooperation, distributed problem solving, game theory, learning, logic, mechanism design, social choice},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/QZVYS7V9/shoham09a.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{xia_evaluation-driven_2025,
|
||||
title = {Evaluation-{Driven} {Development} and {Operations} of {LLM} {Agents}: {A} {Process} {Model} and {Reference} {Architecture}},
|
||||
url = {http://arxiv.org/abs/2411.13768},
|
||||
abstract = {Large Language Models (LLMs) have enabled the emergence of LLM agents, systems capable of pursuing under-specified goals and adapting after deployment. Evaluating such agents is challenging because their behavior is open ended, probabilistic, and shaped by system-level interactions over time. Traditional evaluation methods, built around fixed benchmarks and static test suites, fail to capture emergent behaviors or support continuous adaptation across the lifecycle. To ground a more systematic approach, we conduct a multivocal literature review (MLR) synthesizing academic and industrial evaluation practices. The findings directly inform two empirically derived artifacts: a process model and a reference architecture that embed evaluation as a continuous, governing function rather than a terminal checkpoint. Together they constitute the evaluation-driven development and operations (EDDOps) approach, which unifies offline (development-time) and online (runtime) evaluation within a closed feedback loop. By making evaluation evidence drive both runtime adaptation and governed redevelopment, EDDOps supports safer, more traceable evolution of LLM agents aligned with changing objectives, user needs, and governance constraints.},
|
||||
author = {Xia, Boming and Lu, Qinghua and Zhu, Liming and Xing, Zhenchang and Zhao, Dehai and Zhang, Hao},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
note = {arXiv: 2411.13768},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/H8IS64AW/2411.13768v2.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{xie_osworld_2024,
|
||||
title = {{OSWORLD}: {Benchmarking} {Multimodal} {Agents} for {Open}-{Ended} {Tasks} in {Real} {Computer} {Environments}},
|
||||
url = {https://os-world.github.io},
|
||||
abstract = {Autonomous agents that accomplish complex computer tasks with minimal human interventions have the potential to transform human-computer interaction, significantly enhancing accessibility and productivity. However, existing benchmarks either lack an interactive environment or are limited to environments specific to certain applications or domains, failing to reflect the diverse and complex nature of real-world computer use, thereby limiting the scope of tasks and agent scalability. To address this issue, we introduce OSWORLD, the first-of-its-kind scalable, real computer environment for multimodal agents, supporting task setup, execution-based evaluation, and interactive learning across various operating systems such as Ubuntu, Windows, and macOS. OSWORLD can serve as a unified, integrated computer environment for assessing open-ended computer tasks that involve arbitrary applications. Building upon OSWORLD, we create a benchmark of 369 computer tasks involving real web and desktop apps in open domains, OS file I/O, and workflows spanning multiple applications. Each task example is derived from real-world computer use cases and includes a detailed initial state setup configuration and a custom execution-based evaluation script for reliable, reproducible evaluation. Extensive evaluation of state-of-the-art LLM/VLM-based agents on OSWORLD reveals significant deficiencies in their ability to serve as computer assistants. While humans can accomplish over 72.36\% of the tasks, the best model achieves only 12.24\% success, primarily struggling with GUI grounding and operational knowledge. Comprehensive analysis using OSWORLD provides valuable insights for developing multimodal generalist agents that were not possible with previous benchmarks. Our code, environment, baseline models, and data are publicly available at https://os-world.github.io.},
|
||||
author = {Xie, Tianbao and Zhang, Danyang and Chen, Jixuan and Li, Xiaochuan and Zhao, Siheng and Cao, Ruisheng and Jing Hua, Toh and Cheng, Zhoujun and Shin, Dongchan and Lei, Fangyu and Liu, Yitao and Xu, Yiheng and Zhou, Shuyan and Savarese, Silvio and Xiong, Caiming and Zhong, Victor and Yu, Tao},
|
||||
month = may,
|
||||
year = {2024},
|
||||
note = {arXiv: 2404.07972v2},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/LLRKXIC7/full-text.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{imperva_rapid_2025,
|
||||
title = {The {Rapid} {Rise} of {Bots} and the {Unseen} {Risk} for {Business} \#{2025BADBOTREPORT}},
|
||||
author = {{Imperva}},
|
||||
year = {2025},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/AWR9IQRD/2025-Bad-Bot-Report.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{perez-ricardo_exploring_2025,
|
||||
title = {Exploring booking intentions through price elasticity of demand in tourism accommodations using large-scale data analytics},
|
||||
volume = {31},
|
||||
issn = {24448834},
|
||||
doi = {10.1016/j.iedeen.2025.100271},
|
||||
abstract = {The study aims to explore tourists' booking intentions by analyzing the price elasticity of demand in tourist accommodations. This analysis should reveal how changes in price affect booking behavior across different customer segments, using online booking records. A dataset was compiled from 106 hotels in Malaga, Spain, comprising 27,910 online bookings sourced exclusively from hotel websites. To understand the price elasticity of demand, a simple log-log regression was applied, segmenting the data based on key revenue-related variables. Subsequently, a cluster segmentation was performed using the Elbow method and K-means algorithm to identify distinct market segments. The findings highlighted that Family Travelers and Short Stay Travelers segments exhibited elastic demand, indicating higher sensitivity to price fluctuations. In contrast, Early Bookers and Mid-Season Long Stayers demonstrated inelastic demand, with lower responsiveness to changes in tourist accommodation prices. The number of variables analyzed in this study, along with the cluster analysis, represent a novelty and contribute to the existing literature on market segmentation and price elasticity of demand. This integration enriches both fields of research, offering mutual benefits and deeper insights that enhance the understanding of booking intention and pricing strategies.},
|
||||
number = {1},
|
||||
urldate = {2025-11-28},
|
||||
journal = {European Research on Management and Business Economics},
|
||||
author = {Pérez-Ricardo, Elizabeth del Carmen and García-Mestanza, Josefa},
|
||||
month = jan,
|
||||
year = {2025},
|
||||
note = {Publisher: European Academy of Management and Business Economics},
|
||||
keywords = {Booking intention, Price elasticity, Tourist segmentation},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/QNXZJLRM/S2444883425000038.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{ghaffary_amazon_2025,
|
||||
title = {Amazon {Sues} to {Stop} {Perplexity} {From} {Using} {AI} {Tool} to {Buy} {Stuff}},
|
||||
url = {https://www.bloomberg.com/news/articles/2025-11-04/amazon-demands-perplexity-stop-ai-agent-from-making-purchases},
|
||||
author = {Ghaffary, Shirin and Day, Matt},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/IQL6FPWE/Amazon Sues to Stop Perplexity From Using AI Tool to Buy Stuff - Bloomberg.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{besbes_dynamic_2007,
|
||||
title = {Dynamic {Pricing} {Without} {Knowing} the {Demand} {Function}: {Risk} {Bounds} and {Near}-{Optimal} {Algorithms} *},
|
||||
abstract = {We consider a single product revenue management problem where, given an initial inventory, the objective is to dynamically adjust prices over a finite sales horizon to maximize expected revenues. Realized demand is observed over time, but the underlying functional relationship between price and mean demand rate that governs these observations (otherwise known as the demand function or demand curve), is not known. We consider two instances of this problem: i.) a setting where the demand function is assumed to belong to a known parametric family with unknown parameter values; and ii.) a setting where the demand function is assumed to belong to a broad class of functions that need not admit any parametric representation. In each case we develop policies that learn the demand function "on the fly," and optimize prices based on that. The performance of these algorithms is measured in terms of the regret: the revenue loss relative to the maximal revenues that can be extracted when the demand function is known prior to the start of the selling season. We derive lower bounds on the regret that hold for any admissible pricing policy, and then show that our proposed algorithms achieve a regret that is "close" to this lower bound. The magnitude of the regret can be interpreted as the economic value of prior knowledge on the demand function; manifested as the revenue loss due to model uncertainty.},
|
||||
author = {Besbes, Omar and Zeevi, Assaf},
|
||||
month = dec,
|
||||
year = {2007},
|
||||
note = {Publication Title: Operations Research},
|
||||
keywords = {learning, asymptotic analysis, estimation, exploration-exploitation, pricing, Revenue management, value of information},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/SBAIB4V2/Dp_wo_demand_risk_ob_az_posted.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{markntel_advisors_global_2025,
|
||||
address = {Noida, Uttar Pradesh, India},
|
||||
title = {Global {AI} {Agent} {Market} {Research} {Report}: {Forecast} (2026–2032)},
|
||||
url = {https://www.marknteladvisors.com/research-library/ai-agent-market.html},
|
||||
urldate = {2025-12-12},
|
||||
institution = {MarkNtel Advisors},
|
||||
author = {{MarkNtel Advisors}},
|
||||
year = {2025},
|
||||
}
|
||||
|
||||
@article{amjad_censored_2017,
|
||||
title = {Censored {Demand} {Estimation} in {Retail}},
|
||||
volume = {1},
|
||||
url = {https://par.nsf.gov/servlets/purl/10066022},
|
||||
doi = {10.1145/3154489},
|
||||
abstract = {In this paper, the question of interest is estimating true demand of a product at a given store location and time period in the retail environment based on a single noisy and potentially censored observation. To address this question, we introduce a \%non-parametric framework to make inference from multiple time series. Somewhat surprisingly, we establish that the algorithm introduced for the purpose of "matrix completion" can be used to solve the relevant inference problem. Specifically, using the Universal Singular Value Thresholding (USVT) algorithm [7], we show that our estimator is consistent: the average mean squared error of the estimated average demand with respect to the true average demand goes to 0 as the number of store locations and time intervals increase to \${\textbackslash}infty\$. We establish naturally appealing properties of the resulting estimator both analytically as well as through a sequence of instructive simulations. Using a real dataset in retail (Walmart), we argue for the practical relevance of our approach.},
|
||||
number = {2},
|
||||
urldate = {2025-11-12},
|
||||
journal = {Proceedings of the ACM on Measurement and Analysis of Computing Systems},
|
||||
author = {Amjad, Muhammad J. and Shah, Devavrat},
|
||||
month = dec,
|
||||
year = {2017},
|
||||
note = {Publisher: Association for Computing Machinery (ACM)},
|
||||
pages = {1--28},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/5ZYADDT4/10066022.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{ganie_uncertainty_2025,
|
||||
title = {Uncertainty in {Authorship}: {Why} {Perfect} {AI} {Detection} {Is} {Mathematically} {Impossible}},
|
||||
shorttitle = {Uncertainty in {Authorship}},
|
||||
url = {http://arxiv.org/abs/2509.11915},
|
||||
doi = {10.48550/arXiv.2509.11915},
|
||||
abstract = {As large language models (LLMs) become more advanced, it is increasingly difficult to distinguish between human-written and AI-generated text. This paper draws a conceptual parallel between quantum uncertainty and the limits of authorship detection in natural language. We argue that there is a fundamental trade-off: the more confidently one tries to identify whether a text was written by a human or an AI, the more one risks disrupting the text's natural flow and authenticity. This mirrors the tension between precision and disturbance found in quantum systems. We explore how current detection methods--such as stylometry, watermarking, and neural classifiers--face inherent limitations. Enhancing detection accuracy often leads to changes in the AI's output, making other features less reliable. In effect, the very act of trying to detect AI authorship introduces uncertainty elsewhere in the text. Our analysis shows that when AI-generated text closely mimics human writing, perfect detection becomes not just technologically difficult but theoretically impossible. We address counterarguments and discuss the broader implications for authorship, ethics, and policy. Ultimately, we suggest that the challenge of AI-text detection is not just a matter of better tools--it reflects a deeper, unavoidable tension in the nature of language itself.},
|
||||
language = {en},
|
||||
urldate = {2026-01-05},
|
||||
publisher = {arXiv},
|
||||
author = {Ganie, Aadil Gani},
|
||||
month = sep,
|
||||
year = {2025},
|
||||
note = {arXiv:2509.11915 [cs]},
|
||||
keywords = {Computer Science - Computation and Language},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/3Z2XK4QC/Ganie - 2025 - Uncertainty in Authorship Why Perfect AI Detection Is Mathematically Impossible.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{shi_distributionally_2024,
|
||||
title = {Distributionally {Robust} {Model}-{Based} {Offline} {Reinforcement} {Learning} with {Near}-{Optimal} {Sample} {Complexity}},
|
||||
abstract = {This paper concerns the central issues of model robustness and sample efficiency in offline reinforcement learning (RL), which aims to learn to perform decision making from history data without active exploration. Due to uncertainties and variabilities of the environment, it is critical to learn a robust policy—with as few samples as possible—that performs well even when the deployed environment deviates from the nominal one used to collect the history dataset. We consider a distributionally robust formulation of offline RL, focusing on tabular robust Markov decision processes with an uncertainty set specified by the Kullback-Leibler divergence in both finite-horizon and infinite-horizon settings. To combat with sample scarcity, a model-based algorithm that combines distributionally robust value iteration with the principle of pessimism in the face of uncertainty is proposed, by penalizing the robust value estimates with a carefully designed data-driven penalty term. Under a mild and tailored assumption of the history dataset that measures distribution shift without requiring full coverage of the state-action space, we establish the finite-sample complexity of the proposed algorithms. We further develop an informationtheoretic lower bound, which suggests that learning RMDPs is at least as hard as the standard MDPs when the uncertainty level is sufficient small, and corroborates the tightness of our upper bound up to polynomial factors of the (effective) horizon length for a range of uncertainty levels. To the best our knowledge, this provides the first provably near-optimal robust offline RL algorithm that learns under model uncertainty and partial coverage.},
|
||||
language = {en},
|
||||
author = {Shi, Laixi and Chi, Yuejie},
|
||||
month = jun,
|
||||
year = {2024},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/K56G4EIP/Shi and Chi - Distributionally Robust Model-Based Offline Reinforcement Learning with Near-Optimal Sample Complexity.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@article{dutting_mechanism_2025,
|
||||
title = {Mechanism {Design} for {Large} {Language} {Models} ({Extended} {Abstract})},
|
||||
abstract = {We investigate auction mechanisms for AIgenerated content, focusing on applications like ad creative generation. In our model, agents’ preferences over stochastically generated content are encoded as large language models (LLMs). We propose an auction format that operates on a tokenby-token basis, and allows LLM agents to influence content creation through single dimensional bids. We formulate two desirable incentive properties and prove their equivalence to a monotonicity condition on output aggregation. This equivalence enables a second-price rule design, even absent explicit agent valuation functions. Our design is supported by demonstrations on a publicly available LLM.},
|
||||
language = {en},
|
||||
author = {Dütting, Paul and Mirrokni, Vahab and Leme, Renato Paes and Xu, Haifeng and Zuo, Song},
|
||||
year = {2025},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/2ABDEYDN/Dütting et al. - Mechanism Design for Large Language Models (Extended Abstract).pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{fcmi_machine_2025,
|
||||
title = {Machine {Speed} {Markets}: {AI} {Agent} {Market} {Strategy} \& {Growth}},
|
||||
shorttitle = {Machine {Speed} {Markets}},
|
||||
url = {https://www.360strategy.co.uk/post/machine-speed-markets-ai-agents},
|
||||
abstract = {Recent research by NBER economists suggests these AI agents in particular, could drive a "Coasean singularity," a point where transaction costs fall towards zero, radically reshaping how markets function. In essence, tasks like finding information, negotiating deals, and enforcing contracts which are traditionally costly frictions in commerce, may become nearly instantaneous and costless.},
|
||||
language = {en},
|
||||
urldate = {2026-01-20},
|
||||
journal = {360 Strategy},
|
||||
author = {FCMi, CMgr, Mark Evans MBA},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
file = {Snapshot:/home/velocitatem/Zotero/storage/Z22P9JJH/machine-speed-markets-ai-agents.html:text/html},
|
||||
}
|
||||
|
||||
@article{coase_nature_1937,
|
||||
title = {The {Nature} of the {Firm}},
|
||||
volume = {4},
|
||||
issn = {1468-0335},
|
||||
url = {https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1468-0335.1937.tb00002.x},
|
||||
doi = {10.1111/j.1468-0335.1937.tb00002.x},
|
||||
language = {en},
|
||||
number = {16},
|
||||
urldate = {2026-01-20},
|
||||
journal = {Economica},
|
||||
author = {Coase, R. H.},
|
||||
year = {1937},
|
||||
pages = {386--405},
|
||||
file = {Full Text PDF:/home/velocitatem/Zotero/storage/TABLLPEU/Coase - 1937 - The Nature of the Firm.pdf:application/pdf;Snapshot:/home/velocitatem/Zotero/storage/Q5RFW9LJ/j.1468-0335.1937.tb00002.html:text/html},
|
||||
}
|
||||
|
||||
@misc{fish_algorithmic_2025,
|
||||
title = {Algorithmic {Collusion} by {Large} {Language} {Models}},
|
||||
url = {http://arxiv.org/abs/2404.00806},
|
||||
doi = {10.48550/arXiv.2404.00806},
|
||||
abstract = {The rise of algorithmic pricing raises concerns of algorithmic collusion. We conduct experiments with algorithmic pricing agents based on Large Language Models (LLMs). We find that LLM-based pricing agents quickly and autonomously reach supracompetitive prices and profits in oligopoly settings and that variation in seemingly innocuous phrases in LLM instructions (“prompts”) may substantially influence the degree of supracompetitive pricing. Off-path analysis using novel techniques uncovers price-war concerns as contributing to these phenomena. Our results extend to auction settings. Our findings uncover unique challenges to any future regulation of LLM-based pricing agents, and AI-based pricing agents more broadly.},
|
||||
language = {en},
|
||||
urldate = {2026-01-20},
|
||||
publisher = {arXiv},
|
||||
author = {Fish, Sara and Gonczarowski, Yannai A. and Shorrer, Ran I.},
|
||||
month = sep,
|
||||
year = {2025},
|
||||
note = {arXiv:2404.00806 [econ]},
|
||||
keywords = {Computer Science - Computer Science and Game Theory, Computer Science - Artificial Intelligence, Economics - General Economics},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/QHWVISCZ/Fish et al. - 2025 - Algorithmic Collusion by Large Language Models.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{hardt_strategic_2015,
|
||||
title = {Strategic {Classification}},
|
||||
url = {http://arxiv.org/abs/1506.06980},
|
||||
doi = {10.48550/arXiv.1506.06980},
|
||||
abstract = {Machine learning relies on the assumption that unseen test instances of a classification problem follow the same distribution as observed training data. However, this principle can break down when machine learning is used to make important decisions about the welfare (employment, education, health) of strategic individuals. Knowing information about the classifier, such individuals may manipulate their attributes in order to obtain a better classification outcome. As a result of this behavior—often referred to as gaming—the performance of the classifier may deteriorate sharply. Indeed, gaming is a well-known obstacle for using machine learning methods in practice; in financial policy-making, the problem is widely known as Goodhart’s law. In this paper, we formalize the problem, and pursue algorithms for learning classifiers that are robust to gaming.},
|
||||
language = {en},
|
||||
urldate = {2026-01-20},
|
||||
publisher = {arXiv},
|
||||
author = {Hardt, Moritz and Megiddo, Nimrod and Papadimitriou, Christos and Wootters, Mary},
|
||||
month = nov,
|
||||
year = {2015},
|
||||
note = {arXiv:1506.06980 [cs]},
|
||||
keywords = {Computer Science - Machine Learning},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/HNCDYGWS/Hardt et al. - 2015 - Strategic Classification.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@misc{liu_contextual_2024,
|
||||
title = {Contextual {Dynamic} {Pricing} with {Strategic} {Buyers}},
|
||||
url = {http://arxiv.org/abs/2307.04055},
|
||||
doi = {10.48550/arXiv.2307.04055},
|
||||
abstract = {Personalized pricing, which involves tailoring prices based on individual characteristics, is commonly used by firms to implement a consumer-specific pricing policy. In this process, buyers can also strategically manipulate their feature data to obtain a lower price, incurring certain manipulation costs. Such strategic behavior can hinder firms from maximizing their profits. In this paper, we study the contextual dynamic pricing problem with strategic buyers. The seller does not observe the buyer's true feature, but a manipulated feature according to buyers' strategic behavior. In addition, the seller does not observe the buyers' valuation of the product, but only a binary response indicating whether a sale happens or not. Recognizing these challenges, we propose a strategic dynamic pricing policy that incorporates the buyers' strategic behavior into the online learning to maximize the seller's cumulative revenue. We first prove that existing non-strategic pricing policies that neglect the buyers' strategic behavior result in a linear \$Ω(T)\$ regret with \$T\$ the total time horizon, indicating that these policies are not better than a random pricing policy. We then establish that our proposed policy achieves a sublinear regret upper bound of \$O({\textbackslash}sqrt\{T\})\$. Importantly, our policy is not a mere amalgamation of existing dynamic pricing policies and strategic behavior handling algorithms. Our policy can also accommodate the scenario when the marginal cost of manipulation is unknown in advance. To account for it, we simultaneously estimate the valuation parameter and the cost parameter in the online pricing policy, which is shown to also achieve an \$O({\textbackslash}sqrt\{T\})\$ regret bound. Extensive experiments support our theoretical developments and demonstrate the superior performance of our policy compared to other pricing policies that are unaware of the strategic behaviors.},
|
||||
language = {en},
|
||||
urldate = {2026-01-20},
|
||||
publisher = {arXiv},
|
||||
author = {Liu, Pangpang and Yang, Zhuoran and Wang, Zhaoran and Sun, Will Wei},
|
||||
month = jun,
|
||||
year = {2024},
|
||||
note = {arXiv:2307.04055 [stat]},
|
||||
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Computer Science - Computer Science and Game Theory, Computer Science - Artificial Intelligence},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/MVJNULK3/Liu et al. - 2024 - Contextual Dynamic Pricing with Strategic Buyers.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@techreport{dhir_http_2025,
|
||||
type = {Internet {Draft}},
|
||||
title = {{HTTP} {Agent} {Profile} ({HAP}): {Authenticated} and {Monetized} {Agent} {Traffic} on the {Web}},
|
||||
shorttitle = {{HTTP} {Agent} {Profile} ({HAP})},
|
||||
url = {https://datatracker.ietf.org/doc/draft-dhir-http-agent-profile},
|
||||
abstract = {Autonomous agents such as LLM-powered crawlers, browser-integrated assistants, and task-oriented bots are rapidly becoming first-class HTTP clients on the Web. Today’s infrastructure largely assumes a human behind a browser and monetizes content through advertising and coarse subscriptions. Automated agents consume content at scale without rendering pages or viewing ads, exacerbating bot-mitigation arms races and economic misalignment between content providers and AI systems. This document describes an HTTP Agent Profile (HAP) that enables: (1) cryptographic authentication of agent traffic using HTTP Message Signatures; (2) clear separation between human and agent traffic using privacy-preserving human tokens; and (3) protocol-level value exchange for agents via HTTP status code 402 ("Payment Required") and pluggable micropayment mechanisms. The profile reuses existing HTTP features and is designed for incremental deployment via reverse proxies, CDNs, and agent libraries.},
|
||||
number = {draft-dhir-http-agent-profile-00},
|
||||
urldate = {2026-01-20},
|
||||
institution = {Internet Engineering Task Force},
|
||||
author = {Dhir, Sanat},
|
||||
month = nov,
|
||||
year = {2025},
|
||||
note = {Num Pages: 13},
|
||||
}
|
||||
|
||||
@misc{noauthor_amazoncom_2026,
|
||||
title = {Amazon.com {Services} {LLC} v. {Perplexity} {AI}, {Inc}},
|
||||
language = {en},
|
||||
month = jan,
|
||||
year = {2026},
|
||||
note = {No. 3:25-cv-09514-MMC},
|
||||
file = {PDF:/home/velocitatem/Zotero/storage/4JWZSTXJ/Posner - UNITED STATES DISTRICT COURT NORTHERN DISTRICT OF CALIFORNIA SAN FRANCISCO DIVISION.pdf:application/pdf},
|
||||
}
|
||||
|
||||
@@ -8,59 +8,9 @@
|
||||
|
||||
\section{Introduction}
|
||||
|
||||
In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove separability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
||||
|
||||
This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium.
|
||||
Research Objectives and Contribution: What are we making, why and who should care?
|
||||
|
||||
\subsection{Motivation and Market Context}
|
||||
|
||||
The current innovation boom in generative artificial intelligence and its applications to knowledge-based work tasks has brought many competing technologies for browser-use automation, with benchmarks and evaluations \parencite{xia_evaluation-driven_2025} motivating the development of capabilities focused on commercial research, understanding, and transaction execution \parencite{xie_osworld_2024}. The ``AI Agent'' market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030. This surge reflects adoption in e-commerce, customer service, and enterprise automation, where agents handle interactions previously done by humans, raising the question of how these systems should be designed for future robustness as well as how to maintain a competitive edge in the analytical components of e-commerce platforms \parencite{markntel_advisors_global_2025}.
|
||||
|
||||
The key stakeholders affected by the threat of increasing agent-driven traffic include online businesses and platform operators (especially in bot-heavy sectors like retail, travel, and financial services), their security, fraud, and engineering teams, end users whose accounts and data are exposed and whose experience degrades, regulators and legal stakeholders responding to breaches and fraud, and the attackers or bot operators driving the automation \parencite{imperva_rapid_2025}.
|
||||
|
||||
The industry has already seen legal action in cases like Amazon against Perplexity \parencite{ghaffary_amazon_2025}, stemming from the difficulty of identifying traffic from hybrid systems like the Commet browser. This paper explores such systems to better understand what the interaction data looks like and what it means for dynamic pricing and recommendation systems downstream. This observed impact indicates a need for prevention of secondary negative effects on the ``legacy'' systems which power modern revenue sources for many companies. Dynamic pricing algorithms rely on directly translating demand features $q$ to new price assignments $\hat{p}$ across a catalogue of products of size $N$. This opens opportunities to design a \textit{tabula rasa} of digital market mechanisms that will shape the future of commerce in the age of artificial intelligence.
|
||||
|
||||
Current market dynamics and trends of dynamic pricing and AI agents. Future projections of AI agents. Key stakeholders that are discussing this and reporting on it (Thales). Who is most affected
|
||||
\subsection{Solution Space Overview}
|
||||
Dynamic pricing systems, as presented by \textcite{mueller_low-rank_2019}, often deal with sparse low-rank data of demand signals which, combined with contamination from agents, creates complex interactions that impact pricing. To further complicate the problem, certain commercial settings such as the one presented by \textcite{amjad_censored_2017} must address the true demand of products under censored observations. This provides a formulation for handling demand in our case with multiple kinds of commercial mediators: $\hat{q} \gets q_A + q_H$ where $q_A$ represents the distribution of demand generated by agentic mediators and $q_H$ represents that of true human demand, these are two distinct populations with divergent objective functions.
|
||||
|
||||
We formally define interaction data as coming from some actor which can either be an agent ($A$) or human ($H$). For purposes of this research, an agent is an algorithmic loop with the ability to access a web platform and perform actions such as clicks, scrolls, and input field fills. The loop terminates when the internal large language model judges the provided task definition as complete. A detailed breakdown can be found in \cref{algagent-loop}.
|
||||
|
||||
\subsection{Research Questions}
|
||||
|
||||
This work addresses three core research questions:
|
||||
\begin{enumerate}
|
||||
\item[\textbf{RQ1}] \textit{Separability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting?
|
||||
\item[\textbf{RQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems?
|
||||
\item[\textbf{RQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination?
|
||||
\end{enumerate}
|
||||
|
||||
|
||||
\begin{algorithm}[t]
|
||||
\DontPrintSemicolon
|
||||
|
||||
\SetKwInOut{Input}{Input}
|
||||
\SetKwInOut{Output}{Output}
|
||||
|
||||
\Input{Goal $G$, Platform URL $u$, LLM $\mathcal{M}$}
|
||||
\Output{Task completion result $r$}
|
||||
|
||||
Initialize browser instance $\mathcal{B}$ with connection to $u$\;
|
||||
Construct prompt $\pi \gets \textsc{BuildPrompt}(G, u)$\;
|
||||
$\text{done} \gets \text{False}$\;
|
||||
|
||||
\While{$\neg \text{done}$}{
|
||||
Observe current page state $s_t$ from $\mathcal{B}$\;
|
||||
Query $\mathcal{M}$ with $(\pi, s_t)$ to determine next action $a_t \in \{\text{click}, \text{scroll}, \text{fill}, \text{navigate}\}$\;
|
||||
Execute $a_t$ on $\mathcal{B}$ to transition to state $s_{t+1}$\;
|
||||
$\text{done} \gets \mathcal{M}.\textsc{JudgeCompletion}(G, s_{t+1})$\;
|
||||
}
|
||||
|
||||
Extract final result $r$ from terminal state\;
|
||||
\Return{$r$}\;
|
||||
|
||||
\caption{AI Agent's Interaction Loop}
|
||||
\label{algagent-loop}
|
||||
\end{algorithm}
|
||||
|
||||
|
||||
The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary.
|
||||
Different approaches and perspectives, here also add a preview of what will be developed and explored in the lit review.
|
||||
|
||||
@@ -1,71 +1,17 @@
|
||||
\section{Literature Review}
|
||||
|
||||
To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups.
|
||||
\subsection{Foundational Concepts}
|
||||
|
||||
\subsection{Agent Taxonomy and Definitions}
|
||||
|
||||
An agent in the context of artificial intelligence is generally defined by anything that can reason and act upon observations of its environments (collected through some sensory inputs) and carry out actions through effectors. Moreover, a rational agent is an entity that is capable of perceiving the world around them and taking actions to advance specified goals. This definition by \textcite{russell_artificial_2021} is further developed in an economic context by \textcite{parkes_economic_2015}, suggesting AI research attempts to construct a synthetic \textit{homo economicus}, which may also be termed \textit{machina economicus}.
|
||||
A specific class or taxon of this \textit{machina economicus}, the Large Language Model (LLM) agent, is defined as an autonomous system capable of achieving goals and adapting post-training, often without needing explicit code or fundamental model changes \parencite{xia_evaluation-driven_2025}.
|
||||
|
||||
We must however acknowledge the current SOTA as presented by OSWORLD simulations by \textcite{xie_osworld_2024} have demonstrated that multi-modal tasks across desktop and web interaction modes, have a top-performing score of only 12.24\% success, whereas humans have a higher 72\% success rate; this is linked to the lack of grounding of these agents and their inability of handling unexpected errors. This weakness matters for this research because it clarifies the near-term threat model: practical exploitation does not require a fully competent ``computer assistant'', only enough automation to perform high-volume reconnaissance actions (search/filter/open product pages, probe availability/price boundaries) that can contaminate behavioral signals. With the expected growth of these capabilities, this threat only becomes more perilous to revenue management systems.
|
||||
|
||||
We model an agent session as producing some events with lower in-session conversion levels relative to humans, this we state in our assumption that $P(\text{purchase} \vert A) < P(\text{purchase} \vert H)$ but with a potentially higher volatility in $\hat{q}$, which we observe through the look-to-book metrics in our simulation.
|
||||
|
||||
\subsection{Economic Agents: From Homo Economicus to Machina Economicus}
|
||||
|
||||
Existing behavioral economic models tend to be criticized for the assumption of rational behavior, as is embodied in the term of homo economicus. The definition of a machina economicus by \textcite{parkes_economic_2015} is quite appropriate for our case, particularly because these assumptions of rationality have been argued to be a very adequate reference for AI research by \textcite{varian_economic_1995} due to its expected utility maximizing nature. For modeling this behavior, the trajectories of these agents can be formally defined to be partially observable Markov decision processes \parencite{xie_osworld_2024}. Agents are however not to be confused with web-bots which have previously been known as automated software applications or scrapers which are set with a purpose of carrying out specific tasks on the internet, without a higher level of internal judgement \parencite{imperva_rapid_2025}. In our research, we refer to this actor simply as an Agent belonging to the distribution $A$.
|
||||
|
||||
This economic framing also helps separate two related but distinct phenomena of agents as buyers (changing market demand composition), and agents as information gatherers (changing the observed interactions used by pricing/recommendation systems). The thesis focuses on the second, where information acquisition strategically precedes purchase execution. We do not however dismiss the proposed expectation that existing economic systems serving humans, will not be populated by AIs across multiple channels and with various possibly misaligned goals as stated by \textcite{parkes_economic_2015}.
|
||||
|
||||
A HAP (HTTP Agent Profile) protocol has been developed as an internet draft by \textcite{dhir_http_2025} in an effort to separate agentic and human internet traffic, however the majority adoption by both the sellers and agent providers would be required for the implementation of such a solution.
|
||||
What is the taxonomy and definition of an agent and an actor in this case, a bit more about interaction models in sessions and about dynamic pricing algorithms.
|
||||
|
||||
\subsection{Problem Evidence and Market Impact}
|
||||
Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries
|
||||
|
||||
The statistical issue of contamination in dynamic pricing systems that observe demand features as a means to update prices has been documented in various previous contexts. The airline industry (which has accounted for 24\% of observed disruptions) has seen malicious activity with a measureable impact on skewing key performance indicators by behavior visible in the look-to-book metrics. Excessive reconnaissance traffic inflates search volume without corresponding completed bookings, thereby skewing demand forecasts and disrupting dynamic pricing models. Demand proxies have also been observed to cause significant threat to inventory management by creating artificial scarcity that distorts the demand-supply relationships in the enterprise model. Censored demand as shown by \textcite{amjad_censored_2017} can also be observed in low-bias demand under-estimation caused by a distortion effect coming from non-human traffic data \parencite{imperva_rapid_2025}.
|
||||
|
||||
When dynamic pricing algorithms operate on highly contaminated or noisy data, the risk grows significantly in creating inaccurate price inferences. The emergent mitigation driven by un-informed reward and regret signals might lead to price suppression for sales continuity which results in harming margins and resulting in a revenue loss. System that poorly fit undesired behavior might result in price gouging, which calls for strong guardrails while preserving targeted business strategy \parencite{mullapudi_reinforcement_2025}.
|
||||
|
||||
|
||||
%Documented instances of agent-driven market disruptions - Quantitative evidence of pricing manipulation - Case studies from affected industries
|
||||
|
||||
\subsection{Theoretical Foundations: Economic Parallels}
|
||||
|
||||
|
||||
Early hints of exploration of prices in a standard English auction explored by \textcite{varian_economic_1995} which hints at exploration of prices in a sequential manner, which leads to a marginally different cost to the bidder than the reservation price of the seller. This is a setting in which there is no cost incured by the buyer for their actions or exploring prices in the market. They propose that any agent responsable for the pricing of a good must be imune to dynamic strategies which might extract private information from a market. A key take-away which relates to the Vickery auction mechanism (also called a \textit{direct mechanism}) suggests that not only would defenses against such exploitation be necessary, but the construction of a mechanism in which revelation of the true willingness to pay is the dominant strategy for commerce.
|
||||
|
||||
Like in classical revenue-maximizing auctions \parencite{roughgarden_cs364a_2013} we assume that the human actor in our system has a private valuation $v$ which we formally draw from intrinsically defined distributions. The important note here is that the agent proxy does not have a mechanism to convey this private information into the demand data which directly impacts the pricing systems.
|
||||
|
||||
The key component of this mediation between agents and commercial platforms lays in the transaction costs related to information gathering and negotiation. As proposed by \textcite{shahidi_coasean_2025} these costs are bound to collapse towards zero (which we demonstrate mathematically), calling for a re-evaluation of the boundaries between firms and markets. As argued by \textcite{coase_nature_1937}, the market participation and time associated with that participation, is critical part of the Coasean transaction cost logic which includes the discovery or relevant pricing within a given market. This process of price discovery without the presence of AI Agents can be time consuming and resource intensive. To build on top of this work we provide a proof of optimal conditions theorised by Coaes as an extension to AI-mediated markets.
|
||||
|
||||
% Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance
|
||||
|
||||
\subsection{Theoretical Foundations: Economic Prallels}
|
||||
|
||||
Economic foundations: relating the problem to options pricing theory. Cost of Information (COI) concept and its relevance
|
||||
|
||||
\subsection{Landscape of Existing Work}
|
||||
|
||||
Explorations of the algorithmic collusion by LLMs \parencite{fish_algorithmic_2025} has demonstrated a cross-model tendency of market division with a strong sensitivity to instructions provided in the ``system prompt''. If a dynamic pricing algorithm which is trained to respond to market signals learns to coordinate with competitor agents (or become manipulated by those agents), the market equilibrium is under threat of destabilization. This is particularly true for Q-learning pricing learners as demonstrated by \textcite{calvano_artificial_2018}.
|
||||
|
||||
Our effort to combat contamination stems from research by \textcite{hardt_strategic_2015} on strategic classification, in conjunction with \textcite{liu_contextual_2024} who demonstrate a linear regret if contamination is ignored. The strategic classification adversarial effect comes from an effort to manipulate some representative features used in a learning pipeline, which can result in lower prices on loans or lower prices from dynamic pricing algorithms.
|
||||
|
||||
To bridge the gap between detection and robust pricing, we look at work in Distributionally Robust Optimization (DRO). As defined by \textcite{kuhn_wasserstein_2024}, DRO provides a framework for decision-making under ambiguity, where the true data distribution is unknown but lies within a ``Wasserstein ball'' of a target distribution. In our context, the ``ambiguity set'' represents the uncertainty introduced by agentic reconnaissance. By optimizing for the worst-case distribution within this set, pricing mechanisms can become resilient to the distributional shifts such as the ones caused by non-human actors, effectively robustifying the revenue function against the contamination described in our problem statement.
|
||||
|
||||
In order to create an environment in which prices can be tested against a demand estimate generated by some behavioral model, we take inspiration from the architecture proposed by \textcite{ie_recsim_2019} in the RecSim platform built for recommendation systems. By modeling the distinct user behavior as POMDPs we can generate faithful interactions which allow us to generalize, past the constraint which is also present in recommendation systems, of rarely having enough experience with individual actor's interactions for good recommendations without generalization. The key inspiration comes from the user choice modeling which we translate to a user transition model for each distinct actor type (agent or human). We further consider the possibility of modeling our quantitative research platform using dynamic Bayesian networks for the sake of tractability within the system. The contribution or RecSim enables researchers to better understand learning algorithms in fixed environments, a gap we identify as needing to be bridged within the space of dynamic pricing.
|
||||
|
||||
We also acknowledge the difficulty in similarly affected fields such as authorship, where \textcite{ganie_uncertainty_2025} demonstrate the theoretical limits of the distributional divergence between text authored by a human or large language model. Their approach of computing the divergence between two distributions demonstrates purely theoretically that no classifier can outperform random guessing on their particular task. This is yet another factor to take into consideration when exploring the potential mitigation strategies.
|
||||
|
||||
The setting of our work is quite complex and covers a wide range of topics, each with its own set of issues that further complicate the task at hand. There is however promise in the field of reinforcement learning and adversarial robustness to combat these problems. We can summarize the characteristics learned from the review of our environment as:
|
||||
\begin{enumerate*}[label=(\roman*)]
|
||||
\item non-stationary demand with temporal noise $\epsilon_t$
|
||||
\item contaminated behavioral signals from mixed human-agent traffic with unknown mixing ratio $\alpha$
|
||||
\item partial observability where only demand proxies $\hat{q}$ are available, not true demand $d(\cdot)$
|
||||
\item strategic actors capable of feature manipulation to influence pricing outcomes
|
||||
\item information asymmetry with private valuations $v$ drawn from unknown distributions
|
||||
\item session-based interactions modeled as POMDPs with trajectories $\tau_s$
|
||||
\item low conversion probability for agents: $P(\text{purchase} \mid A) < P(\text{purchase} \mid H)$
|
||||
\item distributional uncertainty requiring robust optimization within Wasserstein ambiguity sets
|
||||
\item potential for adversarial exploitation through false-name bidding and identity whitewashing.
|
||||
\end{enumerate*}
|
||||
|
||||
|
||||
%Previous efforts in adversarial computer use .LLM agents, show how multi-faceted the whole problem is
|
||||
%Here we can show a market visualization (venn-like-diagram)
|
||||
Previous efforts in adversarial computer use LLM agents, show how multi-faceted the whole problem is
|
||||
Here we can show a market visualization (venn-like-diagram)
|
||||
|
||||
@@ -1,365 +1,68 @@
|
||||
\section{Methodology}
|
||||
|
||||
This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven separability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets.
|
||||
|
||||
\subsection{Problem Formalization}
|
||||
|
||||
We define a commercial environment where the platform interacts with a stream of sessions. Let $\mathcal{S}$ denote the set of all sessions. Each session $s \in \mathcal{S}$ is generated by an actor belonging to a latent class $Y_s \in \{H, A\}$, where $H$ denotes Human and $A$ denotes Agent.
|
||||
Mathematical formalization of agent-induced pricing distortions. Formal definition of potential loss mechanisms $\alpha D$
|
||||
|
||||
Each session produces a trajectory of observable events $\tau_s = (e_{s,1}, \ldots, e_{s,L_s})$. An event $e_{s,k}$ is a tuple defined as:
|
||||
\begin{equation}
|
||||
e_{s,k} = (a_{s,k}, i_{s,k}, t_{s,k})
|
||||
\end{equation}
|
||||
where:
|
||||
\begin{itemize}
|
||||
\item $a_{s,k} \in \mathcal{A}$ is the action taken (e.g., \texttt{view\_item}, \texttt{add\_to\_cart}).
|
||||
\item $i_{s,k} \in \{1, \ldots, N\}$ is the target item index.
|
||||
\item $t_{s,k} \in \mathbb{R}_+$ is the continuous timestamp.
|
||||
\end{itemize}
|
||||
We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research.
|
||||
|
||||
The platform does not directly observe the true underlying demand function $d(p)$. Instead, it observes a behavioral proxy $\hat{q}_t$, which is a composite signal derived from the mixture of actor types. We define the demand proxy for product $i$ at epoch $t$ as a weighted aggregation of events:
|
||||
\begin{equation}
|
||||
\label{eq:qhat}
|
||||
\hat{q}_{t,i} = \sum_{s \in \mathcal{S}_t} \sum_{k=1}^{L_s} \omega(a_{s,k}) \cdot \mathbb{1}[i_{s,k} = i]
|
||||
\end{equation}
|
||||
where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay.
|
||||
|
||||
\subsubsection{Actor Types and Demand Curves}
|
||||
We formalize the heterogeneity of actors by introducing a type space $\Theta$. An actor of class $Y_s$ is further parameterized by a type $\theta \sim \mathcal{D}_{Y}$. This type determines the actor's demand response function $d(p; \theta)$, sampled from a distribution of possible demand curves. The total observed demand is a stochastic process governed by the naively defined mixture:
|
||||
\begin{equation}
|
||||
\label{eq:mixture_demand}
|
||||
Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t
|
||||
\end{equation}
|
||||
where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise.
|
||||
We gether interaction data from users interacting with a sample platform simulating a hotel/airline which generates interaction distributions $I_t = \{(p_t, q_t^\text{obs}, \pi_t)\}_{t=1}^T$
|
||||
|
||||
|
||||
\subsection{Cost of Information Framework}
|
||||
|
||||
\subsection{Cost of Information (COI) Framework}
|
||||
|
||||
The \textit{Cost of Information} (COI) represents the markup a pricing policy $\pi$ attempts to extract from the market by leveraging demand signals. We define COI as the expected premium over the minimum viable price $\underline{p}$ (or marginal cost). This also speaks to the financial urgency as a consequence of information asymmetry between the platform and the actors.
|
||||
|
||||
\begin{definition}[Cost of Information]
|
||||
Let $\pi(\tau)$ be a pricing policy mapping interaction histories to prices. The COI is defined as:
|
||||
\begin{align}
|
||||
\text{COI} &= \mathbb{E}[P] - \underline{p} \\
|
||||
&= \int_{\underline{p}}^{\bar{p}} (1 - F_\pi(p)) \, dp
|
||||
\end{align}
|
||||
where $F_\pi(p)$ is the cumulative distribution function of prices generated by $\pi$ under standard operating conditions.
|
||||
\end{definition}
|
||||
Mathematical demonstration and validation of the COI and citation backed evidence, and framework overview + show harm to user via other cost distortions. Maybe split into 3.2.1 (COI Theory) and 3.2.2 (Framework Design)
|
||||
|
||||
\subsection{System Architecture}
|
||||
\begin{figure}[ht]
|
||||
\centering
|
||||
\begin{tikzpicture}[scale=1.2]
|
||||
% Define the Gaussian function: centered at 2
|
||||
\def\bellcurve(#1){1.5 * exp(-0.5*((#1-2)/0.6)^2)}
|
||||
|
||||
% Draw the main axis
|
||||
\draw[->, thick] (0, 0) -- (4.5, 0) node[right] {$p$};
|
||||
\draw[->, thick] (0, 0) -- (0, 2) node[above] {Density};
|
||||
|
||||
\draw[thick, smooth, samples=100] plot[domain=0:4] (\x, {\bellcurve(\x)});
|
||||
\node at (3.2, 1.2) {$f_\pi(p)$};
|
||||
|
||||
% Define p_min and E[p]
|
||||
\def\pmin{0.8}
|
||||
\def\mean{2}
|
||||
|
||||
% Vertical lines
|
||||
\draw[dashed] (\pmin, 0) -- (\pmin, 2.0);
|
||||
\draw[dashed] (\mean, 0) -- (\mean, 2.0);
|
||||
|
||||
% Labels on axis
|
||||
\node[below] at (\pmin, 0) {$\underline{p}$};
|
||||
\node[below] at (\mean, 0) {$\mathbb{E}[p]$};
|
||||
|
||||
\draw[<->, thick, red] (\pmin, 2.0) -- (\mean, 2.0) node[midway, above] {COI};
|
||||
|
||||
\end{tikzpicture}
|
||||
\caption{Illustration of the Cost of Information (COI). The COI is defined as the difference between the expected price $\mathbb{E}[p]$ realized by the policy and the minimum viable price $\underline{p}$.}
|
||||
\label{fig:coi_illustration}
|
||||
\end{figure}
|
||||
|
||||
We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes.
|
||||
|
||||
\begin{theorem}[COI Erosion in the Limit]
|
||||
Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0.
|
||||
\end{theorem}
|
||||
|
||||
\begin{proof}
|
||||
Let $p_1, \ldots, p_N$ be independent and identically distributed (i.i.d.) price samples drawn from the policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. The realizable price for an optimal searching agent is the first order statistic $p_{(1)} = \min(p_1, \ldots, p_N)$.
|
||||
|
||||
The survival function (or reliability function) of the minimum price is given by:
|
||||
\begin{equation}
|
||||
S_{p_{(1)}}(t) = P(p_{(1)} > t) = [1 - F(t)]^N
|
||||
\end{equation}
|
||||
|
||||
To determine the expected value $\mathbb{E}[p_{(1)}]$, we recall the property that for any continuous random variable $X$ with support $[A, B]$, the expectation can be expressed as the lower bound plus the integral of the survival function:
|
||||
\begin{equation}
|
||||
\mathbb{E}[X] = A + \int_{A}^{B} P(X > t) \, dt
|
||||
\end{equation}
|
||||
|
||||
Applying this to our pricing statistic where the lower bound is $\underline{p}$:
|
||||
\begin{align}
|
||||
\mathbb{E}[p_{(1)}] &= \underline{p} + \int_{\underline{p}}^{\bar{p}} P(p_{(1)} > t) \, dt \\
|
||||
&= \underline{p} + \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt
|
||||
\end{align}
|
||||
|
||||
Since $F(t)$ is a valid CDF, for any $t > \underline{p}$, we have strict inequality $F(t) > 0$, implying $0 \le 1 - F(t) < 1$. By the properties of limits, as $N \to \infty$, the term $[1 - F(t)]^N$ converges to 0 pointwise for all $t > \underline{p}$.
|
||||
|
||||
Applying the Lebesgue Dominated Convergence Theorem (noting that the integrand is bounded by 1 on the finite interval $[\underline{p}, \bar{p}]$):
|
||||
\begin{equation}
|
||||
\lim_{N \to \infty} \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt = \int_{\underline{p}}^{\bar{p}} 0 \, dt = 0
|
||||
\end{equation}
|
||||
|
||||
Substituting this back into the expression for COI:
|
||||
\begin{align}
|
||||
\lim_{N \to \infty} \text{COI} &= \lim_{N \to \infty} (\mathbb{E}[p_{(1)}] - \underline{p}) \\
|
||||
&= \lim_{N \to \infty} \left( (\underline{p} + 0) - \underline{p} \right) \\
|
||||
&= 0
|
||||
\end{align}
|
||||
\end{proof}
|
||||
|
||||
|
||||
This result proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a robust counter-mechanism.
|
||||
|
||||
% The DRO objective creates a lower bound on COI extraction, effectively guaranteeing a minimum margin even in the presence of adversarial agents. we need to prove this and demonstrate that in a theorem.
|
||||
|
||||
|
||||
%Mathematical demonstration and validation of the COI and citation backed evidence, and framework overview + show harm to user via other cost distortions. Maybe split into 3.2.1 (COI Theory) and 3.2.2 (Framework Design)
|
||||
|
||||
\subsection{System Architecture: Hybrid Kappa-Lambda Architecture}
|
||||
|
||||
In order for our research to have grounding in interactions we built a robust e-commerce web-platform. We initially conducted a survey of the leading platforms of airlines and hotel booking sites to identify the specific interface patterns that effectively manage complex travel data. Our analysis revealed a clear industry standard: while both sectors rely on tabbed service selection and left-sidebar filtering to streamline navigation, they diverge in result presentation: airlines utilize visual date-price bars and multi-step wizards to optimize for logistical transparency, whereas hotel platforms leverage image-led cards and scarcity triggers to drive emotional engagement and urgency. Our web framework defines a highly agnostic boilerplate which can be seeded with any data-modality with an easy-to-tailor pattern, which we leverage to define a \texttt{hotel} and \texttt{airline} mode. Both modes are then individually deployed via an environment level argument which adjusts the proxy routing with a custom middleware inside next.js to render only the desired mode. The purpose of this was to create a baseline adaptable to any use-case or desired commercial application.
|
||||
|
||||
|
||||
The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise.
|
||||
|
||||
|
||||
\subsubsection{DevOps Principles}
|
||||
|
||||
\subsubsection{Online Dynamic Pricing}
|
||||
|
||||
The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$. The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$):
|
||||
|
||||
\begin{equation}
|
||||
\hat{p}_i = \begin{cases}
|
||||
p_{0,i} \cdot \lambda_{\text{surge}} & \text{if } \hat{q}_i \geq \theta_{\text{high}} \\
|
||||
p_{0,i} \cdot \lambda_{\text{disc}} & \text{if } \hat{q}_i \leq \theta_{\text{low}} \\
|
||||
p_{0,i} & \text{otherwise}
|
||||
\end{cases}
|
||||
\quad \forall i \in \{1, \ldots, N\}
|
||||
\end{equation}
|
||||
|
||||
where $p_0 \in \mathbb{R}^N$ is the base price vector (which is seeded into our database distinctly for each mode of the commerce platform), $\theta_{\text{high}}, \theta_{\text{low}} \in \mathbb{R}$ are demand thresholds defining surge and discount regions, and $\lambda_{\text{surge}}, \lambda_{\text{disc}} \in \mathbb{R}^+$ are multiplicative factors with typical values $\lambda_{\text{surge}} = 1.2$ and $\lambda_{\text{disc}} = 0.9$. This piecewise function enables rapid price adjustment in response to observed demand without requiring complex elasticity estimation or historical calibration, allowing us to expose actors within our experiments to a system with a dynamic component of pricing.
|
||||
|
||||
We will for our offilne experimental intents generalize a master function for encompasing distinct demand estimation and pricing strategies.
|
||||
|
||||
\begin{align}
|
||||
V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]}
|
||||
\end{align}
|
||||
|
||||
We follow differnet substitutouns which will server as hyperparameters later on.
|
||||
|
||||
\subsection{Experimental Design}
|
||||
|
||||
The experimentation begins with the design of goals, with careful consideration to assure a uniform spanning across different variables within each product-architecture of either the hotel or airline platforms. Our crafted collection of goals (jobs to be done) is then tracked in a postgress database with one table to track goals and another table to track different experiment runs, and their associated goals in a experiment-goal one-to-one relationship.
|
||||
|
||||
The purpose of this effort to gather data on interactions, is the first half of our research. With this collected data on behavioral characteristics, enhanced by our feature augmentation, we can create distribution separation into two bins $y \in \{A,H\}$ with a certain probability $p$ dependent on the session-specific features. To address the second loop of our system, we use this gained capability of discrimination to enhance the learner design involved in our surrogate dynamic pricing task which simulates an independent dynamic pricing scenario under which we can train a more controlled policy with the ability to account for true demand signals under conditions of contamination from non-human actors.
|
||||
|
||||
Our approach can be well summarized by a three-stage division, first we intend to observe and \textit{vectorize} the behavioral interaction data from our experiments, we then develop the separability which helps us deepen the semantic understanding of the behavioral patterns. Finally we use our newly gained learner to leverage a defensive mechanism within the simulation stage of a controlled dynamic pricing loop.
|
||||
|
||||
\begin{figure}[ht]
|
||||
\resizebox{\columnwidth}{!}{%
|
||||
\input{chapters/loop_figure.tex}
|
||||
}
|
||||
\caption{Overview of the Dynamic Pricing Tasks.}
|
||||
\end{figure}
|
||||
|
||||
Our web platform (developed in similar patterns as the RecSim by \textcite{ie_recsim_2019}) allows us to setup a controled environment in which we assign tasks to human and agentic actors which are then carried out. Each actor gets a browser assigned experiment identification which is persistent across possibly multiple session identifiers. We then group by experiments and extract all the session interactions (trajectories) which follow the schema formalized below.
|
||||
|
||||
\subsubsection{Interaction Schema}
|
||||
|
||||
We extend the basic event tuple $e_{s,k}$ to capture the full observational signal available to the platform. An interaction event is defined as the extended tuple:
|
||||
\begin{equation}
|
||||
e_{s,k} = \left( a_{s,k}, \, i_{s,k}, \, t_{s,k}, \, \mu_{s,k}, \, \delta_{s,k} \right)
|
||||
\end{equation}
|
||||
where $\mu_{s,k} \in \mathcal{M}$ is a metadata record containing action-specific context (e.g., price observed, filter parameters, element text), and $\delta_{s,k} \in \mathbb{R}_+$ is the dwell time in milliseconds for attention-based actions.
|
||||
|
||||
A session $s$ is itself a structured record:
|
||||
\begin{equation}
|
||||
s = \left( \text{sid}, \, \text{eid}, \, t_0, \, \phi, \, \mathcal{U}, \, \tau_s \right)
|
||||
\end{equation}
|
||||
where $\text{sid}$ is a unique session identifier (UUID), $\text{eid}$ optionally links to an experiment, $t_0$ is the session start timestamp, $\phi \in \{\texttt{hotel}, \texttt{airline}\}$ denotes the platform mode, $\mathcal{U}$ is the user-agent string, and $\tau_s$ is the trajectory of events.
|
||||
|
||||
The action space $\mathcal{A}$ is partitioned into four semantic categories based on the behavioral signal each action conveys:
|
||||
|
||||
\begin{table}[ht]
|
||||
\centering
|
||||
\caption{Action space partition $\mathcal{A} = \mathcal{A}_{\text{nav}} \cup \mathcal{A}_{\text{cart}} \cup \mathcal{A}_{\text{filter}} \cup \mathcal{A}_{\text{dwell}}$ with signal interpretation.}
|
||||
\label{tab:action_space}
|
||||
\begin{tabular}{@{}llll@{}}
|
||||
\toprule
|
||||
\textbf{Category} & \textbf{Actions} & \textbf{Signal} & $\boldsymbol{\omega}$ \\
|
||||
\midrule
|
||||
$\mathcal{A}_{\text{cart}}$ & \texttt{add\_item}, \texttt{remove}, \texttt{checkout}, \texttt{purchase} & Purchase intent & High \\
|
||||
$\mathcal{A}_{\text{dwell}}$ & \texttt{hover\_title}, \texttt{hover\_paragraph}, \texttt{hover\_link} & Sustained attention & Medium \\
|
||||
$\mathcal{A}_{\text{nav}}$ & \texttt{page\_view}, \texttt{view\_item}, \texttt{learn\_more} & Discovery & Low \\
|
||||
$\mathcal{A}_{\text{filter}}$ & \texttt{search}, \texttt{filter\_date}, \texttt{filter\_price}, \texttt{sort} & Preference refinement & Lowest \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\end{table}
|
||||
\begin{tikzpicture}[
|
||||
node distance=1.5cm and 2.5cm,
|
||||
box/.style={rectangle, draw, thick, minimum height=1cm, minimum width=3cm, align=center, fill=blue!10},
|
||||
kafka/.style={rectangle, draw=orange, thick, minimum height=1cm, minimum width=3cm, align=center, fill=orange!15},
|
||||
arrow/.style={thick,->,>=Stealth}
|
||||
]
|
||||
|
||||
This partition enables the weight function $\omega$ from Eq.~\ref{eq:qhat} to assign category-specific signal strengths, with $\omega(\mathcal{A}_{\text{cart}}) > \omega(\mathcal{A}_{\text{dwell}}) > \omega(\mathcal{A}_{\text{nav}}) > \omega(\mathcal{A}_{\text{filter}})$ reflecting decreasing commitment.
|
||||
% Nodes
|
||||
\node[box] (webapp) {Web Application \\ (Producer \& Consumer)};
|
||||
\node[kafka, below=of webapp] (kafka) {Apache Kafka \\ Cluster};
|
||||
\node[box, below=of kafka] (backend) {Backend Services / Microservices \\ (Producers and Consumers)};
|
||||
|
||||
The metadata record $\mu$ varies by action type. For product views, $\mu$ contains the observed price $p_{\text{obs}}$ and product attributes. For dwell events, $\mu$ includes the element text and accumulated hover duration. This heterogeneous structure is captured via a schema-on-read approach in our Kafka ingestion pipeline, where events are validated against type-specific schemas before storage.
|
||||
% Connections
|
||||
\draw[arrow] (webapp) to[out=210,in=150] node[above]{Publish} (kafka);
|
||||
\draw[arrow] (kafka) to[out=50,in=330] node[below]{Consume} (webapp);
|
||||
\draw[arrow] (backend) -- node[above]{Publish/Consume} (kafka);
|
||||
|
||||
In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response.
|
||||
% Optional: Kafka internal components
|
||||
%\node[below=0.7cm of kafka, align=center] (topics) {Topics \\ Partitions};
|
||||
|
||||
|
||||
\subsection{Generative Contamination and Separability}
|
||||
|
||||
To develop a robust pricing learner, we require a simulation environment capable of generating realistic, contaminated interaction data. We achieve this by learning from our Phantom platform data using a two-stage approach.
|
||||
|
||||
|
||||
|
||||
\subsubsection{GOFAI-Based Separability}
|
||||
We employ Good Old-Fashioned AI (GOFAI) heuristics to generate initial weak labels for separability. We define a set of rule-based predicates $\phi_j: \tau \to \{0, 1\}$ to partition the dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We construct distinct MDPs per each behavioral profile of humans and agents and from those we establish $D_{KL}$. From initial findings we compute a KL divergence of $\approx 2.0236$ across transition probabilities between states which can be seen in \ref{fig:human_mdp_viz} and \ref{fig:agent_mdp_viz}.
|
||||
|
||||
\begin{definition}[Kullback-Leibler Divergence for Transition Distributions]
|
||||
Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is:
|
||||
\begin{equation}
|
||||
D_{\mathrm{KL}}(P_e \parallel Q_e) = \sum_{k \in \mathcal{S}_e} P_e(k) \log \frac{P_e(k)}{Q_e(k)}
|
||||
\end{equation}
|
||||
where $\mathcal{S}_e$ denotes the set of destination events that follow $e$ in the human trajectories.
|
||||
\end{definition}
|
||||
|
||||
To obtain this statistic we aggregate state transitions by their triggering event $e$ and treat the normalized outgoing probabilities as the categorical distributions $P_e$ (human) and $Q_e$ (agent). The computation intersects the event labels observed in both datasets, then iterates over each label and accumulates the log-ratio score. In practice this is implemented exactly as in models: for each destination $k$ we multiply the human probability by the log of the probability ratio and add the result to the running sum. Large contributions (including the case where $Q_e(k)$ is near zero) point to intents, such as rapid checkout or repeated navigation, that the agent policy fails to reproduce and therefore drive the contamination analysis.
|
||||
|
||||
With this divergence we train a contrastive learning method to estimate a weak probability of a given trajectory being an agent $f(\cdot) \to [0,1]$ which we can use as a leverage for a weighted sum. This is a first attempt at a more informed separability.
|
||||
|
||||
|
||||
\subsubsection{Transition Probability Estimation}
|
||||
\label{sec:tpe}
|
||||
|
||||
|
||||
For both subsets, we model the session dynamics as a Markov Decision Process (MDP) and estimate the transition kernel $\mathcal{T}$. for each respective actor type we define $\hat{\mathcal{T}}_A$ and $\hat{\mathcal{T}}_H$ which are the general transition kernels subject to clustering into $\hat{\mathcal{T}}_y^i$ where $\forall i \in \text{behavioral clusters of } \hat{\mathcal{T}}_y$. This is done to avoid a lumping of all actor behavior and allows for more intral-class penalization. The probability of transitioning to state $s'$ given state $s$ is estimated via maximum likelihood:
|
||||
\begin{equation}
|
||||
\hat{P}(s' \mid s) = \frac{N(s, s')}{\sum_{k \in \mathcal{S}} N(s, k)}
|
||||
\end{equation}
|
||||
where $N(s, s')$ is the count of observed transitions. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. In addition, given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from the learned transition matrix $\hat{P}_A$ until the effective mixing ratio reaches $\alpha$. From these transition probabilities we can observe an important feature which contributes to a differentiating assumption, which is that the mouse-behavior of an agent is almost non existent and therefore not utilized as a distinguishing factor both in the prior separability nor in any feature engineering.
|
||||
|
||||
\begin{figure}[ht]
|
||||
\centering
|
||||
\includegraphics[width=0.8\textwidth]{chapters/mdp_human.pdf}
|
||||
\caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for human actions.}
|
||||
\label{fig:human_mdp_viz}
|
||||
% Optional background
|
||||
\begin{scope}[on background layer]
|
||||
\node[draw, rounded corners, fill=orange!5, fit=(kafka), inner sep=0.3cm] {};
|
||||
\end{scope}
|
||||
\end{tikzpicture}
|
||||
\caption{Technical Diagram}
|
||||
\end{figure}
|
||||
|
||||
\begin{figure}[ht]
|
||||
\centering
|
||||
\includegraphics[width=0.8\textwidth]{chapters/mdp_agent.pdf}
|
||||
\caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for \textbf{agent} behavior profiles. The state space and transition probabilities are learned from observed session trajectories to enable generative contamination.}
|
||||
\label{fig:agent_mdp_viz}
|
||||
\end{figure}
|
||||
High level overview of how it works
|
||||
\subsection{Experimental Design}
|
||||
Study methodology and approach. Data acquisition strategy. Defined objectives and success criteria. Observable metrics and KPIs
|
||||
|
||||
\subsection{Dynamic Pricing Algorithm Analysis}
|
||||
Deep dive into how the algorithm works, different kinds and justification for chosen appraoches + agent impact modeling and quantification.
|
||||
\subsection{Reinforcement Learning Formulation}
|
||||
How do we define the state space, action space and reward function breakdown and algorithm benchmarking.
|
||||
POSSIBLY: Expand into full subsections: 3.6.1 (State-Action Space), 3.6.2 (Reward Design), 3.6.3 (Benchmarking)
|
||||
|
||||
\subsection{Stronger Classification}
|
||||
We re-map the current event schema semantically to the event schema of another dataset. Our contaminated dataset is then used in another classifier where we can now also apply better feature engineering on other features while assigning correct lables to the entire dataset so the new dataset can be contaminated with $\mathcal{G}$ under some different contamination ratio $\alpha$.
|
||||
|
||||
This new classified can then be used in the reinforcement learning reward structure.
|
||||
|
||||
|
||||
\subsection{Distributionally Robust Reinforcement Learning (DR-RL)}
|
||||
|
||||
We formulate the pricing problem as a Stackelberg Game where the Platform (Leader) sets prices $p_t$ and the Aggregate Demand (Follower) responds. However, the exact mixing parameter $\alpha$ and the demand distribution shift are non-stationary and unknown in online settings. Relying on a simple error term $\epsilon$ is insufficient. Instead, we adopt a Distributionally Robust Optimization (DRO) objective. To formulate the entire dependency chain from the trajctory $\tau^\prime$ which is a newly observed trajectory observed by the platform and generated by an unknown actor type (sampled over a behavioral profile defined in section \ref{sec:tpe}). As part of the dynamic pricing we need a mapping of demand parameterized by a trajectory and a price $\hat{Q}(p, \tau^\prime)$. For an observed trajectory we compute a new $\hat{\mathcal{T}}^\prime$ and using a baseline controlled observations of both $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$ we can compute during inference time the following:
|
||||
|
||||
\begin{align}
|
||||
\label{eq:delta_H}
|
||||
\Delta_H &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_H) \\
|
||||
\label{eq:delta_A}
|
||||
\Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A)
|
||||
\end{align}
|
||||
|
||||
This creates two centroid-like heuristics which can on a per-session granularity basis guide our mixing paramtere $\alpha$.
|
||||
|
||||
\subsubsection{Ambiguity Set Construction}
|
||||
We define an ambiguity set $\mathcal{U}_p(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face:
|
||||
\begin{equation}
|
||||
\mathcal{U}_\epsilon(\hat{P}_N) = \left\{ Q \in \mathcal{P}(\Xi) : W_p(Q, \hat{P}_N) \le \epsilon \right\}
|
||||
\end{equation}
|
||||
This set captures all distributions that are statistically close to our observed training data but allows for adversarial shifts.
|
||||
|
||||
\subsubsection{The Min-Max Objective}
|
||||
The robust policy $\pi^*$ is obtained by solving the maximin problem:
|
||||
\begin{equation}
|
||||
\label{eq:robust_policy}
|
||||
\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}(p) \right]
|
||||
\end{equation}
|
||||
where $R(p, d)$ is the revenue function and $\lambda$ weighs the penalty for information leakage (COI). We previously defined $\text{COI}$, however to properly connect this concept into the reward structure we need to define a parametrized version which informs us of the leakage of said structure with $\text{COI}(p)$.
|
||||
|
||||
Another proposed formulation of the optimal policy would be to adjust the ambiguity set dyanmically over the live computed divergence where $\epsilon(\Delta_H)$ to adjust the ball around or estimator according to each behavioral signal emited through a given trajctory. We state this as a possibility but do not peruse it due to literature suggesting that wesserstine methods do not require absolute continuity and are better with ``black swans'' \parencite{kuhn_wasserstein_2024}.
|
||||
|
||||
\subsubsection{Actor Implementation}
|
||||
In our simulation, the "Follower" is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$.
|
||||
|
||||
|
||||
As part of our reward engineering we think about the UX factor ($UX \in [0,1]$) whic his our proxy for user experience degradation, this is computed as a mixture of contribution from the separability model metric of $\frac{1}{\text{Specificity}}$.
|
||||
|
||||
\begin{figure}[ht]
|
||||
\centering
|
||||
\resizebox{0.5\columnwidth}{!}{%
|
||||
\input{chapters/balance_figure.tex}
|
||||
}
|
||||
\caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-like scale.}
|
||||
\end{figure}
|
||||
|
||||
We also need to think about a policy like taxation to the agents Strategy-Proof Mechanism Design, specifically the Vickrey-Clarke-Groves (VCG) payment rule. We link and prove that this would create an incentive for the dominant strategy to become truth-telling.
|
||||
|
||||
\subsubsection{Pricing Mechanism Summary}
|
||||
|
||||
We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_pricing_loop} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories.
|
||||
|
||||
\begin{algorithm}[t]
|
||||
\caption{PHANTOM defensive pricing loop (bachelor-thesis level)}
|
||||
\label{alg:phantom_loop_clean}
|
||||
\DontPrintSemicolon
|
||||
\SetKwInOut{Input}{Input}\SetKwInOut{Output}{Output}
|
||||
|
||||
\Input{catalog size \(N\); costs \(c\); reference prices \(p^{ref}\); behavior models \(\bar T_H,\bar T_A\);
|
||||
action weights \(\omega\); penalty \(\lambda\); horizon \(T\); sessions per step \(M\)}
|
||||
\Output{price/demand trajectory \(\{(p_t,\hat Q_t,\hat\alpha_t)\}_{t=0}^{T-1}\)}
|
||||
|
||||
Initialize contamination estimate \(\hat\alpha \leftarrow 0.2\)\;
|
||||
|
||||
\For{\(t \leftarrow 0\) \KwTo \(T-1\)}{
|
||||
|
||||
set \(p_t \leftarrow \pi(\cdot) \) %c + (1 - \kappa \hat\alpha)\,(p^{ref}-c)\)\;
|
||||
and clip \(p_t\) to a feasible range (e.g., near cost up to a max margin)\;
|
||||
|
||||
|
||||
\(\hat Q_t \leftarrow 0\), \(\mathcal S_t \leftarrow \emptyset\); \tcp{Observe sessions and compute demand proxy (Eq.~2)}
|
||||
\For{\(m \leftarrow 1\) \KwTo \(M\)}{
|
||||
sample a session trajectory \(\tau_m\) using \(\bar T_H\) or \(\bar T_A\)\;
|
||||
\(\hat Q_t \leftarrow \hat Q_t + \sum_{k}\omega(a_{m,k})\)\;
|
||||
\(\mathcal S_t \leftarrow \mathcal S_t \cup \{\tau_m\}\)\;
|
||||
}
|
||||
|
||||
\tcp{Estimate contamination from behavioral separability}
|
||||
compute \(\hat\alpha \leftarrow \frac{1}{M}\sum_{\tau\in\mathcal S_t} \Big[\sigma\big(\beta(\Delta_H(\tau)-\Delta_A(\tau))\big)\Big]\)\;
|
||||
|
||||
compute \(J_t \leftarrow \text{Revenue}(p_t,\hat Q_t) - \lambda\cdot \text{COILeak}(\hat\alpha)\)\;
|
||||
\KwIn{stepsize $\eta$, smoothing $\delta$, rank $d$}
|
||||
\For{$t=1$ \KwTo $T$}{
|
||||
Sample $u_t$ on unit sphere; set $x_t^\prime=x_t+\delta u_t$\;
|
||||
Set $p_t \gets U x_t^\prime$ and observe $q_t, R_t(p_t)$\;
|
||||
$x_{t+1} \gets \Pi\_{\mathcal{X}}(x_t-\eta R_t(p_t) u_t)$\;
|
||||
}
|
||||
\caption{Online Pricing Optimization (template)}
|
||||
\end{algorithm}
|
||||
|
||||
|
||||
The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform publishes prices (leader move), observes the resulting session trajectories (follower response), and updates its contamination estimate based on behavioral divergence from the learned human and agent transition kernels $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$. The history buffer $\mathcal{L}$ (termed ``Limbo'' in our implementation) enforces the alternating Stackelberg structure by maintaining the temporal sequence of price publications and demand observations.
|
||||
|
||||
%The defensive price update in Line 24 implements a contamination-aware margin shrinkage: as the estimated agent contamination $\hat{\alpha}_t$ increases, the margin $(p^{\mathrm{ref}} - c)$ is proportionally reduced by factor $\kappa \in [0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring prices remain within the feasible set $\mathcal{P}$. In subsequent experiments, this heuristic update is replaced by the DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}, which optimizes against the Wasserstein ambiguity set $\mathcal{U}_\epsilon$ rather than relying on a fixed margin adjustment rule.
|
||||
|
||||
\section{Heuristics as part of neuro-inspired steering systems}
|
||||
|
||||
Steve Burns, superior culliculus (face heuristics) we create this sort of part of the 'brain' + amortized inference.
|
||||
|
||||
We could say that a DQN for example is the learnin subsystem and then within our reward mechanism or some other computational method we introduce a steering subsystem which acts as the proposed ``pricing heuristic'' against the given non human transaction data.
|
||||
|
||||
\section{Market construction}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
\section{Discussion}
|
||||
|
||||
\subsection{Transition to Agentic Market Microstructure}
|
||||
|
||||
Our analysis of the interaction dynamics between the platform and non-human actors suggests that the current static pricing models are insufficient for an agent-mediated economy. If we assume a transition toward a direct revelation mechanism, where actors must reveal their true valuation of a good through bidding dynamics, we inevitably introduce significant stochasticity into the pricing system. Unlike traditional e-commerce where prices are relatively sticky, such a mechanism implies a high volatility characteristic of financial equity markets (without the fungability however).
|
||||
|
||||
However, ecommerce commodities differ fundamentally from financial securities: they possess a hard floor defined by unit economics and reservation prices. The market might react enthusiastically to an iPhone priced at \$1, such a transaction is not permissible. The platform must establish an initial valuation anchor ($P_{0}$) defined by the marginal cost plus a target margin, around which the market price is permitted to fluctuate. We propose the introduction of GenAI Agents as Institutional Market Makers.
|
||||
|
||||
This is also under the assumption of expected transactional capabilities being given to AI Agents.
|
||||
|
||||
|
||||
|
||||
\subsection{Risk Assessment and Limitations}
|
||||
|
||||
Acknowledge risks and constraints and data sizes.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
\section{Conclusion}
|
||||
|
||||
\subsection{Summary of contributions}
|
||||
\subsection{Summary of contributions }
|
||||
Restate the thesis and key findings with validation of research objectives.
|
||||
|
||||
\subsection{Future Works and Next Steps}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
|
||||
\begin{tikzpicture}[
|
||||
% Styles for consistency
|
||||
axis/.style={->, >=Stealth, line width=1.2pt, color=black!85},
|
||||
curve/.style={color=black, line width=2.5pt},
|
||||
point/.style={circle, fill=black, inner sep=0pt, minimum size=6pt},
|
||||
label_text/.style={font=\large, align=center, color=black},
|
||||
annotation_line/.style={thick, -, color=black!60}
|
||||
]
|
||||
|
||||
% Define Radius
|
||||
\def\R{5}
|
||||
|
||||
% Draw Axes
|
||||
% Extended slightly beyond radius (\R + 1)
|
||||
\draw[axis] (0,0) -- (\R+1.5,0) node[midway, below=10pt, font=\bfseries\large] {UX Index};
|
||||
\draw[axis] (0,0) -- (0,\R+1.5) node[midway, left=15pt, rotate=90, font=\bfseries\large] {Performance};
|
||||
|
||||
% Draw Perfect 1/4 Circle
|
||||
% Syntax: arc (start_angle : end_angle : radius)
|
||||
\draw[curve] (0,\R) arc (90:0:\R);
|
||||
|
||||
% 1. Paranoid (High Performance side) -> Angle 67.5 degrees
|
||||
\node[point] (p1) at (75:\R) {};
|
||||
\node[label_text, above right=0.1cm and 0.1cm of p1] (l1) {Paranoid};
|
||||
\draw[annotation_line] (l1) -- (p1);
|
||||
|
||||
% 2. Perfect Detection (Exact Middle) -> Angle 45 degrees
|
||||
\node[point] (p2) at (45:\R) {};
|
||||
\node[label_text, above right=0.2cm and 0.2cm of p2] (l2) {Perfect Detection};
|
||||
\draw[annotation_line] (l2) -- (p2);
|
||||
|
||||
% 3. No Detection (High UX side) -> Angle 22.5 degrees
|
||||
\node[point] (p3) at (15:\R) {};
|
||||
\node[label_text, right=0.5cm of p3] (l3) {No Detection};
|
||||
\draw[annotation_line] (l3) -- (p3);
|
||||
|
||||
\end{tikzpicture}
|
||||
@@ -1,65 +0,0 @@
|
||||
\begin{table}[ht]
|
||||
\centering
|
||||
\small
|
||||
\resizebox{\columnwidth}{!}{%
|
||||
\begin{tabular}{p{4.5cm}p{1.5cm}p{6cm}}
|
||||
\hline
|
||||
\textbf{Feature} & \textbf{Type} & \textbf{Description} \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Session Identifiers}} \\
|
||||
sessionId & object & Unique identifier for user session \\
|
||||
experimentId & object & Experiment run identifier \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Temporal Features}} \\
|
||||
session\_duration\_sec & float & Total session duration in seconds \\
|
||||
avg\_time\_between\_events & float & Mean inter-event time \\
|
||||
std\_time\_between\_events & float & Standard deviation of inter-event times \\
|
||||
min\_time\_between\_events & float & Minimum time between consecutive events \\
|
||||
session\_start\_hour & int & Hour of day when session started \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Interaction Metrics}} \\
|
||||
total\_interactions & int & Count of all user interactions \\
|
||||
total\_events & int & Total number of tracked events \\
|
||||
interaction\_velocity & float & Rate of interactions per time unit \\
|
||||
max\_velocity\_5min & int & Peak interaction count in any 5-minute window \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Navigation Behavior}} \\
|
||||
unique\_pages & int & Number of distinct pages visited \\
|
||||
page\_views & int & Total page view events \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Product Engagement}} \\
|
||||
item\_views & int & Number of product detail views \\
|
||||
unique\_products\_viewed & int & Count of distinct products examined \\
|
||||
product\_view\_depth & int & Repeat views of same products \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Conversion Funnel}} \\
|
||||
cart\_adds & int & Number of items added to cart \\
|
||||
purchases & int & Completed transactions \\
|
||||
cart\_to\_view\_ratio & float & Ratio of cart additions to item views \\
|
||||
conversion\_rate & float & Purchase to view conversion \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Interaction Quality}} \\
|
||||
hover\_events & int & Mouse hover event count \\
|
||||
hover\_intensity & float & Hover events per interaction \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Price Behavior}} \\
|
||||
avg\_price\_seen & float & Mean price across viewed products \\
|
||||
min\_price\_seen & float & Lowest price encountered \\
|
||||
max\_price\_seen & float & Highest price encountered \\
|
||||
price\_range & float & Difference between max and min prices seen \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Technical Fingerprinting}} \\
|
||||
is\_headless & bool & Headless browser detection flag \\
|
||||
is\_automation & bool & Automation framework detection flag \\
|
||||
browser\_family & object & Browser type classification \\
|
||||
\hline
|
||||
\multicolumn{3}{l}{\textit{Experimental Labels}} \\
|
||||
is\_agent & bool & Ground truth agent classification \\
|
||||
xp\_human\_only & bool & Human-only experiment indicator \\
|
||||
xp\_market\_mode & object & Market context (hotel/airline) \\
|
||||
\hline
|
||||
\end{tabular}%
|
||||
}
|
||||
\caption{Feature matrix schema for session-level behavioral classification (32 features total).}
|
||||
\label{tab:features}
|
||||
\end{table}
|
||||
@@ -1,110 +0,0 @@
|
||||
\definecolor{mygreenfill}{RGB}{169, 234, 186}
|
||||
\definecolor{mygreenborder}{RGB}{29, 145, 61}
|
||||
\definecolor{mybluefill}{RGB}{204, 222, 255}
|
||||
\definecolor{myblueborder}{RGB}{66, 106, 189}
|
||||
\definecolor{mygray}{RGB}{150, 150, 150}
|
||||
|
||||
|
||||
|
||||
\begin{tikzpicture}[
|
||||
node distance=2cm,
|
||||
% Style for Green Nodes
|
||||
greenbox/.style={
|
||||
rectangle,
|
||||
draw=mygreenborder,
|
||||
fill=mygreenfill,
|
||||
line width=1.2pt,
|
||||
align=center,
|
||||
minimum height=1cm
|
||||
},
|
||||
% Style for Blue Nodes
|
||||
bluebox/.style={
|
||||
rectangle,
|
||||
draw=myblueborder,
|
||||
fill=mybluefill,
|
||||
line width=1.2pt,
|
||||
align=center,
|
||||
minimum height=1cm
|
||||
},
|
||||
% Style for Arrows
|
||||
myarrow/.style={
|
||||
->,
|
||||
>={Stealth[length=3mm, width=2mm]},
|
||||
draw=black!80,
|
||||
line width=1.2pt,
|
||||
rounded corners=5pt
|
||||
},
|
||||
% Style for Background Dashed Circles
|
||||
dashedloop/.style={
|
||||
dashed,
|
||||
draw=mygray,
|
||||
line width=1pt
|
||||
}
|
||||
]
|
||||
|
||||
% --- Coordinate Layout ---
|
||||
% Defining a grid relative to the center
|
||||
|
||||
% Left Loop (Green) Nodes
|
||||
\node[greenbox, minimum width=3.5cm] (commerce) at (-3.5, 2) {Commerce Experiment};
|
||||
\node[greenbox, minimum width=1.5cm] (raw) at (-6.5, 0) {Raw\\Logs};
|
||||
\node[greenbox, minimum width=1.5cm] (features) at (-4, -2.5) {Features};
|
||||
\node[greenbox, minimum width=2.5cm] (classification) at (-1, -0.5) {Classification\\Training A/H};
|
||||
|
||||
% Right Loop (Blue) Nodes
|
||||
\node[bluebox, minimum width=2.5cm] (trainedpricing) at (3.2, 2) {Trained Pricing};
|
||||
\node[bluebox, minimum width=2.5cm] (policy) at (6.5, 0) {Trained Pricing\\Policy};
|
||||
\node[bluebox, minimum width=2.5cm] (rlgym) at (3.2, -2.2) {RL Gym\\Training};
|
||||
|
||||
% --- Background Dashed Loops ---
|
||||
\begin{scope}[on background layer]
|
||||
% Left Loop Circle
|
||||
\draw[dashedloop] (-3.5, 0) ellipse (3.5cm and 2.8cm);
|
||||
% Right Loop Circle
|
||||
\draw[dashedloop] (3.5, 0) ellipse (3.5cm and 2.8cm);
|
||||
\end{scope}
|
||||
|
||||
% --- Arrows: Loop One (Green) ---
|
||||
% Commerce -> Raw Logs
|
||||
\draw[myarrow] (commerce.west) to[out=180, in=90] (raw.north);
|
||||
|
||||
% Raw Logs -> Features
|
||||
\draw[myarrow] (raw.south) to[out=270, in=180] (features.west);
|
||||
|
||||
% Features -> Classification
|
||||
\draw[myarrow] (features.east) to[out=0, in=250] (classification.south);
|
||||
|
||||
% Classification -> Commerce (Closing the loop)
|
||||
\draw[myarrow] (classification.north) to[out=110, in=0] (commerce.east);
|
||||
|
||||
% --- Arrows: Loop Two (Blue) ---
|
||||
% Classification (Green) -> RL Gym (Blue) - Crossing over
|
||||
\draw[myarrow] (classification.east) to[out=0, in=180] (rlgym.west);
|
||||
|
||||
% RL Gym -> Policy
|
||||
\draw[myarrow] (rlgym.east) to[out=0, in=270] (policy.south);
|
||||
|
||||
% Policy -> Trained Pricing
|
||||
\draw[myarrow] (policy.north) to[out=90, in=0] (trainedpricing.east);
|
||||
|
||||
% Trained Pricing -> Commerce (Crossing back)
|
||||
\draw[myarrow] (trainedpricing.west) -- node[above, font=\small, yshift=2pt] {New Pricing} (commerce.east);
|
||||
|
||||
% --- Text Labels ---
|
||||
|
||||
% Loop One Label
|
||||
\node[align=center] at (-3.8, 0) {Loop One:\\Data \textit{(Online)}};
|
||||
|
||||
% Loop Two Label
|
||||
\node[align=center] at (3.5, 0) {Loop Two:\\Defense Gym \textit{(Offline)}};
|
||||
|
||||
% Bottom Legend
|
||||
\node[font=\small] (taskA) at (-4, -4) {Dynamic Pricing Task A};
|
||||
\node[font=\small] (taskB) at (4, -4) {Dynamic Pricing Task B};
|
||||
\node[font=\small] (indep) at (0, -4) {Independent};
|
||||
|
||||
% Arrows for bottom legend
|
||||
\draw[->, >=Stealth, thick, darkgray] (indep.west) -- (taskA.east);
|
||||
\draw[->, >=Stealth, thick, darkgray] (indep.east) -- (taskB.west);
|
||||
|
||||
\end{tikzpicture}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,51 +1,52 @@
|
||||
% -*- TeX-master: t -*-
|
||||
\documentclass[12pt,letterpaper]{article}
|
||||
\documentclass[sigconf,nonacm,natbib=false]{acmart}
|
||||
|
||||
% Remove ACM copyright/conference info for thesis
|
||||
\settopmatter{printacmref=false}
|
||||
\renewcommand\footnotetextcopyrightpermission[1]{}
|
||||
\pagestyle{plain}
|
||||
|
||||
\input{preamble}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\begin{titlepage}
|
||||
\centering
|
||||
\includegraphics[width=0.3\textwidth]{graphics/SST.png}\\[1cm]
|
||||
\LARGE\textbf{PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms}\\[0.5cm]
|
||||
\Large\textbf{Daniel Rösel}\\
|
||||
\large\textit{Bachelor of Computer Science \& Artificial Intelligence}\\[0.5cm]
|
||||
\Large\textit{Supervised by:}\\
|
||||
\Large\textbf{Alberto Martín Izquierdo}\\
|
||||
\large\textit{IE University, Madrid, Spain}\\[1cm]
|
||||
\large\today
|
||||
\end{titlepage}
|
||||
\title{Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms}
|
||||
|
||||
\author{Daniel Rösel}
|
||||
\email{daniel@alves.world}
|
||||
\affiliation{%
|
||||
\institution{IE University}
|
||||
\city{Madrid}
|
||||
\country{Spain}
|
||||
}
|
||||
|
||||
\author{Alberto Martín Izquierdo}
|
||||
\email{amartini@faculty.ie.edu}
|
||||
\affiliation{%
|
||||
\institution{IE University}
|
||||
\city{Madrid}
|
||||
\country{Spain}
|
||||
}
|
||||
|
||||
\begin{abstract}
|
||||
With accelerated growth of Lager Language Model agents in e-commerce a novel adversarial dynamic to digital markets emerges. This paper address the vulnerability of dynamic pricing systems to AI intermediaries that decouple the information gather stages from the transaction execution. By conducing reconnaissance isolates sessions, agents circumvent the ``Cost of Information'' (COI) defined as the accumulated price premium typically thought demand expression estimators.
|
||||
We formally define this phenomenon and derive the Cost of Information Theorem, proving that as the saturation of independent, utility-maximizing agents increases, the platform’s ability to sustain a COI converges to zero, rendering standard dynamic pricing mechanisms incentive-incompatible.
|
||||
To respond to this threat we propose a defensive framework which integrates behavioral economics with Adversarially Distributionally Robust Optimization (DRO). We introduce a custom e-commerce research platform built on hybrid Kappa-Lambda architecture, designed to capture and simulate high-fidelity controlled interaction trajectories. We further demonstrate through modeling that human and agent behaviors exhibit distinct transition probability kernels, enabling the construction of discriminative models based on Kullback-Leibler divergence.
|
||||
These behavioral signals serve as inputs for a Distributionally Robust Reinforcement Learning (DR-RL) agent. We formulate the pricing problem as a Stackelberg game where the learner optimizes against an ambiguity set of demand distributions defined by the Wasserstein distance. This approach allows the pricing policy to remain robust against non-stationary contamination without overfitting to deterministic demand curves. The research validates a mechanism for preserving margin integrity and market equilibrium in an agent-mediated economy, while minimizing degradation to the legitimate human user experience (UX).
|
||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
||||
\end{abstract}
|
||||
|
||||
\noindent\textbf{Keywords:} Dynamic Pricing, LLM Agents, Adversarial Machine Learning, E-commerce, Behavioral Detection, Reinforcement Learning
|
||||
\maketitle
|
||||
|
||||
\vspace{1em}
|
||||
\noindent\textbf{Acknowledgments:} Eugene Bykovets, PhD - ETH for helping with problem formulation. This research was supported by the TPU Research Cloud program.
|
||||
|
||||
\clearpage
|
||||
\input{chapters/01-intro}
|
||||
\input{chapters/02-literature-review}
|
||||
% \input{chapters/03-methodology}
|
||||
% \input{chapters/04-results}
|
||||
% \input{chapters/05-discussion}
|
||||
% \input{chapters/06-conclusion}
|
||||
\input{chapters/03-methodology}
|
||||
\input{chapters/04-results}
|
||||
\input{chapters/05-discussion}
|
||||
\input{chapters/06-conclusion}
|
||||
|
||||
|
||||
\printbibliography
|
||||
|
||||
\clearpage
|
||||
\onecolumn
|
||||
\appendix
|
||||
\section{Terminology}
|
||||
\begin{description}
|
||||
\item[Agent $A$] An actor of non-human nature, powered by an LLM.
|
||||
\item[Human $H$] An individual human with some job to be done.
|
||||
\end{description}
|
||||
% \input{../build/concatenated_code}
|
||||
\input{../build/concatenated_code}
|
||||
|
||||
\end{document}
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
% Encoding
|
||||
\usepackage[utf8]{inputenc}
|
||||
% acmart already includes: graphicx, hyperref, booktabs, amsmath, natbib
|
||||
% Only load packages not included in acmart
|
||||
|
||||
% Math packages (load before fonts to avoid conflicts)
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amsthm}
|
||||
\usepackage{appendix}
|
||||
\usepackage[inline]{enumitem}
|
||||
|
||||
% Define theorem environments
|
||||
\newtheorem{theorem}{Theorem}
|
||||
\newtheorem{definition}{Definition}
|
||||
\newtheorem{lemma}{Lemma}
|
||||
\newtheorem{corollary}{Corollary}
|
||||
|
||||
% Font and spacing
|
||||
\usepackage{newtxtext,newtxmath}
|
||||
\usepackage{setspace}
|
||||
\doublespacing
|
||||
|
||||
% Page geometry
|
||||
\usepackage[margin=1in]{geometry}
|
||||
|
||||
% Essential packages
|
||||
\usepackage{graphicx}
|
||||
\usepackage{hyperref}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{csquotes}
|
||||
\usepackage{subcaption}
|
||||
\usepackage{siunitx}
|
||||
@@ -32,11 +8,6 @@
|
||||
\usepackage{listings}
|
||||
\usepackage{xcolor}
|
||||
\usepackage[ruled,vlined]{algorithm2e}
|
||||
\usepackage{cleveref}
|
||||
\usepackage{adjustbox}
|
||||
\usetikzlibrary{trees}
|
||||
% Configure cleveref for algorithm2e
|
||||
\crefname{algocf}{Algorithm}{Algorithms}
|
||||
|
||||
\usetikzlibrary{positioning, shapes, arrows.meta, fit, backgrounds}
|
||||
\lstset{
|
||||
@@ -55,16 +26,6 @@
|
||||
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
||||
}
|
||||
|
||||
% Use biblatex with authoryear style for in-text citations like (Author, Year)
|
||||
\usepackage[backend=bibtex,style=authoryear,natbib=true,maxcitenames=2]{biblatex}
|
||||
% Use biblatex instead of natbib (acmart default)
|
||||
\usepackage[backend=bibtex,style=numeric]{biblatex}
|
||||
\addbibresource{bib/references.bib}
|
||||
|
||||
% Page headers (SciTech format)
|
||||
\usepackage{fancyhdr}
|
||||
\setlength{\headheight}{14.5pt}
|
||||
\addtolength{\topmargin}{-2.5pt}
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyhead[L]{PHANTOM}
|
||||
\fancyhead[R]{\thepage}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
|
||||
@@ -11,4 +11,3 @@ pytest-asyncio
|
||||
uv
|
||||
scikit-learn
|
||||
supabase
|
||||
pymc
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
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
|
||||
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))
|
||||
|
||||
|
||||
def simple_agent_detector(session_df: pd.DataFrame) -> pd.Series:
|
||||
# baseline heuristic: high velocity + low conversion
|
||||
v = session_df.get("interaction_velocity", pd.Series(0.0, index=session_df.index))
|
||||
cr = session_df.get("conversion_rate", pd.Series(0.0, index=session_df.index))
|
||||
total = session_df.get("total_interactions", pd.Series(0, index=session_df.index))
|
||||
return (total >= 12) & (v >= 0.20) & (cr <= 0.01)
|
||||
|
||||
|
||||
class CommercePlatform:
|
||||
def __init__(self, product_catelogue_size: int, max_price: float, min_price: float,
|
||||
constraints: BusinessLogicConstraints, agent_detector: Optional[Callable[[pd.DataFrame], pd.Series]] = None,
|
||||
use_defense: bool = False):
|
||||
self.product_catelogue_size = product_catelogue_size
|
||||
self.max_price = max_price
|
||||
self.min_price = min_price
|
||||
self.constraints = constraints
|
||||
self.use_defense = use_defense
|
||||
self.agent_detector = agent_detector
|
||||
self.simulation_history: List[Dict[str, Any]] = []
|
||||
self._rng = np.random.default_rng(constraints.seed)
|
||||
self._popularity = self._rng.lognormal(mean=0.0, sigma=0.6, size=self.product_catelogue_size)
|
||||
self._popularity = self._popularity / (self._popularity.mean() + 1e-12)
|
||||
self._last_interaction_df: pd.DataFrame = pd.DataFrame()
|
||||
|
||||
def setup_true_demand(self, prices: np.ndarray) -> Dict[str, np.ndarray]:
|
||||
# ground truth purchase propensities
|
||||
p = np.clip(prices, self.min_price, self.max_price)
|
||||
pn = p / self.max_price
|
||||
human_prob = self.constraints.base_human_demand * (pn ** self.constraints.human_price_elasticity)
|
||||
agent_prob = self.constraints.base_agent_demand * (pn ** self.constraints.agent_price_elasticity)
|
||||
return {
|
||||
"human_purchase_prob": np.clip(human_prob * self._popularity, 0.0, 0.95),
|
||||
"agent_purchase_prob": np.clip(agent_prob * self._popularity, 0.0, 0.95)
|
||||
}
|
||||
|
||||
def _session_markup_multiplier(self, signal_score: float) -> float:
|
||||
# session-based COI markup based on demand signal expression
|
||||
x = (signal_score - self.constraints.coi_threshold) / max(self.constraints.coi_sigmoid_temp, 1e-6)
|
||||
return 1.0 + self.constraints.coi_strength * float(_sigmoid(np.array([x]))[0])
|
||||
|
||||
def _simulate_sessions(self, base_prices: np.ndarray) -> pd.DataFrame:
|
||||
demand = self.setup_true_demand(base_prices)
|
||||
human_pprob = demand["human_purchase_prob"]
|
||||
agent_pprob = demand["agent_purchase_prob"]
|
||||
events: List[Dict[str, Any]] = []
|
||||
T = self.constraints.sessions_per_step
|
||||
n_agent_sessions = int(round(T * self.constraints.agent_share))
|
||||
n_human_sessions = T - n_agent_sessions
|
||||
|
||||
# human sessions: normal browse with possible purchase
|
||||
for s in range(n_human_sessions):
|
||||
session_id = f"h_{len(events)}_{s}"
|
||||
k = int(self._rng.integers(1, 4))
|
||||
prod_ids = self._rng.choice(self.product_catelogue_size, size=k, replace=False)
|
||||
t = 0.0
|
||||
inter_times = self._rng.gamma(shape=2.0, scale=3.0, size=3 * k)
|
||||
signal_score = 0.0
|
||||
purchased_any = False
|
||||
|
||||
for i, pid in enumerate(prod_ids):
|
||||
t += float(inter_times[i])
|
||||
price_shown = float(base_prices[pid])
|
||||
events.append({
|
||||
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
|
||||
"action": "view", "t": t, "price_shown": price_shown, "is_purchase": 0,
|
||||
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
|
||||
})
|
||||
signal_score += 1.0
|
||||
|
||||
if self._rng.random() < 0.35:
|
||||
t += float(inter_times[i + k])
|
||||
events.append({
|
||||
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
|
||||
"action": "cart", "t": t, "price_shown": price_shown, "is_purchase": 0,
|
||||
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
|
||||
})
|
||||
signal_score += 2.0
|
||||
|
||||
if (not purchased_any) and (self._rng.random() < float(human_pprob[pid])):
|
||||
t += float(inter_times[i + 2 * k])
|
||||
mult = self._session_markup_multiplier(signal_score)
|
||||
price_paid = float(np.clip(base_prices[pid] * mult, self.min_price, self.max_price))
|
||||
events.append({
|
||||
"session_id": session_id, "actor": "human", "agent_id": None, "product_id": int(pid),
|
||||
"action": "purchase", "t": t, "price_shown": float(base_prices[pid]), "is_purchase": 1,
|
||||
"price_paid": price_paid, "oracle_price_paid": price_paid, "signal_score": signal_score,
|
||||
})
|
||||
purchased_any = True
|
||||
|
||||
# agent sessions: split recon/purchase to circumvent COI
|
||||
n_agent_ids = max(1, n_agent_sessions // 2)
|
||||
for a in range(n_agent_ids):
|
||||
agent_id = f"a_{a}"
|
||||
recon_session_id = f"{agent_id}_recon"
|
||||
t = 0.0
|
||||
n_views = int(self._rng.poisson(lam=8) * self.constraints.agent_recon_multiplier) + 5
|
||||
inter_times = self._rng.gamma(shape=2.0, scale=0.6, size=max(n_views, 1))
|
||||
prod_ids = self._rng.integers(0, self.product_catelogue_size, size=n_views)
|
||||
recon_signal = 0.0
|
||||
|
||||
for i, pid in enumerate(prod_ids):
|
||||
t += float(inter_times[i])
|
||||
events.append({
|
||||
"session_id": recon_session_id, "actor": "agent", "agent_id": agent_id, "product_id": int(pid),
|
||||
"action": "view", "t": t, "price_shown": float(base_prices[pid]), "is_purchase": 0,
|
||||
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
|
||||
})
|
||||
recon_signal += 1.0
|
||||
|
||||
# clean purchase session with minimal interactions
|
||||
if self._rng.random() < self.constraints.agent_purchase_probability:
|
||||
purchase_session_id = f"{agent_id}_clean"
|
||||
pid = int(self._rng.integers(0, self.product_catelogue_size))
|
||||
t2 = 0.0
|
||||
clean_signal = 0.0
|
||||
t2 += float(self._rng.gamma(shape=2.0, scale=0.7))
|
||||
events.append({
|
||||
"session_id": purchase_session_id, "actor": "agent", "agent_id": agent_id, "product_id": pid,
|
||||
"action": "view", "t": t2, "price_shown": float(base_prices[pid]), "is_purchase": 0,
|
||||
"price_paid": 0.0, "oracle_price_paid": 0.0, "signal_score": 0.0,
|
||||
})
|
||||
clean_signal += 1.0
|
||||
|
||||
if self._rng.random() < float(agent_pprob[pid]):
|
||||
t2 += float(self._rng.gamma(shape=2.0, scale=0.7))
|
||||
obs_mult = self._session_markup_multiplier(clean_signal)
|
||||
obs_paid = float(np.clip(base_prices[pid] * obs_mult, self.min_price, self.max_price))
|
||||
oracle_mult = self._session_markup_multiplier(recon_signal) # oracle links recon->purchase
|
||||
oracle_paid = float(np.clip(base_prices[pid] * oracle_mult, self.min_price, self.max_price))
|
||||
events.append({
|
||||
"session_id": purchase_session_id, "actor": "agent", "agent_id": agent_id, "product_id": pid,
|
||||
"action": "purchase", "t": t2, "price_shown": float(base_prices[pid]), "is_purchase": 1,
|
||||
"price_paid": obs_paid, "oracle_price_paid": oracle_paid, "signal_score": clean_signal,
|
||||
})
|
||||
|
||||
return pd.DataFrame(events)
|
||||
|
||||
def compute_interaction_features(self, interaction_df: pd.DataFrame) -> Dict[str, float]:
|
||||
if interaction_df.empty:
|
||||
return {"mean_sale_price": 0.0, "look_to_book": 0.0}
|
||||
purchases = interaction_df[interaction_df["action"] == "purchase"]
|
||||
mean_sale_price = float(purchases["price_paid"].mean()) if not purchases.empty else 0.0
|
||||
views = float((interaction_df["action"] == "view").sum())
|
||||
buys = float((interaction_df["action"] == "purchase").sum())
|
||||
return {"mean_sale_price": mean_sale_price, "look_to_book": float(views / (buys + 1e-6))}
|
||||
|
||||
def _session_feature_table(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
g = df.groupby("session_id", sort=False)
|
||||
session_duration = g["t"].max() - g["t"].min()
|
||||
total_interactions = g.size()
|
||||
avg_time_between = g["t"].apply(lambda x: float(np.diff(np.sort(x.to_numpy())).mean()) if len(x) > 1 else 0.0)
|
||||
interaction_velocity = total_interactions / (session_duration + 1e-6)
|
||||
views = g.apply(lambda x: int((x["action"] == "view").sum()), include_groups=False)
|
||||
cart_adds = g.apply(lambda x: int((x["action"] == "cart").sum()), include_groups=False)
|
||||
purchases = g.apply(lambda x: int((x["action"] == "purchase").sum()), include_groups=False)
|
||||
conversion_rate = purchases / (views + 1e-6)
|
||||
is_agent = g["actor"].apply(lambda s: bool((s == "agent").any()), include_groups=False)
|
||||
|
||||
return pd.DataFrame({
|
||||
"session_duration_sec": session_duration.astype(float),
|
||||
"avg_time_between_events": avg_time_between.astype(float),
|
||||
"total_interactions": total_interactions.astype(int),
|
||||
"interaction_velocity": interaction_velocity.astype(float),
|
||||
"item_views": views.astype(int),
|
||||
"cart_adds": cart_adds.astype(int),
|
||||
"purchases": purchases.astype(int),
|
||||
"conversion_rate": conversion_rate.astype(float),
|
||||
"is_agent": is_agent.astype(bool),
|
||||
}).reset_index()
|
||||
|
||||
def demand_estimate(self, interaction_df: pd.DataFrame, exclude_sessions: Optional[pd.Series] = None) -> np.ndarray:
|
||||
# proxy demand from weighted interaction events
|
||||
if interaction_df.empty:
|
||||
return np.zeros(self.product_catelogue_size, dtype=np.float32)
|
||||
df = interaction_df
|
||||
if exclude_sessions is not None:
|
||||
bad_sessions = set(exclude_sessions.loc[exclude_sessions].index)
|
||||
df = df[~df["session_id"].isin(bad_sessions)]
|
||||
weights = {"view": 0.15, "cart": 0.75, "purchase": 2.5}
|
||||
w = df["action"].map(weights).fillna(0.0).to_numpy(dtype=float)
|
||||
prod = df["product_id"].to_numpy(dtype=int)
|
||||
q_hat = np.zeros(self.product_catelogue_size, dtype=float)
|
||||
np.add.at(q_hat, prod, w)
|
||||
return q_hat.astype(np.float32)
|
||||
|
||||
def run_pricing_simulation(self, prices: np.ndarray) -> Dict[str, Any]:
|
||||
interaction_df = self._simulate_sessions(prices)
|
||||
self._last_interaction_df = interaction_df
|
||||
session_df = self._session_feature_table(interaction_df)
|
||||
|
||||
predicted_agent_sessions = None
|
||||
if (self.use_defense and self.agent_detector is not None and not session_df.empty):
|
||||
predicted_agent_sessions = self.agent_detector(session_df.set_index("session_id"))
|
||||
|
||||
q_hat_naive = self.demand_estimate(interaction_df, exclude_sessions=None)
|
||||
q_hat_defended = self.demand_estimate(interaction_df, exclude_sessions=predicted_agent_sessions) \
|
||||
if predicted_agent_sessions is not None else q_hat_naive.copy()
|
||||
|
||||
true_human = np.zeros(self.product_catelogue_size, dtype=float)
|
||||
true_agent = np.zeros(self.product_catelogue_size, dtype=float)
|
||||
if not interaction_df.empty:
|
||||
purchases = interaction_df[interaction_df["action"] == "purchase"]
|
||||
if not purchases.empty:
|
||||
for _, r in purchases.iterrows():
|
||||
if r["actor"] == "human":
|
||||
true_human[int(r["product_id"])] += 1.0
|
||||
else:
|
||||
true_agent[int(r["product_id"])] += 1.0
|
||||
|
||||
revenue_observed = float(interaction_df["price_paid"].sum()) if not interaction_df.empty else 0.0
|
||||
revenue_oracle = float(interaction_df["oracle_price_paid"].sum()) if not interaction_df.empty else 0.0
|
||||
agent_loss = max(0.0, revenue_oracle - revenue_observed)
|
||||
|
||||
eps = 1e-6
|
||||
internal_error_naive = np.abs(true_human - q_hat_naive) / (true_human + eps)
|
||||
internal_error_def = np.abs(true_human - q_hat_defended) / (true_human + eps)
|
||||
interaction_features = self.compute_interaction_features(interaction_df)
|
||||
|
||||
summary = {
|
||||
"prices": prices.copy(),
|
||||
"interaction_df": interaction_df,
|
||||
"session_df": session_df,
|
||||
"q_hat_naive": q_hat_naive,
|
||||
"q_hat_defended": q_hat_defended,
|
||||
"true_human_demand": true_human.astype(np.float32),
|
||||
"true_agent_purchases": true_agent.astype(np.float32),
|
||||
"internal_error_naive": internal_error_naive.astype(np.float32),
|
||||
"internal_error_defended": internal_error_def.astype(np.float32),
|
||||
"interaction_features": interaction_features,
|
||||
"revenue_observed": revenue_observed,
|
||||
"revenue_oracle": revenue_oracle,
|
||||
"agent_loss": agent_loss,
|
||||
"predicted_agent_sessions": predicted_agent_sessions,
|
||||
}
|
||||
self.simulation_history.append(summary)
|
||||
return summary
|
||||
|
||||
def get_interaction_data(self) -> np.ndarray:
|
||||
if self._last_interaction_df.empty:
|
||||
return np.array([], dtype=object)
|
||||
return self._last_interaction_df.to_dict(orient="records")
|
||||
|
||||
|
||||
class PHANTOMEnv(gym.Env):
|
||||
metadata = {"render_modes": []}
|
||||
|
||||
def __init__(self, use_defense: bool = False):
|
||||
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),
|
||||
})
|
||||
})
|
||||
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,
|
||||
agent_detector=simple_agent_detector,
|
||||
use_defense=use_defense)
|
||||
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)
|
||||
result = self.commerce_platform.run_pricing_simulation(new_prices)
|
||||
|
||||
if self.commerce_platform.use_defense:
|
||||
demand_est = result["q_hat_defended"]
|
||||
internal_err = result["internal_error_defended"]
|
||||
else:
|
||||
demand_est = result["q_hat_naive"]
|
||||
internal_err = result["internal_error_naive"]
|
||||
|
||||
self.state["elasticity"]["price"] = new_prices
|
||||
self.state["elasticity"]["demand"] = demand_est
|
||||
|
||||
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"])
|
||||
err_mean = float(np.mean(internal_err))
|
||||
|
||||
reward = (revenue_observed
|
||||
- self.constraints.w_agent_loss * agent_loss
|
||||
- self.constraints.w_volatility * volatility
|
||||
- self.constraints.w_estimation_error * err_mean)
|
||||
|
||||
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()
|
||||
@@ -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 { messages = [] } = await resp.json();
|
||||
return messages 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: 5,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.WEB_URL || 'http://localhost:3000',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import {
|
||||
createFreshSession,
|
||||
viewProductViaFlow,
|
||||
rapidViewProductViaFlow,
|
||||
humanLikeViewProduct,
|
||||
getPriceFromDOM,
|
||||
verifySessionConsistency,
|
||||
addToCart,
|
||||
} from '../helpers/interactions';
|
||||
import { getSessionEvents } from '../helpers/kafka';
|
||||
|
||||
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);
|
||||
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(2500);
|
||||
|
||||
const events = await getSessionEvents(backendUrl, sessionId);
|
||||
expect(events.length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
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);
|
||||
|
||||
const startTime = Date.now();
|
||||
await rapidViewProductViaFlow(page, 10, 80, STORE_TYPE);
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
const eventsPerSec = 10 / duration;
|
||||
expect(eventsPerSec).toBeGreaterThan(2.0);
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
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(1500);
|
||||
await humanLikeViewProduct(page, 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,111 +0,0 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import {
|
||||
createFreshSession,
|
||||
viewProductViaFlow,
|
||||
rapidViewProductViaFlow,
|
||||
getPriceFromDOM,
|
||||
verifySessionConsistency,
|
||||
} from '../helpers/interactions';
|
||||
import { waitForInteractionEvent, countProductViews } from '../helpers/kafka';
|
||||
|
||||
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(2000);
|
||||
|
||||
const evt = await waitForInteractionEvent(backendUrl, sessionId, 'view_item_page');
|
||||
expect(evt).not.toBeNull();
|
||||
|
||||
const viewCount = await countProductViews(backendUrl, productId);
|
||||
expect(viewCount).toBeGreaterThanOrEqual(5);
|
||||
|
||||
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(1500);
|
||||
|
||||
await page.goto(`/products/${productId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const surgedPrice = await getPriceFromDOM(page);
|
||||
expect(surgedPrice).toBeGreaterThan(baselinePrice);
|
||||
|
||||
await page.waitForTimeout(12000);
|
||||
|
||||
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"]
|
||||
}
|
||||
80
web/package-lock.json
generated
80
web/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "^16.0.0",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
@@ -526,15 +526,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz",
|
||||
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz",
|
||||
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -548,9 +548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz",
|
||||
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -564,9 +564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -580,9 +580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -596,9 +596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -612,9 +612,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -628,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -644,9 +644,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1447,12 +1447,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.7",
|
||||
"@next/env": "16.0.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -1465,14 +1465,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"@next/swc-darwin-arm64": "16.0.0",
|
||||
"@next/swc-darwin-x64": "16.0.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.0",
|
||||
"@next/swc-linux-arm64-musl": "16.0.0",
|
||||
"@next/swc-linux-x64-gnu": "16.0.0",
|
||||
"@next/swc-linux-x64-musl": "16.0.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.0",
|
||||
"@next/swc-win32-x64-msvc": "16.0.0",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "^16.0.0",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export default function AirlineCheckout() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-sky-50 to-blue-50">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||
Thank you for flying with us
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||
const storeMode = process.env.STORE_MODE || 'hotel';
|
||||
const userAgent = req.headers.get('user-agent') || undefined;
|
||||
|
||||
const event: EventBase = {
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function GET(req: NextRequest) {
|
||||
const productId = searchParams.get('productId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const experimentId = searchParams.get('experimentId');
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
||||
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -96,10 +96,7 @@ export default function CartPage() {
|
||||
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
dispatchInteraction('checkout_start', undefined, { total, itemCount });
|
||||
window.location.href = '/checkout';
|
||||
}}
|
||||
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Proceed to Checkout
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export default function HotelCheckout() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||
Thank you for staying with us
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,10 @@
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Label, DateInput, Dropdown, DropdownCounter, SelectDropdown, SelectOption } from '@/components/ui';
|
||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||
|
||||
const CITIES: SelectOption[] = [
|
||||
{ value: 'JFK', label: 'New York (JFK)', sublabel: 'John F. Kennedy International' },
|
||||
{ value: 'LAX', label: 'Los Angeles (LAX)', sublabel: 'Los Angeles International' },
|
||||
{ value: 'ORD', label: 'Chicago (ORD)', sublabel: "O'Hare International" },
|
||||
{ value: 'MIA', label: 'Miami (MIA)', sublabel: 'Miami International' },
|
||||
{ value: 'SFO', label: 'San Francisco (SFO)', sublabel: 'San Francisco International' },
|
||||
{ value: 'SEA', label: 'Seattle (SEA)', sublabel: 'Seattle-Tacoma International' },
|
||||
{ value: 'ATL', label: 'Atlanta (ATL)', sublabel: 'Hartsfield-Jackson International' },
|
||||
{ value: 'DFW', label: 'Dallas (DFW)', sublabel: 'Dallas/Fort Worth International' },
|
||||
];
|
||||
|
||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||
|
||||
const PlaneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -32,9 +22,11 @@ const LocationIcon = () => (
|
||||
|
||||
export default function AirlineHero() {
|
||||
const router = useRouter();
|
||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [departDate, setDepartDate] = useState('');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
@@ -48,6 +40,8 @@ export default function AirlineHero() {
|
||||
|
||||
if (origin) params.set('origin', origin);
|
||||
if (destination) params.set('destination', destination);
|
||||
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
||||
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
||||
|
||||
params.set('adults', passengers.adults.toString());
|
||||
params.set('children', passengers.children.toString());
|
||||
@@ -72,15 +66,28 @@ export default function AirlineHero() {
|
||||
|
||||
<div className="search-form">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="mb-6">
|
||||
<RadioGroup
|
||||
name="tripType"
|
||||
value={tripType}
|
||||
onChange={setTripType}
|
||||
options={[
|
||||
{ value: 'roundtrip', label: 'Round-trip' },
|
||||
{ value: 'oneway', label: 'One-way' },
|
||||
{ value: 'multicity', label: 'Multi-city' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="origin">From</Label>
|
||||
<SelectDropdown
|
||||
<Input
|
||||
type="text"
|
||||
id="origin"
|
||||
value={origin}
|
||||
onChange={setOrigin}
|
||||
options={CITIES}
|
||||
placeholder="Select origin"
|
||||
onChange={(e) => setOrigin(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<PlaneIcon />}
|
||||
required
|
||||
/>
|
||||
@@ -88,12 +95,12 @@ export default function AirlineHero() {
|
||||
|
||||
<div>
|
||||
<Label htmlFor="destination">To</Label>
|
||||
<SelectDropdown
|
||||
<Input
|
||||
type="text"
|
||||
id="destination"
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
options={CITIES}
|
||||
placeholder="Select destination"
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Airport or city"
|
||||
icon={<LocationIcon />}
|
||||
required
|
||||
/>
|
||||
@@ -108,6 +115,20 @@ export default function AirlineHero() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="returnDate">Return</Label>
|
||||
{tripType === 'roundtrip' ? (
|
||||
<DateInput
|
||||
id="returnDate"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<DateInput id="returnDate" disabled />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||
|
||||
@@ -21,7 +21,7 @@ const AmenityIcon = ({ name }: { name: string }) => {
|
||||
breakfast: 'Breakfast',
|
||||
spa: 'Spa',
|
||||
};
|
||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name.replaceAll("_", " ")}</span>;
|
||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
||||
};
|
||||
|
||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
@@ -47,31 +47,18 @@ 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"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="hotel-image relative overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={hotel.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gray-200 flex items-center justify-center" style={{ display: 'none' }}>
|
||||
<span className="text-gray-400 text-sm">Image</span>
|
||||
</div>
|
||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-sm">Image</span>
|
||||
</div>
|
||||
|
||||
<div className="hotel-info">
|
||||
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{hotel.checkIn} - {hotel.checkOut}
|
||||
</div>
|
||||
@@ -80,6 +67,9 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||
<AmenityIcon key={a} name={a} />
|
||||
))}
|
||||
</div>
|
||||
{hotel.refundable && (
|
||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hotel-pricing">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Hotel } from '@/lib/hotel-utils';
|
||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||
|
||||
interface HotelDetailsProps {
|
||||
product: Hotel;
|
||||
@@ -10,63 +8,19 @@ interface HotelDetailsProps {
|
||||
addedToCart: boolean;
|
||||
}
|
||||
|
||||
const PriceTotalDisplay = ({ productId, nights }: { productId: string; nights: number }) => {
|
||||
const [price, setPrice] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPrice = async () => {
|
||||
try {
|
||||
const sessionRes = await fetch('/api/session');
|
||||
const sessionData = await sessionRes.json();
|
||||
const params = new URLSearchParams({
|
||||
productId,
|
||||
sessionId: sessionData.sessionId || '',
|
||||
experimentId: sessionData.experimentId || '',
|
||||
});
|
||||
const res = await fetch(`/api/pricing?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
setPrice(data.price);
|
||||
} catch (err) {
|
||||
console.error('failed to fetch price for total:', err);
|
||||
}
|
||||
};
|
||||
fetchPrice();
|
||||
}, [productId]);
|
||||
|
||||
if (!price) return <span className="text-4xl font-bold text-gray-900">Loading...</span>;
|
||||
|
||||
return (
|
||||
<span className="text-4xl font-bold text-gray-900">
|
||||
${(price * nights).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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={imageUrl}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
if (e.currentTarget.nextElementSibling) {
|
||||
(e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center" style={{ display: 'none' }}>
|
||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||
</div>
|
||||
{/* Image Section - Larger and cleaner */}
|
||||
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||
</div>
|
||||
|
||||
{/* Details Section - Full height/width usage */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b pb-6 mb-6">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
||||
<p className="text-xl text-gray-500">{product.roomType}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
@@ -85,17 +39,24 @@ export default function HotelDetails({ product, onAddToCart, addedToCart }: Hote
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.amenities.map(a => (
|
||||
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
||||
{a.replaceAll('_', ' ')}
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.refundable && (
|
||||
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
|
||||
<span className="font-medium">Free cancellation available</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Price per night</p>
|
||||
<div className="mb-3">
|
||||
<PriceDisplay productId={product.id} className="!text-2xl" />
|
||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
import { InputHTMLAttributes, useMemo } from 'react';
|
||||
import { InputHTMLAttributes } from 'react';
|
||||
|
||||
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
||||
|
||||
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
||||
const { minDate, maxDate } = useMemo(() => {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
|
||||
const tenDaysOut = new Date(tomorrow);
|
||||
tenDaysOut.setDate(tomorrow.getDate() + 9); // tomorrow + 9 = 10 days total
|
||||
|
||||
return {
|
||||
minDate: tomorrow.toISOString().split('T')[0],
|
||||
maxDate: tenDaysOut.toISOString().split('T')[0]
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
className={`input-field ${className}`.trim()}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
||||
}
|
||||
|
||||
@@ -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)] font-semibold'
|
||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
@@ -37,7 +37,9 @@ export default function Navigation() {
|
||||
<div className="flex items-center space-x-1">
|
||||
<NavLink href="/">Home</NavLink>
|
||||
<NavLink href="/products">Products</NavLink>
|
||||
<NavLink href="/search">Search</NavLink>
|
||||
<NavLink href="/cart">Cart</NavLink>
|
||||
<NavLink href="/checkout">Checkout</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
interface SelectDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
icon?: ReactNode;
|
||||
required?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function SelectDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
icon,
|
||||
required,
|
||||
id,
|
||||
}: SelectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value);
|
||||
const filtered = options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
o.value.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
o.sublabel?.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelect = (opt: SelectOption) => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div
|
||||
className="input-field flex items-center gap-2 cursor-pointer box-border"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{icon && <span className="text-[var(--text-secondary)]">{icon}</span>}
|
||||
{open ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
id={id}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent outline-none text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 text-sm ${value ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-[var(--bg-primary)] border-2 border-[var(--accent-primary)] rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-[var(--text-secondary)]">No results</div>
|
||||
) : (
|
||||
filtered.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt)}
|
||||
className={`px-4 py-2 cursor-pointer transition-colors hover:bg-[var(--accent-primary-light)] ${
|
||||
opt.value === value ? 'bg-[var(--accent-primary-light)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">{opt.label}</div>
|
||||
{opt.sublabel && <div className="text-xs text-[var(--text-secondary)]">{opt.sublabel}</div>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{required && !value && (
|
||||
<input type="text" required className="sr-only" tabIndex={-1} value="" onChange={() => {}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,5 +5,3 @@ export { default as DateInput } from './DateInput';
|
||||
export { default as RadioGroup } from './RadioGroup';
|
||||
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
||||
export { default as Navigation } from './Navigation';
|
||||
export { default as SelectDropdown } from './SelectDropdown';
|
||||
export type { SelectOption } from './SelectDropdown';
|
||||
|
||||
@@ -16,7 +16,7 @@ const envSchema = z.object({
|
||||
// parse and validate env at module load, fail fast with descriptive errors
|
||||
const parseEnv = (): Env => {
|
||||
const result = envSchema.safeParse({
|
||||
STORE_MODE: process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE,
|
||||
STORE_MODE: process.env.STORE_MODE,
|
||||
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Hotel {
|
||||
checkOut: string;
|
||||
dateIndex: number;
|
||||
amenities: string[];
|
||||
refundable: boolean;
|
||||
pricePerNight: number;
|
||||
nights: number;
|
||||
}
|
||||
@@ -29,37 +30,19 @@ const EPOCH = new Date(0);
|
||||
|
||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
||||
const { id, room_type, date_index, metadata } = p;
|
||||
|
||||
// DB stores date_index as days since epoch
|
||||
// but if value is small (<1000), treat as days from today for backward compat
|
||||
let checkIn: Date;
|
||||
if (date_index < 1000) {
|
||||
// legacy: treat as offset from today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
checkIn = new Date(today.getTime() + date_index * 86400000);
|
||||
} else {
|
||||
// proper: days since epoch
|
||||
checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
||||
}
|
||||
|
||||
const checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
||||
const nights = 1;
|
||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
||||
|
||||
const formatOpts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: checkIn.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
name: metadata?.name || room_type,
|
||||
roomType: room_type,
|
||||
checkIn: checkIn.toLocaleDateString('en-US', formatOpts),
|
||||
checkOut: checkOut.toLocaleDateString('en-US', formatOpts),
|
||||
checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
dateIndex: date_index,
|
||||
amenities: metadata?.amenities || [],
|
||||
refundable: metadata?.refundable || false,
|
||||
pricePerNight: metadata?.base_price || 100,
|
||||
nights,
|
||||
};
|
||||
|
||||
@@ -278,8 +278,6 @@
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-mode="airline"] .input-field:focus {
|
||||
|
||||
Reference in New Issue
Block a user