hard007ik commited on
Commit
048f186
·
1 Parent(s): 2f31737

shop manage eng phase 2 first

Browse files
.gitignore CHANGED
@@ -1,6 +1,9 @@
1
  .venv/
2
  __pycache__/
3
  *.egg-info/
4
- uv.lock
5
  .env
6
  *.pyc
 
 
 
 
 
1
  .venv/
2
  __pycache__/
3
  *.egg-info/
 
4
  .env
5
  *.pyc
6
+ *.log
7
+ data/
8
+
9
+ uv.lock
.hfignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Extra files to keep OUT of the Hugging Face Space upload.
2
+ # Used by: openenv push --exclude .hfignore
3
+ # (in addition to .gitignore)
4
+
5
+ # Local-only env-utility scripts. Useful on dev machine, not part of the Space service.
6
+ rollout_baseline.py
7
+ test_env_smoke.py
8
+
9
+ # Course material / docs we don't want to ship inside the Space.
10
+ *.pdf
11
+ *.odt
12
+
13
+ # Editor / IDE noise.
14
+ .idea/
15
+ .vscode/
16
+ .DS_Store
17
+
18
+ # Inner GitHub repo metadata — Space gets its own git history.
19
+ .git/
20
+
21
+ # Local virtualenv & secrets (also covered by .gitignore but listed here for safety).
22
+ .venv/
23
+ .env
24
+
25
+ # Build / runtime artifacts that auto-regenerate.
26
+ __pycache__/
27
+ *.egg-info/
28
+ *.pyc
29
+ *.log
30
+ data/
31
+ trl_jewelry_out/
__init__.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from .client import JewelryShopEnv
4
  from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
 
5
 
6
  __all__ = [
7
  "JewelryAction",
@@ -9,4 +10,7 @@ __all__ = [
9
  "JewelryState",
10
  "JewelryShopEnv",
11
  "PRODUCT_CATALOG",
 
 
 
12
  ]
 
2
 
3
  from .client import JewelryShopEnv
4
  from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
5
+ from .constants import GRAMS_PER_TROY_OZ, troy_oz_to_grams, grams_to_troy_oz
6
 
7
  __all__ = [
8
  "JewelryAction",
 
10
  "JewelryState",
11
  "JewelryShopEnv",
12
  "PRODUCT_CATALOG",
13
+ "GRAMS_PER_TROY_OZ",
14
+ "troy_oz_to_grams",
15
+ "grams_to_troy_oz",
16
  ]
client.py CHANGED
@@ -1,6 +1,10 @@
1
  from openenv.core.env_client import EnvClient
2
  from openenv.core.client_types import StepResult
3
- from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
 
 
 
 
4
 
5
 
6
  class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState]):
@@ -40,6 +44,19 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
40
  if action.message is not None:
41
  payload["message"] = action.message
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return payload
44
 
45
  # ── 2. UNPACK dict → typed observation (received FROM server) ───────────
@@ -47,10 +64,21 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
47
  def _parse_result(self, payload: dict) -> StepResult:
48
  obs_data = payload.get("observation", {})
49
 
 
 
 
 
 
 
 
 
 
 
 
50
  observation = JewelryObservation(
51
  # Base fields
52
  done=payload.get("done", False),
53
- reward=payload.get("reward", None),
54
 
55
  # Phase info
56
  phase=obs_data.get("phase", "market"),
@@ -58,15 +86,25 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
58
  # Finances & inventory
59
  cash=obs_data.get("cash", 1000.0),
60
  gold_oz=obs_data.get("gold_oz", 0.0),
 
61
 
62
  # Market
63
  gold_price=obs_data.get("gold_price", 0.0),
64
  gold_price_history=obs_data.get("gold_price_history", []),
65
  market_round=obs_data.get("market_round", 0),
66
- max_market_rounds=obs_data.get("max_market_rounds", 3),
 
 
 
 
 
 
 
 
67
 
68
  # Warehouse
69
  demand=obs_data.get("demand", {}),
 
70
  product_catalog=obs_data.get("product_catalog", PRODUCT_CATALOG),
71
  inventory=obs_data.get("inventory", {}),
72
 
@@ -76,13 +114,18 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
76
  current_offer=obs_data.get("current_offer", None),
77
  negotiation_round=obs_data.get("negotiation_round", 0),
78
 
 
 
 
 
 
79
  # Feedback
80
  message=obs_data.get("message", ""),
81
  )
82
 
83
  return StepResult(
84
  observation=observation,
85
- reward=payload.get("reward", None),
86
  done=payload.get("done", False),
87
  )
88
 
@@ -98,8 +141,13 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
98
  gold_price=payload.get("gold_price", 0.0),
99
  gold_price_history=payload.get("gold_price_history", []),
100
  market_round=payload.get("market_round", 0),
 
 
 
 
101
 
102
  demand=payload.get("demand", {}),
 
103
  inventory=payload.get("inventory", {}),
104
 
105
  phase=payload.get("phase", "market"),
@@ -109,4 +157,13 @@ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState])
109
  current_offer=payload.get("current_offer", 0.0),
110
  base_offer=payload.get("base_offer", 0.0),
111
  lowest_price_seen=payload.get("lowest_price_seen", 0.0),
 
 
 
 
 
 
 
 
 
112
  )
 
1
  from openenv.core.env_client import EnvClient
2
  from openenv.core.client_types import StepResult
3
+
4
+ try:
5
+ from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
6
+ except ImportError:
7
+ from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
8
 
9
 
10
  class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState]):
 
44
  if action.message is not None:
45
  payload["message"] = action.message
46
 
47
+ if action.target_price_usd is not None:
48
+ payload["target_price_usd"] = action.target_price_usd
49
+ if action.ai_confidence_pct is not None:
50
+ payload["ai_confidence_pct"] = action.ai_confidence_pct
51
+ if action.ai_reasoning is not None:
52
+ payload["ai_reasoning"] = action.ai_reasoning
53
+ if action.inventory_urgent is not None:
54
+ payload["inventory_urgent"] = action.inventory_urgent
55
+ if action.need_gold_grams is not None:
56
+ payload["need_gold_grams"] = action.need_gold_grams
57
+ if action.buy_deadline_iso is not None:
58
+ payload["buy_deadline_iso"] = action.buy_deadline_iso
59
+
60
  return payload
61
 
62
  # ── 2. UNPACK dict → typed observation (received FROM server) ───────────
 
64
  def _parse_result(self, payload: dict) -> StepResult:
65
  obs_data = payload.get("observation", {})
66
 
67
+ # Force reward and cumulative_reward to Python floats. JSON over the wire
68
+ # can deliver int (e.g. 0) which would later break formatters / training.
69
+ try:
70
+ _reward = float(payload.get("reward")) if payload.get("reward") is not None else 0.0
71
+ except (TypeError, ValueError):
72
+ _reward = 0.0
73
+ try:
74
+ _cum = float(obs_data.get("cumulative_reward", 0.0))
75
+ except (TypeError, ValueError):
76
+ _cum = 0.0
77
+
78
  observation = JewelryObservation(
79
  # Base fields
80
  done=payload.get("done", False),
81
+ reward=_reward,
82
 
83
  # Phase info
84
  phase=obs_data.get("phase", "market"),
 
86
  # Finances & inventory
87
  cash=obs_data.get("cash", 1000.0),
88
  gold_oz=obs_data.get("gold_oz", 0.0),
89
+ gold_grams=obs_data.get("gold_grams", 0.0),
90
 
91
  # Market
92
  gold_price=obs_data.get("gold_price", 0.0),
93
  gold_price_history=obs_data.get("gold_price_history", []),
94
  market_round=obs_data.get("market_round", 0),
95
+ max_market_rounds=obs_data.get("max_market_rounds", 0),
96
+ market_mode=obs_data.get("market_mode", "real"),
97
+ gold_price_source=obs_data.get("gold_price_source", ""),
98
+ inventory_urgent=obs_data.get("inventory_urgent", False),
99
+ need_gold_grams=obs_data.get("need_gold_grams", None),
100
+ buy_deadline_iso=obs_data.get("buy_deadline_iso", None),
101
+ cannot_wait=obs_data.get("cannot_wait", False),
102
+ market_reentries=obs_data.get("market_reentries", 0),
103
+ max_market_reentries=obs_data.get("max_market_reentries", 2),
104
 
105
  # Warehouse
106
  demand=obs_data.get("demand", {}),
107
+ demand_forecast=obs_data.get("demand_forecast", {}),
108
  product_catalog=obs_data.get("product_catalog", PRODUCT_CATALOG),
109
  inventory=obs_data.get("inventory", {}),
110
 
 
114
  current_offer=obs_data.get("current_offer", None),
115
  negotiation_round=obs_data.get("negotiation_round", 0),
116
 
117
+ # Per-task grading
118
+ task_id=obs_data.get("task_id", "profit_negotiator"),
119
+ weights=obs_data.get("weights", []),
120
+ cumulative_reward=_cum,
121
+
122
  # Feedback
123
  message=obs_data.get("message", ""),
124
  )
125
 
126
  return StepResult(
127
  observation=observation,
128
+ reward=_reward,
129
  done=payload.get("done", False),
130
  )
131
 
 
141
  gold_price=payload.get("gold_price", 0.0),
142
  gold_price_history=payload.get("gold_price_history", []),
143
  market_round=payload.get("market_round", 0),
144
+ max_market_rounds=payload.get("max_market_rounds", 0),
145
+ use_fifo_lots=payload.get("use_fifo_lots", False),
146
+ market_mode=payload.get("market_mode", "real"),
147
+ gold_price_source=payload.get("gold_price_source", ""),
148
 
149
  demand=payload.get("demand", {}),
150
+ demand_forecast=payload.get("demand_forecast", {}),
151
  inventory=payload.get("inventory", {}),
152
 
153
  phase=payload.get("phase", "market"),
 
157
  current_offer=payload.get("current_offer", 0.0),
158
  base_offer=payload.get("base_offer", 0.0),
159
  lowest_price_seen=payload.get("lowest_price_seen", 0.0),
160
+ inventory_urgent=payload.get("inventory_urgent", False),
161
+ need_gold_grams=payload.get("need_gold_grams", None),
162
+ buy_deadline_iso=payload.get("buy_deadline_iso", None),
163
+ market_reentries=payload.get("market_reentries", 0),
164
+ max_market_reentries=payload.get("max_market_reentries", 2),
165
+ task_id=payload.get("task_id", "profit_negotiator"),
166
+ weights=payload.get("weights", []),
167
+ cumulative_reward=payload.get("cumulative_reward", 0.0),
168
+ last_phase_emitted_reward=payload.get("last_phase_emitted_reward", 0.0),
169
  )
constants.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared physical and market constants (troy oz ↔ grams, config keys).
3
+ """
4
+ import os
5
+ from typing import Final
6
+
7
+ # Troy ounce (XAU convention in this project) to grams, per user / pricing spec
8
+ GRAMS_PER_TROY_OZ: Final[float] = 31.1035
9
+
10
+
11
+ def troy_oz_to_grams(oz: float) -> float:
12
+ return round(oz * GRAMS_PER_TROY_OZ, 6)
13
+
14
+
15
+ def grams_to_troy_oz(grams: float) -> float:
16
+ if GRAMS_PER_TROY_OZ <= 0:
17
+ return 0.0
18
+ return round(grams / GRAMS_PER_TROY_OZ, 8)
19
+
20
+
21
+ def get_market_mode() -> str:
22
+ """'real' uses live GC=F + DB; 'synthetic' uses legacy random market (for offline tests)."""
23
+ return (os.environ.get("SHOPMANAGER_MARKET_MODE", "real") or "real").lower().strip()
24
+
25
+
26
+ def get_sqlite_path() -> str:
27
+ return os.environ.get("SHOPMANAGER_SQLITE_PATH", "").strip() or ""
28
+
29
+
30
+ def default_sqlite_path() -> str:
31
+ from pathlib import Path
32
+
33
+ here = Path(__file__).resolve().parent
34
+ return str(here / "data" / "shop_manager.db")
inference.py CHANGED
@@ -16,12 +16,19 @@ from ShopManagerEng.models import JewelryAction
16
 
17
  load_dotenv()
18
 
19
- IMAGE_NAME = os.getenv("IMAGE_NAME")
20
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
21
 
22
- API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
23
- # MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
24
- MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
 
 
 
 
 
 
 
 
25
  TASK_NAME = os.getenv("JEWELRY_ENV_TASK", "jewelry-shop")
26
  BENCHMARK = os.getenv("JEWELRY_ENV_BENCHMARK", "jewelry_shop_benchmark")
27
  MAX_STEPS = 15
@@ -32,28 +39,42 @@ SUCCESS_SCORE_THRESHOLD = 0.01
32
 
33
  SYSTEM_PROMPT = textwrap.dedent(
34
  """
35
- You are an expert agent running a jewelry shop. Maximize profit across 3 phases.
36
-
37
- ## Phase 1: MARKET (buy/wait)
38
- Gold prices fluctuate ±10% each round (up to 3 rounds).
39
- - Analyze the price trend from the history.
40
- - If the price DROPPED from the previous round, it might drop further → consider waiting.
41
- - If the price ROSE or you're on the last round buy now.
42
- - Reserve enough cash for labor ($100-$300 depending on product).
43
- - Respond: "buy X.XX" (to buy X.XX oz of gold) or "wait" (to see next price).
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  ## Phase 2: WAREHOUSE (choose product)
46
- You see demand levels for each product. Pick the HIGHEST demand product
47
- that you can afford to craft (enough gold + cash for labor).
 
48
  Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
49
- - Respond: "ring", "necklace", or "bracelet"
 
 
 
50
 
51
  ## Phase 3: SHOWROOM (negotiate)
52
- A customer offers a price. Your goal is to sell at maximum profit.
53
- - Counter-offer to drive the price up (customer raises 5% each round, max 5 rounds).
54
- - Accept when the offer is good (round >= 3 or offer > 1.3× cost).
55
- - NEVER reject.
56
- - Respond: "I accept" or a counter like "How about $X?"
57
 
58
  CRITICAL: Respond with ONLY the action value. No explanations.
59
  """
@@ -94,7 +115,10 @@ def build_user_prompt(step: int, obs, last_reward: float, history: List[str]) ->
94
  else:
95
  trend = "RISING ↑ (buy now before it gets more expensive)"
96
 
97
- rounds_left = obs.max_market_rounds - obs.market_round
 
 
 
98
  # Suggest buy quantity that reserves $300 for labor (max labor cost)
99
  reserve = 300.0
100
  if obs.gold_price > 0:
@@ -104,20 +128,27 @@ def build_user_prompt(step: int, obs, last_reward: float, history: List[str]) ->
104
  else:
105
  suggested_qty = 1.0
106
 
 
107
  phase_hint = (
 
108
  f"Price history: {prices}. Trend: {trend}. "
109
- f"Rounds left: {rounds_left}. "
 
110
  f"If buying, suggested qty: {suggested_qty} oz (reserves $300 for labor). "
111
  f"Respond: 'buy {suggested_qty}' or 'wait'"
112
  )
113
 
114
  elif obs.phase == "warehouse":
115
  demand = obs.demand
 
116
  best_product = max(demand, key=demand.get) if demand else "ring"
117
  phase_hint = (
118
- f"Demand: ring={demand.get('ring', 0):.0%}, "
119
  f"necklace={demand.get('necklace', 0):.0%}, "
120
  f"bracelet={demand.get('bracelet', 0):.0%}. "
 
 
 
121
  f"Highest demand: {best_product}. "
122
  f"You have {obs.gold_oz}oz gold and ${obs.cash} cash. "
123
  f"Respond with EXACTLY: {best_product}"
@@ -253,7 +284,8 @@ async def run_episode(client: OpenAI, task_name: str, env_name: str, base_url: s
253
  try:
254
  env = JewelryShopEnv(base_url=base_url)
255
 
256
- result = await env.reset()
 
257
  obs = result.observation
258
  last_reward = 0.0
259
 
@@ -281,11 +313,9 @@ async def run_episode(client: OpenAI, task_name: str, env_name: str, base_url: s
281
  if done:
282
  break
283
 
284
- if rewards:
285
- score = rewards[-1]
286
- else:
287
- score = 0.0
288
-
289
  score = min(max(score, 0.0), 1.0)
290
  success = score >= SUCCESS_SCORE_THRESHOLD
291
 
@@ -310,13 +340,15 @@ TASKS = [
310
 
311
  async def main() -> None:
312
  client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
313
- # Resolve server URL: evaluator env var → IMAGE_NAME → HF Space → localhost
314
- base_url = os.getenv("ENV_BASE_URL")
315
- if not base_url and IMAGE_NAME:
316
- base_url = f"https://{IMAGE_NAME.replace('/', '-').replace('_', '-')}.hf.space"
317
- if not base_url:
318
- base_url = os.getenv("SPACE_URL", "https://hard007ik-shopmanagereng.hf.space")
319
- # print(f"[CONFIG] base_url={base_url}", flush=True)
 
 
320
 
321
  for task in TASKS:
322
  await run_episode(client, task["id"], task["env"], base_url)
 
16
 
17
  load_dotenv()
18
 
 
19
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
20
 
21
+ # ── LLM API ─────────────────────────────────────────────────────────────────
22
+ # HuggingFace Inference Router (needs HF_TOKEN in .env)
23
+ API_BASE_URL = "https://router.huggingface.co/v1"
24
+
25
+ # ── MODEL ───────────────────────────────────────────────────────────────────
26
+ # Pick one — comment out the other
27
+ MODEL_NAME = "meta-llama/Llama-3.3-70B-Instruct"
28
+ # MODEL_NAME = "Qwen/Qwen2.5-72B-Instruct"
29
+ # MODEL_NAME = "meta-llama/Llama-3.2-3B-Instruct"
30
+ # MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
31
+ # ─────────────────────────────────────────────────────────────────────────────
32
  TASK_NAME = os.getenv("JEWELRY_ENV_TASK", "jewelry-shop")
33
  BENCHMARK = os.getenv("JEWELRY_ENV_BENCHMARK", "jewelry_shop_benchmark")
34
  MAX_STEPS = 15
 
39
 
40
  SYSTEM_PROMPT = textwrap.dedent(
41
  """
42
+ You are an expert agent running a jewelry shop. The episode runs in 3 phases
43
+ and may loop back to MARKET if the warehouse runs out of gold. The episode
44
+ reward is the SUM of per-step partial rewards across the whole episode and
45
+ is bounded in [0, 1]. Each task weights the phases differently:
46
+ - market_timing -> phase 1 = 0.6, phase 2 = 0.2, phase 3 = 0.2
47
+ - demand_crafter -> phase 1 = 0.2, phase 2 = 0.6, phase 3 = 0.2
48
+ - profit_negotiator -> phase 1 = 0.2, phase 2 = 0.2, phase 3 = 0.6
49
+
50
+ ## Phase 1: MARKET (buy / wait)
51
+ Two modes:
52
+ - synthetic mode: gold price moves randomly each WAIT step within a round cap.
53
+ - real mode: gold price comes from a live source (yfinance: GC=F),
54
+ no round cap; WAIT just refreshes the live quote.
55
+ Coordination from the warehouse:
56
+ - inventory_urgent=True / cannot_wait=True means you MUST buy now;
57
+ WAIT will be blocked. Submit "buy X.XX" with an affordable troy-oz qty.
58
+ Behavior:
59
+ - If you can wait, observe the price trend in gold_price_history before buying.
60
+ - Reserve cash for labor (ring=$200, necklace=$300, bracelet=$100).
61
+ - Respond: "buy X.XX" (troy oz of gold) or "wait".
62
 
63
  ## Phase 2: WAREHOUSE (choose product)
64
+ You see two demand fields:
65
+ - demand : the TRUE per-product demand for THIS episode (ground truth).
66
+ - demand_forecast : a NOISY signal you can also lean on for planning.
67
  Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
68
+ If you don't have enough gold to craft your choice, the env may BOUNCE you back
69
+ to MARKET to buy more (up to max_market_reentries times). After max bounces or
70
+ when truly broke, the customer leaves and the episode ends.
71
+ Respond: "ring", "necklace", or "bracelet".
72
 
73
  ## Phase 3: SHOWROOM (negotiate)
74
+ The customer makes an offer; if you counter, they raise it ~5% per round,
75
+ up to 5 rounds. After 5 rounds with no acceptance, the customer leaves
76
+ (no phase-3 reward). Reject also gives 0 phase-3 reward.
77
+ Respond: "I accept" or a counter like "How about $X?". NEVER explicitly reject.
 
78
 
79
  CRITICAL: Respond with ONLY the action value. No explanations.
80
  """
 
115
  else:
116
  trend = "RISING ↑ (buy now before it gets more expensive)"
117
 
118
+ if getattr(obs, "cannot_wait", False):
119
+ trend = "URGENT: inventory needs gold now — you cannot wait; buy at the current live quote with an affordable gold_qty (troy oz)."
120
+
121
+ rounds_left = (obs.max_market_rounds - obs.market_round) if obs.max_market_rounds else None
122
  # Suggest buy quantity that reserves $300 for labor (max labor cost)
123
  reserve = 300.0
124
  if obs.gold_price > 0:
 
128
  else:
129
  suggested_qty = 1.0
130
 
131
+ _rl = "unlimited" if rounds_left is None else str(rounds_left)
132
  phase_hint = (
133
+ f"Price: ${getattr(obs, 'gold_price', 0)}/oz ({getattr(obs, 'gold_price_source', '') or 'n/a'}). "
134
  f"Price history: {prices}. Trend: {trend}. "
135
+ f"Rounds / waits so far: {getattr(obs, 'market_round', 0)}; cap: {_rl}. "
136
+ f"Gold on hand: {getattr(obs, 'gold_oz', 0)} troy oz (~{getattr(obs, 'gold_grams', 0):.2f} g). "
137
  f"If buying, suggested qty: {suggested_qty} oz (reserves $300 for labor). "
138
  f"Respond: 'buy {suggested_qty}' or 'wait'"
139
  )
140
 
141
  elif obs.phase == "warehouse":
142
  demand = obs.demand
143
+ forecast = getattr(obs, "demand_forecast", {}) or {}
144
  best_product = max(demand, key=demand.get) if demand else "ring"
145
  phase_hint = (
146
+ f"Demand (episode): ring={demand.get('ring', 0):.0%}, "
147
  f"necklace={demand.get('necklace', 0):.0%}, "
148
  f"bracelet={demand.get('bracelet', 0):.0%}. "
149
+ f"Forecast (noisy): ring={forecast.get('ring', 0):.0%}, "
150
+ f"necklace={forecast.get('necklace', 0):.0%}, "
151
+ f"bracelet={forecast.get('bracelet', 0):.0%}. "
152
  f"Highest demand: {best_product}. "
153
  f"You have {obs.gold_oz}oz gold and ${obs.cash} cash. "
154
  f"Respond with EXACTLY: {best_product}"
 
284
  try:
285
  env = JewelryShopEnv(base_url=base_url)
286
 
287
+ # Pass task_id so the env applies that task's per-phase weights.
288
+ result = await env.reset(task_id=task_name)
289
  obs = result.observation
290
  last_reward = 0.0
291
 
 
313
  if done:
314
  break
315
 
316
+ # Trajectory return = env's authoritative cumulative reward (sum of per-step
317
+ # partials, in [0, 1]). Falls back to summing locally if the field is missing.
318
+ score = float(getattr(obs, "cumulative_reward", sum(rewards) if rewards else 0.0))
 
 
319
  score = min(max(score, 0.0), 1.0)
320
  success = score >= SUCCESS_SCORE_THRESHOLD
321
 
 
340
 
341
  async def main() -> None:
342
  client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
343
+
344
+ # ── ENV SERVER URL ──────────────────────────────────────────────────────
345
+ # LOCAL: start server with `uv run --project . server`, then use localhost
346
+ # REMOTE: comment the localhost line and uncomment the HF Space line
347
+ # base_url = "http://localhost:8000"
348
+ base_url = "https://hard007ik-shopmanagereng.hf.space"
349
+ # ───────────────────────────────────────────────────────────────────────
350
+
351
+ # print(f"[CONFIG] base_url={base_url} model={MODEL_NAME}", flush=True)
352
 
353
  for task in TASKS:
354
  await run_episode(client, task["id"], task["env"], base_url)
models.py CHANGED
@@ -23,12 +23,23 @@ class JewelryAction(Action):
23
  Phase 1 (market) → market_action ("buy"/"wait") + gold_qty (oz to buy)
24
  Phase 2 (warehouse) → product_choice ("ring"/"necklace"/"bracelet")
25
  Phase 3 (showroom) → message (accept / counter / reject)
 
 
 
 
26
  """
27
  market_action: Optional[str] = None # "buy" or "wait"
28
  gold_qty: Optional[float] = None # How many oz to buy (market phase)
29
  product_choice: Optional[str] = None # "ring" / "necklace" / "bracelet"
30
  message: Optional[str] = None # Showroom negotiation text
31
 
 
 
 
 
 
 
 
32
 
33
  # ─────────────────────────────────────────────
34
  # OBSERVATION
@@ -44,12 +55,26 @@ class JewelryObservation(Observation):
44
 
45
  # Market phase
46
  gold_price: float # Current gold price ($/oz)
 
47
  gold_price_history: List[float] = [] # Last N prices for trend analysis
48
- market_round: int = 0 # Current round in market (0-indexed)
49
- max_market_rounds: int = 3 # Max rounds before forced decision
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  # Warehouse phase
52
- demand: Dict[str, float] = {} # Demand level per product (0-1)
 
53
  product_catalog: Dict[str, dict] = {} # Gold/labor costs per product
54
  inventory: Dict[str, int] = {} # Crafted products in stock
55
 
@@ -59,6 +84,11 @@ class JewelryObservation(Observation):
59
  current_offer: Optional[float] = None # Customer's live offer
60
  negotiation_round: int = 0 # Counter-offer rounds so far
61
 
 
 
 
 
 
62
  message: str = "" # Human-readable feedback
63
 
64
 
@@ -75,8 +105,10 @@ class JewelryState(State):
75
  gold_price: float = 0.0
76
  gold_price_history: List[float] = []
77
  market_round: int = 0
 
78
 
79
  demand: Dict[str, float] = {}
 
80
  inventory: Dict[str, int] = {}
81
 
82
  phase: str = "market"
@@ -85,4 +117,21 @@ class JewelryState(State):
85
  negotiation_round: int = 0
86
  current_offer: float = 0.0
87
  base_offer: float = 0.0 # Hidden from agent
88
- lowest_price_seen: float = 0.0 # For r1 scoring
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  Phase 1 (market) → market_action ("buy"/"wait") + gold_qty (oz to buy)
24
  Phase 2 (warehouse) → product_choice ("ring"/"necklace"/"bracelet")
25
  Phase 3 (showroom) → message (accept / counter / reject)
26
+
27
+ Market (optional): when logging a BUY to SQLite / invoice, the agent may send
28
+ LLM target + reasoning; when coordinating with the inventory side, it may
29
+ update urgency / need-by fields that were also set on reset.
30
  """
31
  market_action: Optional[str] = None # "buy" or "wait"
32
  gold_qty: Optional[float] = None # How many oz to buy (market phase)
33
  product_choice: Optional[str] = None # "ring" / "necklace" / "bracelet"
34
  message: Optional[str] = None # Showroom negotiation text
35
 
36
+ target_price_usd: Optional[float] = None
37
+ ai_confidence_pct: Optional[float] = None
38
+ ai_reasoning: Optional[str] = None
39
+ inventory_urgent: Optional[bool] = None
40
+ need_gold_grams: Optional[float] = None
41
+ buy_deadline_iso: Optional[str] = None
42
+
43
 
44
  # ─────────────────────────────────────────────
45
  # OBSERVATION
 
55
 
56
  # Market phase
57
  gold_price: float # Current gold price ($/oz)
58
+ gold_grams: float = 0.0 # Raw gold in inventory (grams) — troy-oz * GRAMS_PER_TROY_OZ
59
  gold_price_history: List[float] = [] # Last N prices for trend analysis
60
+ market_round: int = 0 # "Wait" count in this episode (for analytics; no cap in real mode)
61
+ max_market_rounds: int = 0 # 0 = no forced round limit (real market); >0 = synthetic only
62
+ market_mode: str = "real" # "real" | "synthetic"
63
+ gold_price_source: str = "" # e.g. yfinance:GC=F
64
+
65
+ # Inventory <-> market coordination (from reset / optional step updates)
66
+ inventory_urgent: bool = False
67
+ need_gold_grams: Optional[float] = None
68
+ buy_deadline_iso: Optional[str] = None
69
+ cannot_wait: bool = False # If urgent, "wait" action is rejected
70
+
71
+ # Inventory -> Market bounce-back (when warehouse cannot craft due to low gold)
72
+ market_reentries: int = 0 # How many times warehouse has sent us back to market
73
+ max_market_reentries: int = 2 # Cap on bounce-backs to avoid infinite loops
74
 
75
  # Warehouse phase
76
+ demand: Dict[str, float] = {} # "True" per-product demand this episode (0-1)
77
+ demand_forecast: Dict[str, float] = {} # Noisy / model-facing signal (inventory "prediction" slot)
78
  product_catalog: Dict[str, dict] = {} # Gold/labor costs per product
79
  inventory: Dict[str, int] = {} # Crafted products in stock
80
 
 
84
  current_offer: Optional[float] = None # Customer's live offer
85
  negotiation_round: int = 0 # Counter-offer rounds so far
86
 
87
+ # Per-task grading (chosen at reset() from openenv.yaml task_id)
88
+ task_id: str = "profit_negotiator"
89
+ weights: List[float] = [] # [w_market, w_warehouse, w_showroom], sums to 1.0
90
+ cumulative_reward: float = 0.0 # Running sum of per-step rewards in this episode
91
+
92
  message: str = "" # Human-readable feedback
93
 
94
 
 
105
  gold_price: float = 0.0
106
  gold_price_history: List[float] = []
107
  market_round: int = 0
108
+ max_market_rounds: int = 0 # 0 = no cap (real); >0 only in synthetic mode
109
 
110
  demand: Dict[str, float] = {}
111
+ demand_forecast: Dict[str, float] = {}
112
  inventory: Dict[str, int] = {}
113
 
114
  phase: str = "market"
 
117
  negotiation_round: int = 0
118
  current_offer: float = 0.0
119
  base_offer: float = 0.0 # Hidden from agent
120
+ lowest_price_seen: float = 0.0 # For r1 scoring
121
+
122
+ inventory_urgent: bool = False
123
+ need_gold_grams: Optional[float] = None
124
+ buy_deadline_iso: Optional[str] = None
125
+ use_fifo_lots: bool = False # If True, warehouse cost uses per-gram lots in SQLite
126
+ gold_price_source: str = ""
127
+ market_mode: str = "real"
128
+
129
+ # Inventory -> Market bounce-back loop
130
+ market_reentries: int = 0
131
+ max_market_reentries: int = 2
132
+
133
+ # Per-task grading (selected at reset)
134
+ task_id: str = "profit_negotiator"
135
+ weights: List[float] = [] # [w_market, w_warehouse, w_showroom]
136
+ cumulative_reward: float = 0.0
137
+ last_phase_emitted_reward: float = 0.0 # Reward emitted at the most recent step (debug)
openenv.yaml CHANGED
@@ -9,19 +9,20 @@ tasks:
9
  - id: market_timing
10
  name: "Market Price Analyst"
11
  description: >
12
- Analyze gold price fluctuations across up to 3 market rounds.
13
- Decide whether to buy immediately or wait for a price drop.
14
- Reserve enough cash for crafting labor ($100-$300).
 
15
  grader:
16
  type: reward_threshold
17
  threshold: 0.3
18
 
19
  - id: demand_crafter
20
- name: "Demand-Based Crafter"
21
  description: >
22
- Check warehouse demand levels for ring, necklace, and bracelet.
23
- Choose the highest-demand product you can afford to craft.
24
- Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
25
  grader:
26
  type: reward_threshold
27
  threshold: 0.2
 
9
  - id: market_timing
10
  name: "Market Price Analyst"
11
  description: >
12
+ Long-horizon Phase 1: get gold (troy oz) at a reasonable cost using live or synthetic
13
+ market quotes, coordinate with optional inventory-urgent signals, then proceed to
14
+ craft and sell. May require multiple market steps in real mode (no hard round cap);
15
+ synthetic mode supports classic round-budget runs.
16
  grader:
17
  type: reward_threshold
18
  threshold: 0.3
19
 
20
  - id: demand_crafter
21
+ name: "Demand-Based Crafter (Inventory)"
22
  description: >
23
+ Phase 2: use demand and demand_forecast, gold stock, and costs to pick which product
24
+ to craft. Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
25
+ FIFO (SQLite) pricing applies in real + DB mode for true metal cost.
26
  grader:
27
  type: reward_threshold
28
  threshold: 0.2
pyproject.toml CHANGED
@@ -18,14 +18,9 @@ dependencies = [
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
  "openenv-core[core]>=0.2.2",
21
- # Environment-specific dependencies
22
- # Add all dependencies needed for your environment here
23
- # Examples:
24
- # "numpy>=1.19.0",
25
- # "torch>=2.0.0",
26
- # "gymnasium>=0.29.0",
27
- # "openspiel>=1.0.0",
28
- # "smolagents>=1.22.0,<2",
29
  ]
30
 
31
  [project.optional-dependencies]
 
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
  "openenv-core[core]>=0.2.2",
21
+ "yfinance>=0.2.40",
22
+ "python-dotenv>=1.0.0",
23
+ "requests>=2.28.0",
 
 
 
 
 
24
  ]
25
 
26
  [project.optional-dependencies]
rollout_baseline.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ No-TRL baseline: run random vs simple heuristic policies; log mean return.
4
+ Run local server first: uv run server
5
+ Then: SHOPMANAGER_MARKET_MODE=synthetic SHOPMANAGER_TRAIN_BASE_URL=http://127.0.0.1:8000 python rollout_baseline.py
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import os
12
+ import random
13
+ import sys
14
+ from pathlib import Path
15
+ from statistics import fmean, pstdev
16
+ from typing import List, Optional
17
+
18
+ ROOT = Path(__file__).resolve().parent
19
+ if str(ROOT) not in sys.path:
20
+ sys.path.insert(0, str(ROOT))
21
+
22
+ from client import JewelryShopEnv
23
+ from models import JewelryAction, PRODUCT_CATALOG
24
+
25
+
26
+ def _heuristic_action(obs) -> JewelryAction:
27
+ ph = obs.phase
28
+ if ph == "market":
29
+ g = float(obs.gold_price or 0.0) or 1.0
30
+ need = 1.0
31
+ if obs.cash >= need * g + 10:
32
+ return JewelryAction(
33
+ market_action="buy", gold_qty=need, target_price_usd=obs.gold_price
34
+ )
35
+ return JewelryAction(market_action="wait")
36
+ if ph == "warehouse":
37
+ dem = obs.demand or {"ring": 0.5, "necklace": 0.3, "bracelet": 0.2}
38
+ for name in sorted(dem, key=lambda k: dem.get(k, 0), reverse=True):
39
+ gneed = float(PRODUCT_CATALOG[name]["gold_oz"])
40
+ lab = float(PRODUCT_CATALOG[name]["labor"])
41
+ if obs.gold_oz + 1e-9 >= gneed and obs.cash + 1e-9 >= lab:
42
+ return JewelryAction(product_choice=name)
43
+ return JewelryAction(product_choice="ring")
44
+ if ph == "showroom":
45
+ if (
46
+ obs.current_offer
47
+ and obs.cost_basis > 0
48
+ and (float(obs.current_offer) / float(obs.cost_basis)) >= 1.15
49
+ ) or (getattr(obs, "negotiation_round", 0) and int(obs.negotiation_round) >= 3):
50
+ return JewelryAction(message="I accept")
51
+ off = float(obs.current_offer or 0.0)
52
+ return JewelryAction(
53
+ message=f"How about ${off * 1.08:.2f}?" if off else "I need a better offer"
54
+ )
55
+ return JewelryAction()
56
+
57
+
58
+ def _random_action(obs) -> JewelryAction:
59
+ if obs.phase == "market":
60
+ if random.random() < 0.35:
61
+ return JewelryAction(
62
+ market_action="buy", gold_qty=round(random.uniform(0.1, 1.2), 2)
63
+ )
64
+ return JewelryAction(market_action="wait")
65
+ if obs.phase == "warehouse":
66
+ return JewelryAction(product_choice=random.choice(["ring", "necklace", "bracelet"]))
67
+ return JewelryAction(
68
+ message=random.choice(
69
+ [
70
+ "I accept",
71
+ f"How about ${float(obs.current_offer or 0) * 1.1:.0f}?",
72
+ ]
73
+ )
74
+ )
75
+
76
+
77
+ async def one_episode(base: str, policy: str, seed: Optional[int], max_steps: int) -> float:
78
+ """
79
+ Run one episode under the given policy and return the trajectory return,
80
+ which is the env's cumulative reward (sum of per-step partials, in [0, 1]).
81
+ """
82
+ if seed is not None:
83
+ random.seed(seed)
84
+ env = JewelryShopEnv(base_url=base)
85
+ r = await env.reset(seed=seed, episode_id=None)
86
+ o = r.observation
87
+ for _ in range(max_steps):
88
+ if r.done:
89
+ break
90
+ if policy == "heuristic":
91
+ a = _heuristic_action(o)
92
+ else:
93
+ a = _random_action(o)
94
+ r = await env.step(a)
95
+ o = r.observation
96
+ try:
97
+ await env.close()
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+ # Authoritative trajectory return from the server (in [0, 1]).
101
+ return float(getattr(o, "cumulative_reward", 0.0))
102
+
103
+
104
+ def main() -> None:
105
+ p = argparse.ArgumentParser()
106
+ p.add_argument("--episodes", type=int, default=20)
107
+ p.add_argument("--max-steps", type=int, default=25)
108
+ p.add_argument(
109
+ "--base-url",
110
+ default=os.environ.get("SHOPMANAGER_TRAIN_BASE_URL", "http://127.0.0.1:8000"),
111
+ )
112
+ p.add_argument("--policies", nargs="+", default=["heuristic", "random"])
113
+ p.add_argument("--out", type=Path, default=Path("rollout_metrics.txt"))
114
+ args = p.parse_args()
115
+ base = str(args.base_url)
116
+ all_lines: List[str] = [f"base_url={base}", f"episodes={args.episodes} max_steps={args.max_steps}", ""]
117
+
118
+ for name in args.policies:
119
+ scores: List[float] = []
120
+ for epi in range(args.episodes):
121
+ sc = asyncio.run(
122
+ one_episode(base, name, seed=epi, max_steps=int(args.max_steps)) # type: ignore[misc] # noqa: E501
123
+ )
124
+ scores.append(sc)
125
+ m = fmean(scores) if scores else 0.0
126
+ sd = pstdev(scores) if len(scores) > 1 else 0.0
127
+ line = f"{name}: mean={m:.4f} std={sd:.4f} scores={scores!s}"
128
+ all_lines.append(line)
129
+ print(line)
130
+ text = "\n".join(all_lines) + "\n"
131
+ try:
132
+ args.out.write_text(text, encoding="utf-8")
133
+ print(f"Wrote {args.out}", flush=True)
134
+ except OSError as err:
135
+ print("Could not write out file:", err, flush=True)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
server/ShopManagerEng_environment.py CHANGED
@@ -1,37 +1,40 @@
1
  import random
2
  import uuid
 
 
3
  from openenv.core.env_server import Environment
4
 
5
  try:
 
6
  from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
 
 
7
  except ImportError:
 
 
8
  from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
 
 
9
 
10
 
11
- # ─────────────────────────────────────────────
12
- # CONSTANTS
13
- # ─────────────────────────────────────────────
14
-
15
- STARTING_CASH = 1000.0
16
- GOLD_PRICE_MIN = 250.0
17
- GOLD_PRICE_MAX = 450.0
18
- PRICE_FLUCTUATION = 0.10 # ±10% per market round
19
- MAX_MARKET_ROUNDS = 3 # Rounds the agent can wait in market
20
- MAX_NEGOTIATION = 5 # Showroom counter-offer limit
21
- COUNTER_BUMP = 1.05 # Customer raises offer by 5% each round
22
- OFFER_MIN_RATIO = 0.80 # Customer opens at 80-130% of cost basis
23
- OFFER_MAX_RATIO = 1.30
24
- DEMAND_OFFER_BONUS = 0.20 # High demand adds up to 20% to offer
25
- MAX_PROFIT_MULT = 2.0 # Normalization ceiling for r3
26
-
27
 
28
- # ─────────────────────────────────────────────
29
- # KEYWORD DETECTION (Phase 3 — Showroom)
30
- # ─────────────────────────────────────────────
 
 
 
31
 
32
  ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
33
  REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
34
 
 
35
  def detect_intent(message: str) -> str:
36
  msg = message.lower()
37
  for kw in ACCEPT_KEYWORDS:
@@ -44,37 +47,48 @@ def detect_intent(message: str) -> str:
44
 
45
 
46
  # ─────────────────────────────────────────────
47
- # REWARD HELPERS
 
 
 
48
  # ─────────────────────────────────────────────
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def compute_r1(buy_price: float, lowest_price: float) -> float:
51
- """
52
- Phase 1 reward: did the agent buy near the lowest price seen?
53
- 1.0 if bought at the lowest, decreasing as buy price increases.
54
- """
55
  if lowest_price <= 0 or buy_price <= 0:
56
  return 0.0
57
- ratio = lowest_price / buy_price # 1.0 = perfect, <1.0 = overpaid
58
- return round(min(ratio, 1.0) * 0.5, 4)
59
 
60
 
61
  def compute_r2(product_choice: str, demand: dict) -> float:
62
- """
63
- Phase 2 reward: did the agent pick the highest-demand product?
64
- 0.5 if picked the best, proportionally less for worse choices.
65
- """
66
  if not demand or product_choice not in demand:
67
  return 0.0
68
  max_demand = max(demand.values())
69
  if max_demand <= 0:
70
  return 0.0
71
- return round((demand[product_choice] / max_demand) * 0.5, 4)
72
 
73
 
74
  def compute_r3(accepted_price: float, cost_basis: float) -> float:
75
- """
76
- Phase 3 reward: normalized profit margin on sale.
77
- """
78
  if cost_basis <= 0:
79
  return 0.0
80
  profit = accepted_price - cost_basis
@@ -84,47 +98,235 @@ def compute_r3(accepted_price: float, cost_basis: float) -> float:
84
  return round(min(profit / max_profit, 1.0), 4)
85
 
86
 
87
- def combined_reward(r1: float, r2: float, r3: float) -> float:
88
- """Weighted combination: showroom dominates."""
89
- return round((0.2 * r1) + (0.2 * r2) + (0.6 * r3), 4)
 
 
 
 
 
 
 
 
 
 
90
 
91
 
92
- # ─────────────────────────────────────────────
93
- # ENVIRONMENT
94
- # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
95
 
96
  class JewelryShopEnvironment(Environment):
97
  SUPPORTS_CONCURRENT_SESSIONS = True
98
 
99
  def __init__(self):
100
  self._state = JewelryState()
 
101
  self._r1 = 0.0
102
  self._r2 = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
- # ── RESET ──────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
107
  if seed is not None:
108
  random.seed(seed)
109
-
110
- gold_price = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
111
-
112
- # Randomize demand levels for this episode
113
- demand = {
114
- "ring": round(random.uniform(0.4, 1.0), 2),
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  "necklace": round(random.uniform(0.2, 0.8), 2),
116
  "bracelet": round(random.uniform(0.1, 0.6), 2),
117
  }
118
-
119
- self._state = JewelryState(
120
- episode_id=episode_id or str(uuid.uuid4()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  step_count=0,
122
- cash=STARTING_CASH,
123
  gold_oz=0.0,
124
- gold_price=gold_price,
125
- gold_price_history=[gold_price],
126
  market_round=0,
127
- demand=demand,
 
 
128
  inventory={"ring": 0, "necklace": 0, "bracelet": 0},
129
  phase="market",
130
  product_for_sale=None,
@@ -132,379 +334,371 @@ class JewelryShopEnvironment(Environment):
132
  negotiation_round=0,
133
  current_offer=0.0,
134
  base_offer=0.0,
135
- lowest_price_seen=gold_price,
 
 
 
 
 
 
 
 
 
 
 
 
136
  )
137
  self._r1 = 0.0
138
  self._r2 = 0.0
139
-
140
- return JewelryObservation(
141
- done=False,
142
- reward=None,
143
- phase="market",
144
- cash=STARTING_CASH,
145
- gold_oz=0.0,
146
- gold_price=gold_price,
147
- gold_price_history=[gold_price],
148
- market_round=0,
149
- max_market_rounds=MAX_MARKET_ROUNDS,
150
- demand=demand,
151
- product_catalog=PRODUCT_CATALOG,
152
- inventory={"ring": 0, "necklace": 0, "bracelet": 0},
153
- product_for_sale=None,
154
- cost_basis=0.0,
155
- current_offer=None,
156
- negotiation_round=0,
157
- message=(
158
- f"Welcome to the Jewelry Shop! Today's gold price is ${gold_price}/oz. "
159
- f"You have ${STARTING_CASH}. You can 'buy' gold or 'wait' for a better price. "
160
- f"Market rounds remaining: {MAX_MARKET_ROUNDS}."
161
  ),
162
  )
163
-
164
- # ── STEP ───────────────────────────────────
165
 
166
  def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
167
  self._state.step_count += 1
168
- phase = self._state.phase
169
-
170
- if phase == "market":
171
  return self._step_market(action)
172
- elif phase == "warehouse":
173
  return self._step_warehouse(action)
174
- elif phase == "showroom":
175
  return self._step_showroom(action)
176
- else:
177
- raise ValueError(f"Unknown phase: {phase}")
178
 
179
- # ── PHASE 1: MARKET ────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  def _step_market(self, action: JewelryAction) -> JewelryObservation:
182
  s = self._state
183
  market_action = (action.market_action or "wait").lower().strip()
184
 
185
- if market_action == "buy":
186
- gold_qty = action.gold_qty or 0.0
187
- total_cost = gold_qty * s.gold_price
188
-
189
- if gold_qty <= 0 or total_cost > s.cash:
190
- # Failed transaction — stay in market
191
- return JewelryObservation(
192
- done=False,
193
- reward=0.0,
194
- phase="market",
195
- cash=s.cash,
196
- gold_oz=s.gold_oz,
197
- gold_price=s.gold_price,
198
- gold_price_history=list(s.gold_price_history),
199
- market_round=s.market_round,
200
- max_market_rounds=MAX_MARKET_ROUNDS,
201
- demand=s.demand,
202
- product_catalog=PRODUCT_CATALOG,
203
- inventory=s.inventory,
204
- message=(
205
- f"Transaction failed. Tried to buy {gold_qty}oz "
206
- f"(${total_cost:.2f}) but you have ${s.cash:.2f}. "
207
- f"Try a smaller quantity or wait."
208
- ),
209
- )
210
-
211
- # Successful buy
212
- s.cash -= total_cost
213
- s.gold_oz += gold_qty
214
- self._r1 = compute_r1(s.gold_price, s.lowest_price_seen)
215
 
216
- # Advance to warehouse
 
 
 
 
 
217
  s.phase = "warehouse"
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
- return JewelryObservation(
220
- done=False,
221
- reward=self._r1,
222
- phase="warehouse",
223
- cash=s.cash,
224
- gold_oz=s.gold_oz,
225
- gold_price=s.gold_price,
226
- gold_price_history=list(s.gold_price_history),
227
- market_round=s.market_round,
228
- max_market_rounds=MAX_MARKET_ROUNDS,
229
- demand=s.demand,
230
- product_catalog=PRODUCT_CATALOG,
231
- inventory=s.inventory,
232
- message=(
233
- f"Bought {gold_qty}oz of gold at ${s.gold_price}/oz "
234
- f"for ${total_cost:.2f}. Cash remaining: ${s.cash:.2f}. "
235
- f"Now check your warehouse. Which product to craft? "
236
- f"Options: ring (1oz gold + $200), necklace (2oz + $300), bracelet (0.5oz + $100)."
237
- ),
238
- )
239
-
240
- else:
241
- # Agent chose to WAIT — advance market round
242
- s.market_round += 1
243
 
244
- if s.market_round >= MAX_MARKET_ROUNDS:
245
- # Forced to buy at current price or skip to warehouse with no gold
246
- s.phase = "warehouse"
247
- self._r1 = 0.0
248
- return JewelryObservation(
249
- done=False,
250
- reward=0.0,
251
- phase="warehouse",
252
- cash=s.cash,
253
- gold_oz=s.gold_oz,
254
- gold_price=s.gold_price,
255
- gold_price_history=list(s.gold_price_history),
256
- market_round=s.market_round,
257
- max_market_rounds=MAX_MARKET_ROUNDS,
258
- demand=s.demand,
259
- product_catalog=PRODUCT_CATALOG,
260
- inventory=s.inventory,
261
- message=(
262
- f"Market closed! You waited too long and didn't buy any gold. "
263
- f"Entering warehouse with {s.gold_oz}oz gold and ${s.cash} cash."
264
- ),
265
  )
266
-
267
- # Price fluctuates ±10%
268
- change = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
269
- new_price = round(s.gold_price * (1 + change), 2)
270
- new_price = max(new_price, 50.0) # Floor price
271
- s.gold_price = new_price
272
- s.gold_price_history.append(new_price)
273
- s.lowest_price_seen = min(s.lowest_price_seen, new_price)
274
-
275
- trend = "↑" if change > 0 else "↓"
276
- return JewelryObservation(
277
- done=False,
278
- reward=0.0,
279
- phase="market",
280
- cash=s.cash,
281
- gold_oz=s.gold_oz,
282
- gold_price=new_price,
283
- gold_price_history=list(s.gold_price_history),
284
- market_round=s.market_round,
285
- max_market_rounds=MAX_MARKET_ROUNDS,
286
- demand=s.demand,
287
- product_catalog=PRODUCT_CATALOG,
288
- inventory=s.inventory,
289
- message=(
290
- f"You waited. Gold price moved {trend} to ${new_price}/oz. "
291
- f"Price history: {s.gold_price_history}. "
292
- f"Rounds left: {MAX_MARKET_ROUNDS - s.market_round}. "
293
- f"Buy now or wait?"
294
- ),
295
  )
 
 
296
 
297
- # ── PHASE 2: WAREHOUSE ─────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
  def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
300
  s = self._state
301
  choice = (action.product_choice or "ring").lower().strip()
302
-
303
  if choice not in PRODUCT_CATALOG:
304
- choice = "ring" # Default fallback
305
-
306
  spec = PRODUCT_CATALOG[choice]
307
- gold_needed = spec["gold_oz"]
308
  labor_cost = spec["labor"]
309
-
310
- has_gold = s.gold_oz >= gold_needed
311
- has_cash = s.cash >= labor_cost
312
-
313
- if not has_gold or not has_cash:
314
- # Cannot craft — skip to showroom with nothing
 
 
 
 
 
 
 
 
 
315
  self._r2 = 0.0
316
  s.phase = "showroom"
317
- reason = (
318
- f"not enough gold (need {gold_needed}oz, have {s.gold_oz}oz)"
319
- if not has_gold else
320
- f"not enough cash for labor (need ${labor_cost}, have ${s.cash:.2f})"
 
321
  )
322
- return JewelryObservation(
323
- done=False,
324
- reward=0.0,
325
- phase="showroom",
326
- cash=s.cash,
327
- gold_oz=s.gold_oz,
328
- gold_price=s.gold_price,
329
- gold_price_history=list(s.gold_price_history),
330
- demand=s.demand,
331
- product_catalog=PRODUCT_CATALOG,
332
- inventory=s.inventory,
333
- product_for_sale=None,
334
- cost_basis=0.0,
335
- message=f"Cannot craft {choice}: {reason}. Entering showroom with nothing.",
336
  )
 
 
 
 
337
 
338
- # Successful craft
339
  s.cash -= labor_cost
340
- s.gold_oz -= gold_needed
341
- s.inventory[choice] = s.inventory.get(choice, 0) + 1
342
- s.cost_basis = s.gold_price * gold_needed + labor_cost
343
- s.product_for_sale = choice
344
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  self._r2 = compute_r2(choice, s.demand)
346
-
347
- # Generate customer offer based on demand and cost basis
348
- demand_factor = s.demand.get(choice, 0.5)
349
- offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (demand_factor * DEMAND_OFFER_BONUS)
350
- base_offer = round(s.cost_basis * offer_ratio, 2)
351
- s.base_offer = base_offer
352
- s.current_offer = base_offer
353
  s.phase = "showroom"
354
  s.negotiation_round = 0
355
-
356
- return JewelryObservation(
357
- done=False,
358
- reward=self._r2,
359
- phase="showroom",
360
- cash=s.cash,
361
- gold_oz=s.gold_oz,
362
- gold_price=s.gold_price,
363
- gold_price_history=list(s.gold_price_history),
364
- demand=s.demand,
365
- product_catalog=PRODUCT_CATALOG,
366
- inventory=s.inventory,
367
- product_for_sale=choice,
368
- cost_basis=s.cost_basis,
369
- current_offer=s.current_offer,
370
- negotiation_round=0,
371
- message=(
372
- f"Crafted a {choice}! Cost basis: ${s.cost_basis:.2f} "
373
- f"(gold ${s.gold_price * gold_needed:.2f} + labor ${labor_cost}). "
374
- f"Demand for {choice}: {demand_factor:.0%}. "
375
- f"A customer offers ${s.current_offer:.2f}. Accept, counter, or reject?"
376
- ),
377
  )
378
-
379
- # ── PHASE 3: SHOWROOM ──────────────────────
 
 
 
 
380
 
381
  def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
382
  s = self._state
383
-
384
- # No product → episode ends immediately
385
  if s.product_for_sale is None:
386
- return JewelryObservation(
387
- done=True,
388
- reward=combined_reward(self._r1, self._r2, 0.0),
389
- phase="showroom",
390
- cash=s.cash,
391
- gold_oz=s.gold_oz,
392
- gold_price=s.gold_price,
393
- demand=s.demand,
394
- product_catalog=PRODUCT_CATALOG,
395
- inventory=s.inventory,
396
- product_for_sale=None,
397
- cost_basis=0.0,
398
- message="No products to sell. Episode over.",
399
  )
400
-
 
 
401
  message = action.message or ""
402
  intent = detect_intent(message)
403
-
404
- # ── ACCEPT ──
405
  if intent == "accept":
406
- r3 = compute_r3(s.current_offer, s.cost_basis)
407
- final_reward = combined_reward(self._r1, self._r2, r3)
408
  s.cash += s.current_offer
409
  s.inventory[s.product_for_sale] -= 1
410
- product_sold = s.product_for_sale
411
  s.product_for_sale = None
412
-
413
- return JewelryObservation(
414
- done=True,
415
- reward=final_reward,
416
- phase="showroom",
417
- cash=s.cash,
418
- gold_oz=s.gold_oz,
419
- gold_price=s.gold_price,
420
- demand=s.demand,
421
- product_catalog=PRODUCT_CATALOG,
422
- inventory=s.inventory,
423
- product_for_sale=None,
424
- cost_basis=s.cost_basis,
425
- current_offer=s.current_offer,
426
- negotiation_round=s.negotiation_round,
427
- message=(
428
- f"Deal! Sold {product_sold} for ${s.current_offer:.2f}. "
429
- f"Profit: ${s.current_offer - s.cost_basis:.2f}. "
430
- f"Final reward: {final_reward}."
431
- ),
432
  )
433
-
434
- # ── REJECT ──
 
435
  if intent == "reject":
436
- final_reward = combined_reward(self._r1, self._r2, 0.0)
437
- return JewelryObservation(
438
- done=True,
439
- reward=final_reward,
440
- phase="showroom",
441
- cash=s.cash,
442
- gold_oz=s.gold_oz,
443
- gold_price=s.gold_price,
444
- demand=s.demand,
445
- product_catalog=PRODUCT_CATALOG,
446
- inventory=s.inventory,
447
- product_for_sale=s.product_for_sale,
448
- cost_basis=s.cost_basis,
449
- current_offer=s.current_offer,
450
- negotiation_round=s.negotiation_round,
451
- message=(
452
- f"You rejected the offer. Customer left. "
453
- f"Final reward: {final_reward}."
454
- ),
455
  )
456
-
457
- # ── COUNTER ──
 
458
  s.negotiation_round += 1
459
-
460
  if s.negotiation_round >= MAX_NEGOTIATION:
461
- final_reward = combined_reward(self._r1, self._r2, 0.0)
462
- return JewelryObservation(
463
- done=True,
464
- reward=final_reward,
465
- phase="showroom",
466
- cash=s.cash,
467
- gold_oz=s.gold_oz,
468
- gold_price=s.gold_price,
469
- demand=s.demand,
470
- product_catalog=PRODUCT_CATALOG,
471
- inventory=s.inventory,
472
- product_for_sale=s.product_for_sale,
473
- cost_basis=s.cost_basis,
474
- current_offer=s.current_offer,
475
- negotiation_round=s.negotiation_round,
476
- message=(
477
- f"Customer left after {MAX_NEGOTIATION} rounds. "
478
- f"Final reward: {final_reward}."
479
- ),
480
  )
481
-
482
- # Customer raises offer by 5%
483
  s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
484
-
485
- return JewelryObservation(
486
- done=False,
487
- reward=0.0,
488
- phase="showroom",
489
- cash=s.cash,
490
- gold_oz=s.gold_oz,
491
- gold_price=s.gold_price,
492
- demand=s.demand,
493
- product_catalog=PRODUCT_CATALOG,
494
- inventory=s.inventory,
495
- product_for_sale=s.product_for_sale,
496
- cost_basis=s.cost_basis,
497
- current_offer=s.current_offer,
498
- negotiation_round=s.negotiation_round,
499
- message=(
500
- f"Customer raises to ${s.current_offer:.2f} "
501
- f"(round {s.negotiation_round}/{MAX_NEGOTIATION}). "
502
- f"Accept, counter, or reject?"
503
- ),
504
- )
505
-
506
- # ── STATE PROPERTY ─────────────────────────
507
 
508
  @property
509
  def state(self) -> JewelryState:
510
- return self._state
 
1
  import random
2
  import uuid
3
+ from typing import Optional
4
+
5
  from openenv.core.env_server import Environment
6
 
7
  try:
8
+ from ..constants import get_market_mode, troy_oz_to_grams
9
  from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
10
+ from .market_data import last_quote_or_fallback, fetch_gold_spot_usd_per_oz
11
+ from . import sqlite_store
12
  except ImportError:
13
+ # Installed: ShopManagerEng.* — otherwise dev layout: CWD=ShopManagerEng, `import server` (siblings: models, constants)
14
+ from constants import get_market_mode, troy_oz_to_grams
15
  from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
16
+ from server.market_data import last_quote_or_fallback, fetch_gold_spot_usd_per_oz
17
+ from server import sqlite_store
18
 
19
 
20
+ # Legacy synthetic market (used when SHOPMANAGER_MARKET_MODE=synthetic)
21
+ STARTING_CASH = 10000.0
22
+ GOLD_PRICE_MIN = 250.0
23
+ GOLD_PRICE_MAX = 450.0
24
+ PRICE_FLUCTUATION = 0.10
25
+ MAX_MARKET_ROUNDS = 3
 
 
 
 
 
 
 
 
 
 
26
 
27
+ MAX_NEGOTIATION = 5
28
+ COUNTER_BUMP = 1.05
29
+ OFFER_MIN_RATIO = 0.80
30
+ OFFER_MAX_RATIO = 1.30
31
+ DEMAND_OFFER_BONUS = 0.20
32
+ MAX_PROFIT_MULT = 2.0
33
 
34
  ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
35
  REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
36
 
37
+
38
  def detect_intent(message: str) -> str:
39
  msg = message.lower()
40
  for kw in ACCEPT_KEYWORDS:
 
47
 
48
 
49
  # ─────────────────────────────────────────────
50
+ # REWARD MODEL
51
+ # All r1/r2/r3 are normalized to [0, 1].
52
+ # Each step emits a WEIGHTED PARTIAL reward.
53
+ # Sum of every step's reward over an episode is in [0, 1].
54
  # ─────────────────────────────────────────────
55
 
56
+ # Per-task phase weights (w_market, w_warehouse, w_showroom). Each row sums to 1.0.
57
+ TASK_WEIGHTS = {
58
+ "market_timing": (0.6, 0.2, 0.2), # Phase 1 dominates
59
+ "demand_crafter": (0.2, 0.6, 0.2), # Phase 2 dominates
60
+ "profit_negotiator": (0.2, 0.2, 0.6), # Phase 3 dominates; phases 1 & 2 weighted equally
61
+ }
62
+ DEFAULT_TASK_ID = "profit_negotiator"
63
+
64
+
65
+ def resolve_weights(task_id: Optional[str]) -> tuple:
66
+ tid = (task_id or DEFAULT_TASK_ID).lower().strip()
67
+ if tid not in TASK_WEIGHTS:
68
+ tid = DEFAULT_TASK_ID
69
+ return TASK_WEIGHTS[tid]
70
+
71
+
72
  def compute_r1(buy_price: float, lowest_price: float) -> float:
73
+ """Phase 1 score in [0, 1]. 1.0 == bought at lowest seen price."""
 
 
 
74
  if lowest_price <= 0 or buy_price <= 0:
75
  return 0.0
76
+ ratio = lowest_price / buy_price
77
+ return round(min(ratio, 1.0), 4)
78
 
79
 
80
  def compute_r2(product_choice: str, demand: dict) -> float:
81
+ """Phase 2 score in [0, 1]. 1.0 == picked the most-demanded product."""
 
 
 
82
  if not demand or product_choice not in demand:
83
  return 0.0
84
  max_demand = max(demand.values())
85
  if max_demand <= 0:
86
  return 0.0
87
+ return round(demand[product_choice] / max_demand, 4)
88
 
89
 
90
  def compute_r3(accepted_price: float, cost_basis: float) -> float:
91
+ """Phase 3 score in [0, 1]. 1.0 == hit the max profit multiple."""
 
 
92
  if cost_basis <= 0:
93
  return 0.0
94
  profit = accepted_price - cost_basis
 
98
  return round(min(profit / max_profit, 1.0), 4)
99
 
100
 
101
+ def step_reward(weights: tuple, phase_emitted: str, r_value: float) -> float:
102
+ """
103
+ Convert a normalized phase score (in [0, 1]) into the WEIGHTED partial
104
+ reward emitted at that step. Summing these across an episode is in [0, 1].
105
+ Guaranteed to return a Python float (never int / never None).
106
+ """
107
+ if phase_emitted == "market":
108
+ return float(round(float(weights[0]) * float(r_value), 4))
109
+ if phase_emitted == "warehouse":
110
+ return float(round(float(weights[1]) * float(r_value), 4))
111
+ if phase_emitted == "showroom":
112
+ return float(round(float(weights[2]) * float(r_value), 4))
113
+ return 0.0
114
 
115
 
116
+ def _demand_forecast_from(demand: dict) -> dict:
117
+ """
118
+ Noisy "forecast" for the inventory agent to plan against (same scale as demand).
119
+ Deterministic w.r.t. the RNG in reset(seed=...) on the current episode.
120
+ """
121
+ out: dict = {}
122
+ for k, v in demand.items():
123
+ wiggle = random.uniform(-0.12, 0.12)
124
+ out[k] = round(max(0.0, min(1.0, float(v) + wiggle)), 2)
125
+ return out
126
+
127
 
128
  class JewelryShopEnvironment(Environment):
129
  SUPPORTS_CONCURRENT_SESSIONS = True
130
 
131
  def __init__(self):
132
  self._state = JewelryState()
133
+ # Normalized per-phase scores in [0, 1] (raw, before weighting)
134
  self._r1 = 0.0
135
  self._r2 = 0.0
136
+ self._r3 = 0.0
137
+
138
+ def _emit(self, phase_emitted: str, r_value: float) -> float:
139
+ """
140
+ Convert a normalized phase score into the per-step weighted reward,
141
+ update cumulative bookkeeping, and return the value to attach to obs.
142
+ Guaranteed: returned value AND s.cumulative_reward are Python floats.
143
+ """
144
+ s = self._state
145
+ weights = tuple(s.weights) if s.weights else resolve_weights(s.task_id)
146
+ partial = float(step_reward(weights, phase_emitted, r_value))
147
+ s.cumulative_reward = float(round(float(s.cumulative_reward) + partial, 4))
148
+ s.last_phase_emitted_reward = partial
149
+ return partial
150
+
151
+ def _apply_action_inventory_fields(self, action: JewelryAction) -> None:
152
+ s = self._state
153
+ if action.inventory_urgent is not None:
154
+ s.inventory_urgent = bool(action.inventory_urgent)
155
+ if action.need_gold_grams is not None:
156
+ s.need_gold_grams = action.need_gold_grams
157
+ if action.buy_deadline_iso is not None:
158
+ s.buy_deadline_iso = action.buy_deadline_iso
159
+
160
+ def _mm_line(self) -> str:
161
+ s = self._state
162
+ if s.market_mode == "synthetic" and s.max_market_rounds and s.max_market_rounds > 0:
163
+ return f"Market simulation rounds in this phase: {s.max_market_rounds - s.market_round} (of {s.max_market_rounds})."
164
+ if s.max_market_rounds == 0 or s.max_market_rounds is None:
165
+ return "No round limit: wait to refresh the quote; buy when ready."
166
+ return f"Rounds left: {max(0, s.max_market_rounds - s.market_round)}."
167
+
168
+ def _co_market(
169
+ self,
170
+ *,
171
+ done: bool = False,
172
+ reward: float = 0.0,
173
+ msg: str = "",
174
+ keep_phase: Optional[str] = None,
175
+ ) -> dict:
176
+ s = self._state
177
+ ph = keep_phase or s.phase
178
+ max_r = s.max_market_rounds
179
+ g_oz = s.gold_oz
180
+ # Always emit reward as a Python float so it survives JSON serialization
181
+ # as a JSON number with a decimal point (e.g. 0.0, not 0).
182
+ try:
183
+ reward_f = float(reward) if reward is not None else 0.0
184
+ except (TypeError, ValueError):
185
+ reward_f = 0.0
186
+ return dict(
187
+ done=done,
188
+ reward=reward_f,
189
+ phase=ph,
190
+ cash=s.cash,
191
+ gold_oz=g_oz,
192
+ gold_grams=round(troy_oz_to_grams(g_oz), 4),
193
+ gold_price=s.gold_price,
194
+ gold_price_history=list(s.gold_price_history),
195
+ market_round=s.market_round,
196
+ max_market_rounds=max_r,
197
+ market_mode=s.market_mode,
198
+ gold_price_source=s.gold_price_source,
199
+ inventory_urgent=s.inventory_urgent,
200
+ need_gold_grams=s.need_gold_grams,
201
+ buy_deadline_iso=s.buy_deadline_iso,
202
+ cannot_wait=s.inventory_urgent and ph == "market",
203
+ market_reentries=s.market_reentries,
204
+ max_market_reentries=s.max_market_reentries,
205
+ demand=s.demand,
206
+ demand_forecast=getattr(s, "demand_forecast", {}) or {},
207
+ product_catalog=PRODUCT_CATALOG,
208
+ inventory=s.inventory,
209
+ product_for_sale=None if ph == "market" else s.product_for_sale,
210
+ cost_basis=s.cost_basis if ph != "market" else 0.0,
211
+ current_offer=None if ph == "market" else s.current_offer,
212
+ negotiation_round=s.negotiation_round,
213
+ task_id=s.task_id,
214
+ weights=list(s.weights) if s.weights else list(resolve_weights(s.task_id)),
215
+ cumulative_reward=float(s.cumulative_reward),
216
+ message=msg,
217
+ )
218
 
219
+ def _obs_from(self, o: dict) -> JewelryObservation:
220
+ try:
221
+ _r = float(o.get("reward", 0.0)) if o.get("reward", 0.0) is not None else 0.0
222
+ except (TypeError, ValueError):
223
+ _r = 0.0
224
+ try:
225
+ _cr = float(o.get("cumulative_reward", 0.0))
226
+ except (TypeError, ValueError):
227
+ _cr = 0.0
228
+ return JewelryObservation(
229
+ done=o.get("done", False),
230
+ reward=_r,
231
+ phase=o.get("phase", "market"),
232
+ cash=o.get("cash", 1000.0),
233
+ gold_oz=o.get("gold_oz", 0.0),
234
+ gold_grams=o.get("gold_grams", 0.0),
235
+ gold_price=o.get("gold_price", 0.0),
236
+ gold_price_history=o.get("gold_price_history", []),
237
+ market_round=o.get("market_round", 0),
238
+ max_market_rounds=o.get("max_market_rounds", 0),
239
+ market_mode=o.get("market_mode", "real"),
240
+ gold_price_source=o.get("gold_price_source", ""),
241
+ inventory_urgent=o.get("inventory_urgent", False),
242
+ need_gold_grams=o.get("need_gold_grams", None),
243
+ buy_deadline_iso=o.get("buy_deadline_iso", None),
244
+ cannot_wait=o.get("cannot_wait", False),
245
+ market_reentries=o.get("market_reentries", 0),
246
+ max_market_reentries=o.get("max_market_reentries", 2),
247
+ demand=o.get("demand", {}),
248
+ demand_forecast=o.get("demand_forecast", {}),
249
+ product_catalog=o.get("product_catalog", PRODUCT_CATALOG),
250
+ inventory=o.get("inventory", {}),
251
+ product_for_sale=o.get("product_for_sale", None),
252
+ cost_basis=o.get("cost_basis", 0.0),
253
+ current_offer=o.get("current_offer", None),
254
+ negotiation_round=o.get("negotiation_round", 0),
255
+ task_id=o.get("task_id", DEFAULT_TASK_ID),
256
+ weights=o.get("weights", list(resolve_weights(DEFAULT_TASK_ID))),
257
+ cumulative_reward=_cr,
258
+ message=o.get("message", ""),
259
+ )
260
 
261
  def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
262
  if seed is not None:
263
  random.seed(seed)
264
+ eid = episode_id or str(uuid.uuid4())
265
+ try:
266
+ starting_cash = float(kwargs.get("starting_cash", STARTING_CASH))
267
+ except (TypeError, ValueError):
268
+ starting_cash = STARTING_CASH
269
+
270
+ inv_urgent = bool(kwargs.get("inventory_urgent", False))
271
+ need_g = kwargs.get("need_gold_grams", None)
272
+ if need_g is not None:
273
+ try:
274
+ need_g = float(need_g)
275
+ except (TypeError, ValueError):
276
+ need_g = None
277
+ deadline = kwargs.get("buy_deadline_iso", None)
278
+ if deadline is not None and not isinstance(deadline, str):
279
+ deadline = str(deadline) if deadline is not None else None
280
+
281
+ dem = {
282
+ "ring": round(random.uniform(0.4, 1.0), 2),
283
  "necklace": round(random.uniform(0.2, 0.8), 2),
284
  "bracelet": round(random.uniform(0.1, 0.6), 2),
285
  }
286
+ dem_fc = _demand_forecast_from(dem)
287
+ mode = (kwargs.get("market_mode") or get_market_mode()).lower().strip()
288
+
289
+ if mode == "synthetic":
290
+ gp = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
291
+ hist = [gp]
292
+ mmode = "synthetic"
293
+ src = "synthetic:random_range"
294
+ maxr = int(kwargs.get("max_market_rounds", MAX_MARKET_ROUNDS))
295
+ use_lots = False
296
+ else:
297
+ mmode = "real"
298
+ maxr = 0
299
+ use_lots = True
300
+ sqlite_store.init_schema()
301
+ try:
302
+ q = fetch_gold_spot_usd_per_oz()
303
+ gp = round(q.usd_per_oz, 2)
304
+ src = q.source
305
+ except Exception:
306
+ gp = 2000.0
307
+ src = "yfinance:error_fallback(2000)"
308
+ hist = [gp]
309
+
310
+ max_r0 = int(maxr) if mode == "synthetic" else 0
311
+ task_id = (kwargs.get("task_id") or DEFAULT_TASK_ID).strip().lower()
312
+ weights = resolve_weights(task_id)
313
+ try:
314
+ max_reentries = int(kwargs.get("max_market_reentries", 2))
315
+ if max_reentries < 0:
316
+ max_reentries = 0
317
+ except (TypeError, ValueError):
318
+ max_reentries = 2
319
+ s = self._state = JewelryState(
320
+ episode_id=eid,
321
  step_count=0,
322
+ cash=starting_cash,
323
  gold_oz=0.0,
324
+ gold_price=gp,
325
+ gold_price_history=hist,
326
  market_round=0,
327
+ max_market_rounds=max_r0,
328
+ demand=dem,
329
+ demand_forecast=dem_fc,
330
  inventory={"ring": 0, "necklace": 0, "bracelet": 0},
331
  phase="market",
332
  product_for_sale=None,
 
334
  negotiation_round=0,
335
  current_offer=0.0,
336
  base_offer=0.0,
337
+ lowest_price_seen=gp,
338
+ inventory_urgent=inv_urgent,
339
+ need_gold_grams=need_g,
340
+ buy_deadline_iso=deadline,
341
+ use_fifo_lots=use_lots,
342
+ gold_price_source=src,
343
+ market_mode=mmode,
344
+ task_id=task_id,
345
+ weights=list(weights),
346
+ cumulative_reward=0.0,
347
+ last_phase_emitted_reward=0.0,
348
+ market_reentries=0,
349
+ max_market_reentries=max_reentries,
350
  )
351
  self._r1 = 0.0
352
  self._r2 = 0.0
353
+ self._r3 = 0.0
354
+ sstep = s.max_market_rounds if s.max_market_rounds else 0
355
+ o = self._co_market(
356
+ msg=(
357
+ f"Welcome. Task='{task_id}' weights(market,warehouse,showroom)={weights}. "
358
+ f"Gold: ${gp}/oz ({s.gold_price_source}). Cash: ${s.cash:.2f}. "
359
+ f"Inventory need-urgent={inv_urgent}."
360
+ f" {self._mm_line()}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  ),
362
  )
363
+ o["max_market_rounds"] = sstep
364
+ return self._obs_from(o)
365
 
366
  def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
367
  self._state.step_count += 1
368
+ if self._state.phase == "market":
369
+ self._apply_action_inventory_fields(action)
370
+ if self._state.phase == "market":
371
  return self._step_market(action)
372
+ if self._state.phase == "warehouse":
373
  return self._step_warehouse(action)
374
+ if self._state.phase == "showroom":
375
  return self._step_showroom(action)
376
+ raise ValueError(f"Unknown phase: {self._state.phase}")
 
377
 
378
+ def _refresh_real_quote(self) -> None:
379
+ s = self._state
380
+ if s.market_mode != "real":
381
+ return
382
+ try:
383
+ q = fetch_gold_spot_usd_per_oz()
384
+ s.gold_price = round(q.usd_per_oz, 2)
385
+ s.gold_price_source = q.source
386
+ except Exception as exc: # noqa: BLE001
387
+ fb = s.gold_price if s.gold_price > 0 else 2000.0
388
+ q2 = last_quote_or_fallback(fb)
389
+ s.gold_price = round(q2.usd_per_oz, 2)
390
+ s.gold_price_source = f"{q2.source}(err:{type(exc).__name__})"
391
+ s.gold_price_history.append(s.gold_price)
392
+ s.lowest_price_seen = min(s.lowest_price_seen, s.gold_price) if s.lowest_price_seen else s.gold_price
393
 
394
  def _step_market(self, action: JewelryAction) -> JewelryObservation:
395
  s = self._state
396
  market_action = (action.market_action or "wait").lower().strip()
397
 
398
+ if s.market_mode == "synthetic":
399
+ return self._step_market_synthetic(action, market_action)
400
+ return self._step_market_real(action, market_action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
402
+ def _step_market_synthetic(self, action: JewelryAction, market_action: str) -> JewelryObservation:
403
+ s = self._state
404
+ if market_action == "buy":
405
+ return self._exec_buy_synthetic_common(action, market_action)
406
+ s.market_round += 1
407
+ if s.market_round >= (s.max_market_rounds or MAX_MARKET_ROUNDS) and s.max_market_rounds is not None and s.max_market_rounds > 0:
408
  s.phase = "warehouse"
409
+ self._r1 = 0.0
410
+ o = self._co_market(keep_phase="warehouse", msg="(Synthetic) Market round limit — entering warehouse with no new purchase.")
411
+ return self._obs_from(o)
412
+ ch = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
413
+ np = round(s.gold_price * (1 + ch), 2)
414
+ s.gold_price = max(np, 50.0)
415
+ s.gold_price_history.append(s.gold_price)
416
+ s.lowest_price_seen = min(s.lowest_price_seen, s.gold_price) if s.lowest_price_seen else s.gold_price
417
+ o = self._co_market(
418
+ msg=f"(Synthetic) New quote ${s.gold_price}/oz. History (last 5): {s.gold_price_history[-5:]!s}. {self._mm_line()}",
419
+ )
420
+ return self._obs_from(o)
421
 
422
+ def _exec_buy_synthetic_common(self, action: JewelryAction, market_action: str) -> JewelryObservation:
423
+ return self._step_market_buy_and_advance(
424
+ action,
425
+ persist_db=False,
426
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
+ def _step_market_real(self, action: JewelryAction, market_action: str) -> JewelryObservation:
429
+ s = self._state
430
+ self._refresh_real_quote()
431
+ if market_action != "buy":
432
+ if s.inventory_urgent:
433
+ o = self._co_market(
434
+ msg="Urgent (inventory): you must not wait. Submit market_action=buy with a gold_qty you can afford at the current live quote, or 0.01 if testing.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  )
436
+ return self._obs_from(o)
437
+ s.market_round += 1
438
+ o = self._co_market(
439
+ msg=f"Quote refreshed. Gold ${s.gold_price}/oz from {s.gold_price_source}. {self._mm_line()} Rounds so far: {s.market_round}.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  )
441
+ return self._obs_from(o)
442
+ return self._step_market_buy_and_advance(action, persist_db=True)
443
 
444
+ def _step_market_buy_and_advance(self, action: JewelryAction, *, persist_db: bool) -> JewelryObservation:
445
+ s = self._state
446
+ market_action = "buy"
447
+ gold_qty = action.gold_qty
448
+ if gold_qty is None or float(gold_qty) <= 0:
449
+ o = self._co_market(
450
+ msg="Buy failed: set gold_qty to a positive number of troy oz.",
451
+ )
452
+ return self._obs_from(o)
453
+ gold_qty = float(gold_qty)
454
+ price = s.gold_price
455
+ total_cost = gold_qty * price
456
+ if total_cost > s.cash:
457
+ o = self._co_market(
458
+ msg=f"Not enough cash: need ${total_cost:.2f} for {gold_qty}oz @ ${price}, have ${s.cash:.2f}.",
459
+ )
460
+ return self._obs_from(o)
461
+ fund_before = s.cash
462
+ s.cash -= total_cost
463
+ s.gold_oz += gold_qty
464
+ s.phase = "warehouse"
465
+ # The bounce signal was satisfied by this purchase; clear it so the next
466
+ # warehouse failure (if any) can emit a fresh urgency.
467
+ s.inventory_urgent = False
468
+ s.need_gold_grams = None
469
+ # Only score r1 on the FIRST market visit; bounce-back buys are loop-recovery,
470
+ # not "good price hunting", so they shouldn't pay phase-1 reward again.
471
+ if s.market_reentries == 0:
472
+ self._r1 = compute_r1(s.gold_price, s.lowest_price_seen) if s.lowest_price_seen else 0.0
473
+ market_partial = self._emit("market", self._r1)
474
+ else:
475
+ self._r1 = 0.0
476
+ market_partial = self._emit("market", 0.0)
477
+ eid = getattr(s, "episode_id", None) or "unknown"
478
+ if persist_db and s.use_fifo_lots and eid != "unknown":
479
+ try:
480
+ sqlite_store.record_gold_purchase(
481
+ eid,
482
+ "GOLD",
483
+ price,
484
+ gold_qty,
485
+ round(total_cost, 2),
486
+ "BUY",
487
+ action.ai_confidence_pct,
488
+ action.ai_reasoning,
489
+ action.target_price_usd,
490
+ fund_before,
491
+ s.cash,
492
+ )
493
+ except Exception as exc: # noqa: BLE001
494
+ s.gold_price_source = f"{s.gold_price_source} | db_log_failed:{type(exc).__name__}"
495
+ o = self._co_market(
496
+ reward=market_partial,
497
+ keep_phase="warehouse",
498
+ msg=(
499
+ f"Bought {gold_qty} troy oz at ${price}/oz ($ {total_cost:.2f}). "
500
+ f"Cash ${s.cash:.2f}. {self._mm_line()} "
501
+ f"Phase reward(r1={self._r1:.4f} * w_market={s.weights[0]})={market_partial:.4f}. "
502
+ f"Cumulative={s.cumulative_reward:.4f}. Choose a product in the warehouse."
503
+ ),
504
+ )
505
+ return self._obs_from(o)
506
+
507
+ def _can_afford_smallest_buy(self) -> bool:
508
+ """
509
+ Loop guard: are we even theoretically able to buy *some* useful gold?
510
+ We require cash >= price * smallest product's gold need (i.e. enough
511
+ for at least one bracelet's worth of gold). If not, bouncing back to
512
+ market is wasteful and we should stop the loop.
513
+ """
514
+ s = self._state
515
+ if s.gold_price <= 0:
516
+ return False
517
+ cheapest_gold_oz = min(spec["gold_oz"] for spec in PRODUCT_CATALOG.values())
518
+ return s.cash >= s.gold_price * cheapest_gold_oz
519
+
520
+ def _bounce_to_market(self, choice: str, grams_needed: float, reason: str) -> JewelryObservation:
521
+ """
522
+ Inventory -> Market loop: send the agent back to the market phase to
523
+ buy more gold, with urgency flags so the market step won't allow waits.
524
+ Emits 0.0 reward; final episode score still bounded in [0, 1].
525
+ """
526
+ s = self._state
527
+ s.market_reentries += 1
528
+ s.phase = "market"
529
+ s.market_round = 0 # fresh patience counter for this re-entry
530
+ s.inventory_urgent = True
531
+ s.need_gold_grams = round(grams_needed, 4)
532
+ bounce_partial = self._emit("warehouse", 0.0)
533
+ o = self._co_market(reward=bounce_partial, keep_phase="market")
534
+ o["message"] = (
535
+ f"Inventory needs more gold to craft {choice} ({reason}). "
536
+ f"Bouncing back to MARKET (re-entry {s.market_reentries}/{s.max_market_reentries}). "
537
+ f"Need ~{grams_needed:.2f} g. inventory_urgent=True; market_action='wait' will be blocked. "
538
+ f"Cumulative={s.cumulative_reward:.4f}."
539
+ )
540
+ o["product_for_sale"] = None
541
+ o["current_offer"] = None
542
+ o["cost_basis"] = 0.0
543
+ return self._obs_from(o)
544
 
545
  def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
546
  s = self._state
547
  choice = (action.product_choice or "ring").lower().strip()
 
548
  if choice not in PRODUCT_CATALOG:
549
+ choice = "ring"
 
550
  spec = PRODUCT_CATALOG[choice]
551
+ gold_needed_oz = spec["gold_oz"]
552
  labor_cost = spec["labor"]
553
+ grams_needed = troy_oz_to_grams(gold_needed_oz)
554
+
555
+ has_gold_oz = s.gold_oz + 1e-8 >= gold_needed_oz
556
+ if not has_gold_oz:
557
+ # Inventory -> market loop: try to buy more gold if budget + bounces remain.
558
+ if (
559
+ s.market_reentries < s.max_market_reentries
560
+ and self._can_afford_smallest_buy()
561
+ ):
562
+ return self._bounce_to_market(
563
+ choice,
564
+ grams_needed,
565
+ reason=f"have {s.gold_oz:.4f} oz, need {gold_needed_oz:.4f} oz",
566
+ )
567
+ # Out of bounces or no money: customer leaves, episode ends with no sale.
568
  self._r2 = 0.0
569
  s.phase = "showroom"
570
+ o = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
571
+ why = "no bounce-backs left" if s.market_reentries >= s.max_market_reentries else "not enough cash to buy any gold"
572
+ o["message"] = (
573
+ f"Cannot craft {choice}: insufficient gold and {why}. "
574
+ f"Customer walks away. Cumulative={s.cumulative_reward:.4f}."
575
  )
576
+ o["product_for_sale"] = None
577
+ o["current_offer"] = None
578
+ o["cost_basis"] = 0.0
579
+ return self._obs_from(o)
580
+ if s.cash < labor_cost:
581
+ self._r2 = 0.0
582
+ s.phase = "showroom"
583
+ o = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
584
+ o["message"] = (
585
+ f"Cannot craft {choice}: have gold but no cash for labor (${labor_cost:.2f}). "
586
+ f"Cumulative={s.cumulative_reward:.4f}."
 
 
 
587
  )
588
+ o["product_for_sale"] = None
589
+ o["current_offer"] = None
590
+ o["cost_basis"] = 0.0
591
+ return self._obs_from(o)
592
 
 
593
  s.cash -= labor_cost
594
+ eid = getattr(s, "episode_id", None) or "unknown"
595
+ if s.use_fifo_lots and s.market_mode == "real" and eid != "unknown":
596
+ ok, gold_cost, _d = sqlite_store.fifo_consume_grams(eid, grams_needed)
597
+ if not ok:
598
+ s.cash += labor_cost
599
+ self._r2 = 0.0
600
+ s.phase = "showroom"
601
+ o_ = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
602
+ o_["message"] = "FIFO: not enough gold lots in the database for this episode (or oz/gram mismatch)."
603
+ o_["product_for_sale"] = None
604
+ o_["current_offer"] = None
605
+ o_["cost_basis"] = 0.0
606
+ return self._obs_from(o_)
607
+ s.gold_oz -= gold_needed_oz
608
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
609
+ s.product_for_sale = choice
610
+ s.cost_basis = float(gold_cost) + float(labor_cost)
611
+ else:
612
+ s.gold_oz -= gold_needed_oz
613
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
614
+ s.product_for_sale = choice
615
+ s.cost_basis = s.gold_price * gold_needed_oz + labor_cost
616
  self._r2 = compute_r2(choice, s.demand)
617
+ warehouse_partial = self._emit("warehouse", self._r2)
618
+ dmf = s.demand.get(choice, 0.5)
619
+ offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (dmf * DEMAND_OFFER_BONUS)
620
+ s.base_offer = round(s.cost_basis * offer_ratio, 2)
621
+ s.current_offer = s.base_offer
 
 
622
  s.phase = "showroom"
623
  s.negotiation_round = 0
624
+ o2 = {**self._co_market(keep_phase="showroom")}
625
+ o2["reward"] = warehouse_partial
626
+ o2["product_for_sale"] = choice
627
+ o2["cost_basis"] = s.cost_basis
628
+ o2["current_offer"] = s.current_offer
629
+ _cost_label = (
630
+ "FIFO (SQLite lots) gold + labor"
631
+ if s.use_fifo_lots and s.market_mode == "real" and eid != "unknown"
632
+ else "market gold + labor"
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  )
634
+ o2["message"] = (
635
+ f"Crafted {choice}. Cost ({_cost_label}): ${s.cost_basis:.2f}. "
636
+ f"Phase reward(r2={self._r2:.4f} * w_warehouse={s.weights[1]})={warehouse_partial:.4f}. "
637
+ f"Cumulative={s.cumulative_reward:.4f}. Customer offers ${s.current_offer:.2f}."
638
+ )
639
+ return self._obs_from(o2)
640
 
641
  def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
642
  s = self._state
 
 
643
  if s.product_for_sale is None:
644
+ self._r3 = 0.0
645
+ showroom_partial = self._emit("showroom", 0.0)
646
+ o3 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
647
+ o3["message"] = (
648
+ "No products to sell. Episode over. "
649
+ f"Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
650
+ f"Cumulative={s.cumulative_reward:.4f}."
 
 
 
 
 
 
651
  )
652
+ o3["product_for_sale"] = None
653
+ o3["current_offer"] = s.current_offer
654
+ return self._obs_from(o3)
655
  message = action.message or ""
656
  intent = detect_intent(message)
 
 
657
  if intent == "accept":
658
+ self._r3 = compute_r3(s.current_offer, s.cost_basis)
659
+ showroom_partial = self._emit("showroom", self._r3)
660
  s.cash += s.current_offer
661
  s.inventory[s.product_for_sale] -= 1
662
+ _ps = s.product_for_sale
663
  s.product_for_sale = None
664
+ o4 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
665
+ o4["message"] = (
666
+ f"Sold {_ps} for ${s.current_offer:.2f}. "
667
+ f"Phase reward(r3={self._r3:.4f} * w_showroom={s.weights[2]})={showroom_partial:.4f}. "
668
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  )
670
+ o4["product_for_sale"] = None
671
+ o4["current_offer"] = s.current_offer
672
+ return self._obs_from(o4)
673
  if intent == "reject":
674
+ self._r3 = 0.0
675
+ showroom_partial = self._emit("showroom", 0.0)
676
+ o5 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
677
+ o5["message"] = (
678
+ f"Rejected. Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
679
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  )
681
+ o5["product_for_sale"] = s.product_for_sale
682
+ o5["current_offer"] = s.current_offer
683
+ return self._obs_from(o5)
684
  s.negotiation_round += 1
 
685
  if s.negotiation_round >= MAX_NEGOTIATION:
686
+ self._r3 = 0.0
687
+ showroom_partial = self._emit("showroom", 0.0)
688
+ o6 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
689
+ o6["message"] = (
690
+ f"Max negotiation rounds reached. "
691
+ f"Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
692
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
 
 
 
 
 
 
 
 
 
 
 
 
693
  )
694
+ return self._obs_from(o6)
 
695
  s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
696
+ o7 = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
697
+ o7["message"] = f"Customer at ${s.current_offer:.2f} (round {s.negotiation_round})."
698
+ o7["current_offer"] = s.current_offer
699
+ o7["product_for_sale"] = s.product_for_sale
700
+ return self._obs_from(o7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
 
702
  @property
703
  def state(self) -> JewelryState:
704
+ return self._state
server/app.py CHANGED
@@ -1,5 +1,15 @@
 
 
 
1
  from openenv.core.env_server import create_fastapi_app
2
 
 
 
 
 
 
 
 
3
  try:
4
  from .ShopManagerEng_environment import JewelryShopEnvironment
5
  from ..models import JewelryAction, JewelryObservation
@@ -9,7 +19,23 @@ except ImportError:
9
 
10
  import uvicorn
11
 
12
- app = create_fastapi_app(JewelryShopEnvironment, JewelryAction, JewelryObservation)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  def main():
15
  uvicorn.run(app, host="0.0.0.0", port=8000)
 
1
+ import os
2
+ from pathlib import Path
3
+
4
  from openenv.core.env_server import create_fastapi_app
5
 
6
+ try:
7
+ from dotenv import load_dotenv as _load_dotenv
8
+ except ImportError:
9
+ def _load_dotenv(_path: str) -> bool: # type: ignore[misc]
10
+ return False
11
+
12
+
13
  try:
14
  from .ShopManagerEng_environment import JewelryShopEnvironment
15
  from ..models import JewelryAction, JewelryObservation
 
19
 
20
  import uvicorn
21
 
22
+ # Load .env from this package (ShopManagerEng/.env) for FRED/keys when running the server
23
+ _env = Path(__file__).resolve().parent.parent / ".env"
24
+ if _env.is_file():
25
+ _load_dotenv(_env)
26
+
27
+ # RL trainers (TRL GRPO, etc.) open one WebSocket per parallel rollout. With
28
+ # num_generations=8 + per_device_train_batch_size>=8 you can easily need 8-16
29
+ # concurrent envs. Default max is 1, so we bump it. Override via env var
30
+ # SHOPMANAGER_MAX_CONCURRENT_ENVS for hosted Spaces with tighter budgets.
31
+ _MAX_CONCURRENT_ENVS = int(os.environ.get("SHOPMANAGER_MAX_CONCURRENT_ENVS", "16"))
32
+
33
+ app = create_fastapi_app(
34
+ JewelryShopEnvironment,
35
+ JewelryAction,
36
+ JewelryObservation,
37
+ max_concurrent_envs=_MAX_CONCURRENT_ENVS,
38
+ )
39
 
40
  def main():
41
  uvicorn.run(app, host="0.0.0.0", port=8000)
server/market_data.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Live gold (USD / troy oz) via yfinance, aligned with api_key_test.py (GC=F).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import List, Optional, Tuple
8
+
9
+
10
+ @dataclass
11
+ class GoldPriceQuote:
12
+ usd_per_oz: float
13
+ source: str
14
+
15
+
16
+ def os_gold_symbol() -> str:
17
+ import os
18
+
19
+ return (os.environ.get("SHOPMANAGER_GOLD_SYMBOL", "GC=F") or "GC=F").strip()
20
+
21
+
22
+ def _fetch_yfinance_gold() -> Tuple[float, str, List[float]]:
23
+ import yfinance as yf
24
+
25
+ sym = (os_gold_symbol() or "GC=F").strip() or "GC=F"
26
+ ticker = yf.Ticker(sym)
27
+ hist = ticker.history(period="60d", interval="1d")
28
+ if hist is None or hist.empty:
29
+ raise ValueError("No price history for gold symbol")
30
+ closes = hist["Close"].dropna().astype(float).tolist()
31
+ if not closes:
32
+ raise ValueError("Empty close series for gold")
33
+ return float(closes[-1]), f"yfinance:{sym}", [float(c) for c in closes[-30:]]
34
+
35
+
36
+ def fetch_gold_spot_usd_per_oz() -> GoldPriceQuote:
37
+ usd, src, _ = _fetch_yfinance_gold()
38
+ if usd <= 0:
39
+ raise ValueError("Invalid non-positive gold price")
40
+ return GoldPriceQuote(usd_per_oz=usd, source=src)
41
+
42
+
43
+ def recent_close_history(max_points: int = 30) -> List[float]:
44
+ try:
45
+ _, _, hist = _fetch_yfinance_gold()
46
+ except Exception:
47
+ return []
48
+ if max_points and len(hist) > max_points:
49
+ return hist[-max_points:]
50
+ return list(hist)
51
+
52
+
53
+ def last_quote_or_fallback(fallback: float) -> GoldPriceQuote:
54
+ try:
55
+ return fetch_gold_spot_usd_per_oz()
56
+ except Exception:
57
+ return GoldPriceQuote(usd_per_oz=fallback, source="fallback")
server/requirements.txt DELETED
@@ -1,6 +0,0 @@
1
- openenv[core]>=0.2.0
2
- fastapi>=0.115.0
3
- uvicorn>=0.24.0
4
-
5
-
6
-
 
 
 
 
 
 
 
server/sqlite_store.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite persistence: market gold purchase invoices (troy oz) and per-gram lots (FIFO for warehouse).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import sqlite3
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional, Tuple
10
+
11
+ try:
12
+ from ..constants import GRAMS_PER_TROY_OZ, default_sqlite_path, get_sqlite_path
13
+ except ImportError:
14
+ # `python -c` / `import server` from the ShopManagerEng/ folder: `server` is a top module,
15
+ # so `..constants` is invalid. Parent package constants.py lives as a sibling of `server/`.
16
+ from constants import GRAMS_PER_TROY_OZ, default_sqlite_path, get_sqlite_path
17
+
18
+
19
+ def _db_path() -> str:
20
+ p = get_sqlite_path()
21
+ return p if p else default_sqlite_path()
22
+
23
+
24
+ def _connect() -> sqlite3.Connection:
25
+ path = _db_path()
26
+ from pathlib import Path
27
+
28
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
29
+ conn = sqlite3.connect(path, check_same_thread=False, timeout=30.0)
30
+ conn.row_factory = sqlite3.Row
31
+ return conn
32
+
33
+
34
+ def init_schema() -> None:
35
+ with _connect() as c:
36
+ c.executescript(
37
+ """
38
+ CREATE TABLE IF NOT EXISTS gold_purchases (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ episode_id TEXT NOT NULL,
41
+ product_name TEXT NOT NULL,
42
+ buy_price_usd REAL NOT NULL,
43
+ quantity_oz REAL NOT NULL,
44
+ cost_usd REAL NOT NULL,
45
+ ai_decision TEXT NOT NULL,
46
+ ai_confidence_pct REAL,
47
+ ai_reasoning TEXT,
48
+ target_price_usd REAL,
49
+ bought_at TEXT NOT NULL,
50
+ fund_before_usd REAL NOT NULL,
51
+ fund_after_usd REAL NOT NULL
52
+ );
53
+ CREATE INDEX IF NOT EXISTS idx_gold_purchases_episode
54
+ ON gold_purchases (episode_id);
55
+ CREATE TABLE IF NOT EXISTS gold_grams_lots (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ purchase_id INTEGER NOT NULL,
58
+ episode_id TEXT NOT NULL,
59
+ product_name TEXT NOT NULL,
60
+ buy_price_usd_per_gram REAL NOT NULL,
61
+ quantity_grams_total REAL NOT NULL,
62
+ quantity_grams_remaining REAL NOT NULL,
63
+ bought_at TEXT NOT NULL,
64
+ FOREIGN KEY (purchase_id) REFERENCES gold_purchases (id)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_lots_episode_bought
67
+ ON gold_grams_lots (episode_id, bought_at, id);
68
+ """
69
+ )
70
+ c.commit()
71
+
72
+
73
+ @dataclass
74
+ class PurchaseRow:
75
+ id: int
76
+ buy_price_usd: float
77
+ quantity_oz: float
78
+ cost_usd: float
79
+ target_price_usd: Optional[float]
80
+ fund_before_usd: float
81
+ fund_after_usd: float
82
+ bought_at: str
83
+
84
+
85
+ def record_gold_purchase(
86
+ episode_id: str,
87
+ product_name: str,
88
+ buy_price_usd: float,
89
+ quantity_oz: float,
90
+ cost_usd: float,
91
+ ai_decision: str,
92
+ ai_confidence_pct: Optional[float],
93
+ ai_reasoning: Optional[str],
94
+ target_price_usd: Optional[float],
95
+ fund_before_usd: float,
96
+ fund_after_usd: float,
97
+ ) -> Tuple[int, int]:
98
+ """
99
+ Inserts into gold_purchases and gold_grams_lots. Returns (purchase_id, lot_id).
100
+ """
101
+ init_schema()
102
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
103
+ g_total = round(quantity_oz * GRAMS_PER_TROY_OZ, 6)
104
+ ppg = round(buy_price_usd / GRAMS_PER_TROY_OZ, 8) if GRAMS_PER_TROY_OZ > 0 else 0.0
105
+ ai_r = (ai_reasoning or "").strip() or None
106
+ with _connect() as c:
107
+ cur = c.execute(
108
+ """
109
+ INSERT INTO gold_purchases (
110
+ episode_id, product_name, buy_price_usd, quantity_oz, cost_usd,
111
+ ai_decision, ai_confidence_pct, ai_reasoning, target_price_usd,
112
+ bought_at, fund_before_usd, fund_after_usd
113
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
114
+ """,
115
+ (
116
+ episode_id,
117
+ product_name,
118
+ buy_price_usd,
119
+ quantity_oz,
120
+ cost_usd,
121
+ ai_decision,
122
+ ai_confidence_pct,
123
+ ai_r,
124
+ target_price_usd,
125
+ now,
126
+ fund_before_usd,
127
+ fund_after_usd,
128
+ ),
129
+ )
130
+ purchase_id = int(cur.lastrowid)
131
+ cur2 = c.execute(
132
+ """
133
+ INSERT INTO gold_grams_lots (
134
+ purchase_id, episode_id, product_name, buy_price_usd_per_gram,
135
+ quantity_grams_total, quantity_grams_remaining, bought_at
136
+ ) VALUES (?,?,?,?,?,?,?)
137
+ """,
138
+ (
139
+ purchase_id,
140
+ episode_id,
141
+ product_name,
142
+ ppg,
143
+ g_total,
144
+ g_total,
145
+ now,
146
+ ),
147
+ )
148
+ lot_id = int(cur2.lastrowid)
149
+ c.commit()
150
+ return purchase_id, lot_id
151
+
152
+
153
+ def fifo_consume_grams(
154
+ episode_id: str, grams_needed: float
155
+ ) -> Tuple[bool, float, List[dict]]:
156
+ """
157
+ Uses oldest lots first. Returns (ok, total_usd_cost, details).
158
+ """
159
+ if grams_needed <= 0:
160
+ return True, 0.0, []
161
+ init_schema()
162
+ rem = float(grams_needed)
163
+ total_usd = 0.0
164
+ details: List[dict] = []
165
+ with _connect() as c:
166
+ cur = c.execute(
167
+ """
168
+ SELECT id, quantity_grams_remaining, buy_price_usd_per_gram
169
+ FROM gold_grams_lots
170
+ WHERE episode_id = ? AND quantity_grams_remaining > 0.0000001
171
+ ORDER BY bought_at ASC, id ASC
172
+ """,
173
+ (episode_id,),
174
+ )
175
+ rows = cur.fetchall()
176
+ for row in rows:
177
+ if rem <= 1e-9:
178
+ break
179
+ lot_id = int(row["id"])
180
+ qrem = float(row["quantity_grams_remaining"])
181
+ ppg = float(row["buy_price_usd_per_gram"])
182
+ take = min(qrem, rem)
183
+ cost = take * ppg
184
+ new_rem = round(qrem - take, 6)
185
+ c.execute(
186
+ "UPDATE gold_grams_lots SET quantity_grams_remaining = ? WHERE id = ?",
187
+ (new_rem, lot_id),
188
+ )
189
+ rem -= take
190
+ total_usd += cost
191
+ details.append(
192
+ {
193
+ "lot_id": lot_id,
194
+ "grams": take,
195
+ "cost_usd": round(cost, 4),
196
+ }
197
+ )
198
+ c.commit()
199
+ if rem > 1e-5:
200
+ return False, 0.0, []
201
+ return True, round(total_usd, 4), details
202
+
203
+
204
+ def ensure_schema_once() -> None:
205
+ try:
206
+ init_schema()
207
+ except Exception:
208
+ pass
test_env_smoke.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone, no-LLM, no-server smoke tests for the JewelryShop env.
2
+
3
+ Run from inside the ShopManagerEng folder so `models` / `server` import.
4
+
5
+ Usage:
6
+ cd ShopManagerEng
7
+ SHOPMANAGER_MARKET_MODE=synthetic python test_env_smoke.py # default: A+B
8
+ SHOPMANAGER_MARKET_MODE=real python test_env_smoke.py live # C only
9
+ python test_env_smoke.py all # all three
10
+
11
+ Why a script: putting `f'gold_price=${o.gold_price}/oz'` inside `bash -c "..."`
12
+ makes bash try to expand `${o.gold_price}` as a shell variable and crash with
13
+ `bad substitution`. Running it from a .py file removes that whole class of bugs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+
21
+ from server.ShopManagerEng_environment import JewelryShopEnvironment
22
+ from models import JewelryAction
23
+
24
+
25
+ def test_reward_path() -> None:
26
+ """A. Synthetic episode end-to-end, prints per-step partial + cumulative."""
27
+ print("\n=== A. reward path (synthetic) ===")
28
+ os.environ["SHOPMANAGER_MARKET_MODE"] = "synthetic"
29
+ e = JewelryShopEnvironment()
30
+ o = e.reset(seed=42, task_id="market_timing", starting_cash=10000.0)
31
+ print(
32
+ f" reset: phase={o.phase} cash=${o.cash} gold={o.gold_oz}oz "
33
+ f"price=${o.gold_price} weights={o.weights}"
34
+ )
35
+
36
+ actions = [
37
+ JewelryAction(market_action="buy", gold_qty=1.0),
38
+ JewelryAction(product_choice="ring"),
39
+ JewelryAction(message="I accept"),
40
+ ]
41
+ for i, a in enumerate(actions, 1):
42
+ o = e.step(a)
43
+ print(
44
+ f" step {i}: phase={o.phase} reward={o.reward:.4f} "
45
+ f"cum={o.cumulative_reward:.4f} done={o.done}"
46
+ )
47
+ print(f" FINAL cum={o.cumulative_reward:.4f} (must be in [0, 1])")
48
+
49
+
50
+ def test_bounce_loop() -> None:
51
+ """B. Warehouse cannot craft -> agent is bounced back to MARKET."""
52
+ print("\n=== B. bounce loop (warehouse -> market) ===")
53
+ os.environ["SHOPMANAGER_MARKET_MODE"] = "synthetic"
54
+ e = JewelryShopEnvironment()
55
+ o = e.reset(seed=42, task_id="profit_negotiator", starting_cash=10000.0)
56
+
57
+ o = e.step(JewelryAction(market_action="buy", gold_qty=0.2))
58
+ print(f" bought 0.2oz: phase={o.phase}, gold={o.gold_oz}oz")
59
+
60
+ o = e.step(JewelryAction(product_choice="ring"))
61
+ print(
62
+ f" tried ring: phase={o.phase} reentries={o.market_reentries}"
63
+ f"/{o.max_market_reentries} urgent={o.inventory_urgent} "
64
+ f"cannot_wait={o.cannot_wait}"
65
+ )
66
+ assert o.phase == "market", "expected bounce back to market"
67
+ assert o.inventory_urgent is True, "expected urgent flag"
68
+
69
+ o = e.step(JewelryAction(market_action="wait"))
70
+ print(f" tried wait while urgent: phase={o.phase} (should still be market)")
71
+ assert o.phase == "market", "wait should be blocked when urgent"
72
+
73
+ o = e.step(JewelryAction(market_action="buy", gold_qty=1.0))
74
+ print(f" bought 1.0oz more: phase={o.phase} gold={o.gold_oz}oz")
75
+
76
+ o = e.step(JewelryAction(product_choice="ring"))
77
+ print(f" craft ring: phase={o.phase} cum={o.cumulative_reward:.4f}")
78
+
79
+ o = e.step(JewelryAction(message="I accept"))
80
+ print(f" FINAL cum={o.cumulative_reward:.4f}")
81
+
82
+
83
+ def test_live_quote() -> None:
84
+ """C. Real mode: live yfinance gold price (needs network)."""
85
+ print("\n=== C. live yfinance quote (real mode) ===")
86
+ os.environ["SHOPMANAGER_MARKET_MODE"] = "real"
87
+ e = JewelryShopEnvironment()
88
+ o = e.reset(seed=0, task_id="market_timing", starting_cash=10000.0)
89
+ print(f" gold_price=${o.gold_price}/oz source={o.gold_price_source}")
90
+ print(f" market_mode={o.market_mode}")
91
+ print(f" history(last 5)={o.gold_price_history[-5:]}")
92
+
93
+
94
+ def main(argv: list[str]) -> None:
95
+ arg = (argv[1] if len(argv) > 1 else "default").lower()
96
+
97
+ if arg in ("default", "ab"):
98
+ test_reward_path()
99
+ test_bounce_loop()
100
+ elif arg == "live":
101
+ test_live_quote()
102
+ elif arg == "all":
103
+ test_reward_path()
104
+ test_bounce_loop()
105
+ test_live_quote()
106
+ elif arg == "a":
107
+ test_reward_path()
108
+ elif arg == "b":
109
+ test_bounce_loop()
110
+ elif arg == "c":
111
+ test_live_quote()
112
+ else:
113
+ print(f"unknown arg: {arg}. use one of: a / b / c / ab / live / all")
114
+ sys.exit(2)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main(sys.argv)