anuragredbus commited on
Commit
28dd5a4
·
0 Parent(s):

Viraltest OpenEnv: deploy to HF Space

Browse files

Single-commit history without PNG binaries (Hub pre-receive rejects them).

Made-with: Cursor

.agents/skills/openenv-cli/SKILL.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: openenv-cli
3
+ description: "OpenEnv CLI (`openenv`) for scaffolding, validating, building, and pushing OpenEnv environments."
4
+ ---
5
+
6
+ Install: `pip install openenv-core`
7
+
8
+ The OpenEnv CLI command `openenv` is available.
9
+ Use `openenv --help` to view available commands.
10
+
11
+ Generated with `openenv-core v0.2.3`. Run `openenv skills add --force` to regenerate.
12
+
13
+ ## Tips
14
+
15
+ - Start with `openenv init <env_name>` to scaffold a new environment
16
+ - Validate projects with `openenv validate`
17
+ - Build and deploy with `openenv build` and `openenv push`
18
+ - Use `openenv <command> --help` for command-specific options
.codex/skills/openenv-cli ADDED
@@ -0,0 +1 @@
 
 
1
+ ../../.agents/skills/openenv-cli
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ .git
3
+ .gitignore
4
+ .env
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ *.pyw
10
+ *.pyz
11
+ *.pywz
12
+ *.pyzw
13
+ *.pyzwz
14
+
15
+
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Copy to .env and set values ( .env is gitignored )
2
+ HF_TOKEN=hf_your_token_here
3
+
4
+ # Optional overrides for Step 5 / inference (defaults match inference.py):
5
+ # MODEL_NAME=gemma-4-E4B-it-IQ4_XS
6
+ # API_BASE_URL=https://router.huggingface.co/v1
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local secrets (HF_TOKEN, etc.) — never commit
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # Generated visualization outputs (regenerate: python visualize_optimal.py)
7
+ # Hugging Face Spaces rejects plain-git binary files; keep charts local or use Git LFS elsewhere.
8
+ *.png
9
+
10
+ __pycache__/
11
+ *.py[cod]
12
+ *.egg-info/
13
+ .mplconfig/
DESIGN.md ADDED
@@ -0,0 +1,792 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Viraltest — RL-Based Creator Optimization Agent
2
+
3
+ ## Problem
4
+
5
+ Content creators on platforms like Meta (Instagram, Facebook) face:
6
+
7
+ - Unpredictable engagement
8
+ - No clear posting strategy
9
+ - Pressure to post frequently
10
+ - Burnout due to over-posting
11
+ - Drop in content quality over time
12
+
13
+ Existing tools show analytics (likes, reach) and past performance but don't **actively guide creators on optimal behavior over time**.
14
+
15
+ **Core problem**: No intelligent system continuously learns and adapts a creator's posting strategy to balance growth and burnout.
16
+
17
+ ## Solution
18
+
19
+ An RL agent that learns **when to post**, **what type to post**, **which tags to use**, and **how to differentiate from competitors** — maximizing engagement while minimizing burnout over a weekly cycle.
20
+
21
+ ---
22
+
23
+ ## Architecture
24
+
25
+ ```
26
+ ┌─────────────────────────────────────────────────────────────────────┐
27
+ │ INFERENCE SCRIPT (inference.py) │
28
+ │ │
29
+ │ env = ViraltestEnv(base_url="https://...") │
30
+ │ result = env.reset(task="weekly_strategic") ← picks task │
31
+ │ result = env.step(action) ← type-safe! │
32
+ │ │
33
+ │ ┌───────────────────────────────────────────────────────────┐ │
34
+ │ │ LLM Agent (OpenAI Client) │ │
35
+ │ │ Reads: observation → Decides: action │ │
36
+ │ │ Model: Qwen/Qwen2.5-72B-Instruct │ │
37
+ │ └───────────────────────────────────────────────────────────┘ │
38
+ │ │
39
+ │ Logs: [START] [STEP] [END] to stdout │
40
+ └──────────────────────────┬──────────────────────────────────────────┘
41
+
42
+ WebSocket /ws
43
+
44
+
45
+ ┌─────────────────────────────────────────────────────────────────────┐
46
+ │ DOCKER CONTAINER (HF Space) │
47
+ │ │
48
+ │ ┌───────────────────────────────────────────────────────────┐ │
49
+ │ │ FastAPI Server (server/app.py) — port 8000 │ │
50
+ │ │ │ │
51
+ │ │ ┌─────────────────────────────────────────────────────┐ │ │
52
+ │ │ │ ViraltestEnvironment │ │ │
53
+ │ │ │ │ │ │
54
+ │ │ │ ┌─────────────────┐ ┌──────────────────────┐ │ │ │
55
+ │ │ │ │ reset(task) │ │ step(action) │ │ │ │
56
+ │ │ │ │ • Set task │ │ 1. Validate action │ │ │ │
57
+ │ │ │ │ • Init state │ │ 2. Apply effects │ │ │ │
58
+ │ │ │ │ • energy=1.0 │ │ 3. Calc engagement │ │ │ │
59
+ │ │ │ │ • followers=N │ │ 4. Tag analytics │ │ │ │
60
+ │ │ │ │ • Init tags │ │ 5. Competitor check │ │ │ │
61
+ │ │ │ │ • Init rivals │ │ 6. Update followers │ │ │ │
62
+ │ │ │ │ • Return obs │ │ 7. Calc reward │ │ │ │
63
+ │ │ │ └─────────────────┘ │ 8. Check done │ │ │ │
64
+ │ │ │ │ 9. Return obs │ │ │ │
65
+ │ │ │ ┌─────────────────┐ └──────────────────────┘ │ │ │
66
+ │ │ │ │ state() │ │ │ │
67
+ │ │ │ │ • episode_id │ ┌──────────────────────┐ │ │ │
68
+ │ │ │ │ • step_count │ │ Grader (per task) │ │ │ │
69
+ │ │ │ │ • task_name │ │ • weekly_engage │ │ │ │
70
+ │ │ │ └─────────────────┘ │ • weekly_strategic │ │ │ │
71
+ │ │ │ │ • weekly_competitive │ │ │ │
72
+ │ │ │ └──────────────────────┘ │ │ │
73
+ │ │ │ │ │ │
74
+ │ │ │ Simulation Engine (research-backed params) │ │ │
75
+ │ │ │ • Hour multipliers (Buffer 9.6M study) │ │ │
76
+ │ │ │ • Content rates (SocialInsider 2025) │ │ │
77
+ │ │ │ • Burnout curve (Sozee 2026 creator study) │ │ │
78
+ │ │ │ • Tag engagement model │ │ │
79
+ │ │ │ • Competitor simulation │ │ │
80
+ │ │ └─────────────────────────────────────────────────────┘ │ │
81
+ │ └───────────────────────────────────────────────────────────┘ │
82
+ │ │
83
+ │ Isolated • Reproducible • Secure • Deterministic (seeded RNG) │
84
+ └─────────────────────────────────────────────────────────────────────┘
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Pydantic Models
90
+
91
+ ```
92
+ models.py
93
+ ├── ViraltestAction(Action)
94
+ │ ├── action_type: Literal["post", "rest", "create_content"]
95
+ │ ├── content_type: Optional[Literal["reel", "story", "carousel", "text_post"]]
96
+ │ ├── topic: Optional[str]
97
+ │ └── tags: Optional[list[str]] ← max 5 tags per post
98
+
99
+ └── ViraltestObservation(Observation)
100
+ ├── current_hour: int (0–23)
101
+ ├── day_of_week: int (0–6)
102
+ ├── days_elapsed: int
103
+ ├── creator_energy: float (0.0–1.0, burnout meter)
104
+ ├── follower_count: int
105
+ ├── engagement_rate: float (rolling avg last 10 posts)
106
+ ├── posts_today: int
107
+ ├── time_since_last_post: int (hours)
108
+ ├── trending_topics: list[str]
109
+ ├── content_queue_size: int
110
+ ├── last_post_type: str
111
+
112
+ │ ── Tag Analytics ──
113
+ ├── tag_performance: dict[str, float] (tag → avg engagement from your past posts)
114
+ ├── trending_tags: list[str] (currently hot tags on the platform)
115
+
116
+ │ ── Competitor Intelligence ──
117
+ ├── competitor_recent_posts: list[dict] (last 3 posts from similar creators)
118
+ │ each: {content_type, topic, tags, engagement, hours_ago}
119
+ ├── competitor_avg_engagement: float (avg engagement of similar creators)
120
+ ├── niche_saturation: float (0.0–1.0, how crowded your topic space is)
121
+
122
+ ├── done: bool (inherited)
123
+ └── reward: float (inherited)
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Data Flow — Single Step
129
+
130
+ ```
131
+ AGENT ENVIRONMENT
132
+ │ │
133
+ │ ── Action ───────────────────────────► │
134
+ │ { │
135
+ │ action_type: "post" │
136
+ │ content_type: "reel" │ 1. Validate fields
137
+ │ topic: "AI trends" │ 2. energy -= 0.25
138
+ │ tags: ["ai", "tech", "future"] │ 3. engagement = base_rate
139
+ │ } │ × hour_mult
140
+ │ │ × energy_quality
141
+ │ │ × tag_boost
142
+ │ │ × trending_bonus
143
+ │ │ × competitor_diff_bonus
144
+ │ │ × audience_fatigue
145
+ │ │ 4. Update tag_performance history
146
+ │ │ 5. Update niche_saturation
147
+ │ │ 6. followers += f(engagement)
148
+ │ │ 7. advance hour
149
+ │ │ 8. reward = composite score
150
+ │ │ 9. done? (168 steps or energy=0)
151
+ │ ◄── Observation ───────────────────── │
152
+ │ { │
153
+ │ current_hour: 14 │
154
+ │ creator_energy: 0.62 │
155
+ │ follower_count: 10340 │
156
+ │ engagement_rate: 0.048 │
157
+ │ tag_performance: { │
158
+ │ "ai": 0.72, "tech": 0.55, │
159
+ │ "food": 0.31, "travel": 0.44 │
160
+ │ } │
161
+ │ trending_tags: ["ai", "summer"] │
162
+ │ competitor_recent_posts: [ │
163
+ │ {type:"carousel", topic:"AI", │
164
+ │ tags:["ai","ml"], eng:0.61, │
165
+ │ hours_ago: 3}, │
166
+ │ ... │
167
+ │ ] │
168
+ │ niche_saturation: 0.7 │
169
+ │ done: false, reward: 0.67 │
170
+ │ } │
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Step Processing (Server-Side)
176
+
177
+ ### 1. Validate Action
178
+
179
+ - `action_type` must be one of `post`, `rest`, `create_content`
180
+ - If `post`: `content_type` required, `topic` non-empty ≤200 chars, `tags` max 5 items from known pool
181
+ - Invalid action → reward=0, error in observation
182
+
183
+ ### 2. Apply Energy Cost
184
+
185
+ | Action | Energy Effect |
186
+ |---|---|
187
+ | Post (reel) | -0.25 |
188
+ | Post (carousel) | -0.20 |
189
+ | Post (story) | -0.08 |
190
+ | Post (text_post) | -0.06 |
191
+ | Rest | +0.12 (capped at 1.0) |
192
+ | Create content | -0.05, queue += 1 |
193
+
194
+ Repetition penalty: same content type as last 3 posts → extra -0.05.
195
+ If energy ≤ 0 → `done = true` (burnout).
196
+
197
+ ### 3. Calculate Engagement (post only)
198
+
199
+ ```
200
+ engagement = base_rate × hour_mult × quality × tag_boost × trending_bonus
201
+ × competitor_diff × fatigue_penalty
202
+ ```
203
+
204
+ **Base engagement rates** (SocialInsider 2025):
205
+
206
+ | Type | Rate | Reach Mult |
207
+ |---|---|---|
208
+ | Carousel | 0.55% | 1.0x |
209
+ | Reel | 0.52% | 2.25x |
210
+ | Story | 0.30% | 0.5x |
211
+ | Text post | 0.37% | 0.44x |
212
+
213
+ **Hour multipliers** (Buffer 9.6M posts):
214
+
215
+ | Time Slot | Multiplier |
216
+ |---|---|
217
+ | 9AM–12PM weekdays | 1.3x |
218
+ | 12PM–3PM Tue-Thu | 1.4x (peak) |
219
+ | 6PM–8PM | 1.25x |
220
+ | 8PM–11PM | 1.1x |
221
+ | 11PM–6AM | 0.5x |
222
+ | Fri/Sat | 0.7x base penalty |
223
+
224
+ **Quality modifier** (Sozee burnout study: 30-52% productivity drop):
225
+
226
+ ```
227
+ quality = 1.0 if energy > 0.5 else max(0.48, energy × 1.5)
228
+ ```
229
+
230
+ **Tag boost** (see Tag Engagement section below):
231
+
232
+ ```
233
+ tag_boost = 1.0 + 0.1 × count(tags that are in trending_tags)
234
+ + 0.05 × avg(tag_performance[tag] for tag in action.tags)
235
+ ```
236
+
237
+ **Competitor differentiation bonus**:
238
+
239
+ ```
240
+ if topic NOT in competitor_recent_topics (last 12hrs):
241
+ competitor_diff = 1.3 (unique angle, underserved)
242
+ elif niche_saturation > 0.7:
243
+ competitor_diff = 0.6 (oversaturated, too many posting same thing)
244
+ else:
245
+ competitor_diff = 1.0 (neutral)
246
+ ```
247
+
248
+ **Audience fatigue**: posts_today > 3 → ×0.5, posts_today > 5 → ×0.1
249
+
250
+ **Trending bonus**: topic matches trending → ×1.5
251
+
252
+ ### 4. Update Tag Performance
253
+
254
+ After each post, the environment records engagement per tag:
255
+
256
+ ```python
257
+ for tag in action.tags:
258
+ tag_history[tag].append(this_post_engagement)
259
+ tag_performance[tag] = rolling_avg(tag_history[tag], window=5)
260
+ ```
261
+
262
+ This gives the agent a feedback loop — it can see which tags historically work and adapt.
263
+
264
+ ### 5. Update Competitor State
265
+
266
+ Each step, the simulated competitors also "post" according to a deterministic schedule (seeded RNG):
267
+
268
+ ```python
269
+ for competitor in competitors:
270
+ if should_post(competitor, current_hour): # seeded probability
271
+ competitor.recent_posts.append({
272
+ content_type: random.choice(types),
273
+ topic: random.choice(competitor.niche_topics),
274
+ tags: random.sample(tag_pool, 3),
275
+ engagement: base + noise,
276
+ hours_ago: 0
277
+ })
278
+ # Age out old posts
279
+ competitor.recent_posts = [p for p in competitor.recent_posts if p.hours_ago < 48]
280
+
281
+ niche_saturation = count(competitor posts with overlapping topic in last 12hrs) / max_posts
282
+ ```
283
+
284
+ ### 6. Update Followers
285
+
286
+ - Posted: `followers += int(engagement × 100)`
287
+ - No post for 48+ hrs: followers decay (algorithm deprioritization)
288
+
289
+ ### 7. Advance Time
290
+
291
+ - hour += 1
292
+ - If hour ≥ 24: day advances, posts_today resets, trending topics/tags rotate (seeded)
293
+
294
+ ### 8. Compute Reward
295
+
296
+ ```
297
+ reward = clamp(0, 1,
298
+ engagement_gained × 0.3
299
+ + energy_delta × 0.15
300
+ + consistency_bonus × 0.15
301
+ + tag_optimization_score × 0.15
302
+ + competitor_diff_score × 0.15
303
+ - burnout_penalty × 0.1
304
+ )
305
+ ```
306
+
307
+ - `consistency_bonus`: 1.0 if 1-2 posts/day, 0.5 if 0 or 3, 0.0 if 4+
308
+ - `tag_optimization_score`: how well agent's chosen tags match high-performing + trending tags
309
+ - `competitor_diff_score`: 1.0 if posting unique angle, 0.0 if fully overlapping
310
+ - `burnout_penalty`: 1.0 if energy < 0.2
311
+
312
+ ### 9. Check Done
313
+
314
+ Episode ends when:
315
+ - `step_count >= 168` (1 week = 7 days × 24 hours)
316
+ - `energy <= 0` (burned out)
317
+
318
+ ---
319
+
320
+ ## Tag Engagement System
321
+
322
+ ### How Tags Work
323
+
324
+ The environment maintains a **tag pool** of ~30 tags across categories:
325
+
326
+ | Category | Example Tags |
327
+ |---|---|
328
+ | Tech | `ai`, `ml`, `coding`, `startup`, `saas` |
329
+ | Lifestyle | `fitness`, `travel`, `food`, `wellness`, `fashion` |
330
+ | Trending | `summer`, `worldcup`, `election` (rotate daily) |
331
+ | Niche | `productivity`, `minimalism`, `stoic`, `web3` |
332
+ | Broad | `motivation`, `tips`, `howto`, `viral` |
333
+
334
+ ### Tag Performance Tracking
335
+
336
+ Each tag accumulates engagement history from the agent's own posts:
337
+
338
+ ```
339
+ tag_performance = {
340
+ "ai": 0.72, ← avg engagement when you used this tag
341
+ "fitness": 0.31, ← this tag isn't working for your audience
342
+ "motivation": 0.55,
343
+ ...
344
+ }
345
+ ```
346
+
347
+ Initially all tags start at 0.0 (unknown). As the agent posts with different tags, it builds this signal.
348
+
349
+ ### Tag Dynamics
350
+
351
+ - **Trending tags** change every 24 simulated hours (seeded, deterministic)
352
+ - Using a trending tag gives +10% engagement per trending tag matched
353
+ - Using a high-performing tag (from your history) gives +5% per tag
354
+ - Using an **oversaturated tag** (competitors using it heavily) gives -10%
355
+ - Max 5 tags per post — agent must choose wisely
356
+
357
+ ### What the Agent Must Learn
358
+
359
+ 1. **Discover** which tags work for its audience (explore early, exploit later)
360
+ 2. **Ride trends** — use trending tags when they align with its niche
361
+ 3. **Avoid saturation** — if competitors are all using `#ai`, pivot to `#ml` or `#coding`
362
+ 4. **Combine** high-performing niche tags with 1-2 trending tags for optimal reach+engagement
363
+
364
+ ---
365
+
366
+ ## Competitor Intelligence System
367
+
368
+ ### Simulated Competitors
369
+
370
+ The environment simulates **3 competing creators** in the same niche. Each has:
371
+
372
+ ```python
373
+ competitor = {
374
+ "name": "creator_A",
375
+ "niche_topics": ["AI", "tech", "startups"], # their focus
376
+ "preferred_types": ["reel", "carousel"], # what they mostly post
377
+ "posting_frequency": 2.5, # avg posts/day
378
+ "base_engagement": 0.45, # their avg engagement
379
+ "tag_preferences": ["ai", "startup", "coding"],
380
+ }
381
+ ```
382
+
383
+ ### What the Agent Sees
384
+
385
+ Each step, the observation includes:
386
+
387
+ ```python
388
+ competitor_recent_posts: [
389
+ {"content_type": "reel", "topic": "AI tools", "tags": ["ai", "tools"],
390
+ "engagement": 0.61, "hours_ago": 3},
391
+ {"content_type": "carousel", "topic": "startup tips", "tags": ["startup"],
392
+ "engagement": 0.48, "hours_ago": 8},
393
+ {"content_type": "reel", "topic": "AI news", "tags": ["ai", "news"],
394
+ "engagement": 0.52, "hours_ago": 14},
395
+ ]
396
+ competitor_avg_engagement: 0.54
397
+ niche_saturation: 0.7 # 0.0=empty, 1.0=everyone posting same stuff
398
+ ```
399
+
400
+ ### How Competitors Affect Your Engagement
401
+
402
+ ```
403
+ if your topic overlaps with ≥2 competitor posts in last 12hrs:
404
+ niche_saturation → high (0.7+)
405
+ your engagement × 0.6 (audience already saw similar content)
406
+
407
+ if your topic is unique (no overlap in 12hrs):
408
+ competitor_diff_bonus = 1.3x (fresh angle, algorithm favors)
409
+
410
+ if competitor engagement is HIGH on a topic:
411
+ that topic has proven demand, but also competition
412
+ → agent must decide: follow the proven topic (safe) or differentiate (risky but higher upside)
413
+ ```
414
+
415
+ ### What the Agent Must Learn
416
+
417
+ 1. **Monitor** competitor posting patterns and timing
418
+ 2. **Differentiate** — find underserved time slots and topics
419
+ 3. **Counter-program** — post different content type when competitors flood reels
420
+ 4. **Learn from competitor success** — if competitor's carousel on "AI" got 0.8 engagement, the topic has demand, but post at a different time or with different tags
421
+
422
+ ---
423
+
424
+ ## Tasks & Graders (All Weekly — 168 steps)
425
+
426
+ All three tasks run for exactly **1 week (168 hourly steps)**. The difficulty increases through what dimensions are graded and what constraints apply.
427
+
428
+ ### Task 1: weekly_engage (Easy)
429
+
430
+ **Focus**: Pure engagement maximization.
431
+
432
+ **What's active**: Basic mechanics only — time of day, content type, energy, audience fatigue.
433
+
434
+ **What's NOT graded**: Tags, competitors (still simulated but don't affect score).
435
+
436
+ **Grader formula**:
437
+
438
+ ```
439
+ score = total_engagement / theoretical_max_engagement
440
+ ```
441
+
442
+ **Theoretical max**: Calculated as if agent posted at every peak hour with best content type at full energy. Roughly ~14 optimal posts over 7 days.
443
+
444
+ **How it's computed**:
445
+ 1. Sum all engagement values from every post the agent made
446
+ 2. Divide by the theoretical max (computed from: 2 posts/day × 7 days × peak_hour_mult × best_content_rate × quality=1.0)
447
+ 3. Clamp to [0.0, 1.0]
448
+
449
+ **What a smart agent does**: Posts 1-2x/day at peak hours (12-3PM), uses high-engagement content types (carousel/reel), rests to keep energy above 0.5.
450
+
451
+ **What a dumb agent scores**: Random ≈ 0.08–0.12. Spam-every-hour ≈ 0.15–0.25 (audience fatigue kills it).
452
+
453
+ ---
454
+
455
+ ### Task 2: weekly_strategic (Medium)
456
+
457
+ **Focus**: Engagement + energy management + tag optimization.
458
+
459
+ **What's active**: Everything from Task 1, PLUS tag engagement system.
460
+
461
+ **Grader formula**:
462
+
463
+ ```
464
+ tag_discovery = unique_tags_used_with_positive_engagement / total_tag_pool_size
465
+ tag_exploitation = avg(top_3_tag_performances) / max_possible_tag_performance
466
+
467
+ tag_score = 0.4 × tag_discovery + 0.6 × tag_exploitation
468
+
469
+ score = (0.35 × normalized_engagement)
470
+ + (0.25 × tag_score)
471
+ + (0.25 × avg_energy)
472
+ + (0.15 × consistency_score)
473
+ ```
474
+
475
+ **Constraints**:
476
+ - If energy ever drops below 0.3 → score capped at 0.5
477
+ - If fewer than 5 unique tags used across the week → score × 0.7
478
+
479
+ **How each component works**:
480
+
481
+ | Component | What it measures | How it's normalized |
482
+ |---|---|---|
483
+ | `normalized_engagement` | Total engagement across all posts | `sum(engagement) / theoretical_max` |
484
+ | `tag_discovery` | Did the agent explore different tags? | `unique_positive_tags / 30 (pool size)` |
485
+ | `tag_exploitation` | Did the agent learn which tags work and reuse them? | `avg(best 3 tags) / 1.0` |
486
+ | `avg_energy` | Did the agent maintain sustainable energy? | `mean(energy at each step) / 1.0` |
487
+ | `consistency_score` | Regular posting rhythm | `days_with_1_or_2_posts / 7` |
488
+
489
+ **What a smart agent does**: Explores different tags in days 1-2, identifies top performers by day 3, then exploits them while riding trending tags. Balances rest to keep energy > 0.5.
490
+
491
+ **What a dumb agent scores**: Random ≈ 0.10–0.15 (random tags, no learning). Always-same-tags ≈ 0.20 (no discovery).
492
+
493
+ ---
494
+
495
+ ### Task 3: weekly_competitive (Hard)
496
+
497
+ **Focus**: Everything + competitor awareness + follower growth.
498
+
499
+ **What's active**: Full simulation — engagement, tags, competitors, niche saturation.
500
+
501
+ **Grader formula**:
502
+
503
+ ```
504
+ follower_growth = (final_followers - initial_followers) / initial_followers
505
+ normalized_growth = min(1.0, follower_growth / target_growth_rate)
506
+
507
+ competitor_outperformance = your_avg_engagement / competitor_avg_engagement
508
+ normalized_outperformance = min(1.0, competitor_outperformance / 1.5)
509
+
510
+ differentiation = steps_where_topic_was_unique / total_posting_steps
511
+
512
+ score = (0.25 × normalized_engagement)
513
+ + (0.20 × tag_score) ← same formula as Task 2
514
+ + (0.20 × normalized_growth)
515
+ + (0.15 × normalized_outperformance)
516
+ + (0.10 × differentiation)
517
+ + (0.10 × min_energy_floor)
518
+ ```
519
+
520
+ **Constraints**:
521
+ - Energy hits 0 → score = 0.0 (total fail, burned out)
522
+ - Fewer than 3 content types used → score × 0.5
523
+ - Fewer than 8 unique tags used → score × 0.7
524
+ - If agent never checks competitor patterns (always overlaps) → differentiation = 0
525
+
526
+ **How each component works**:
527
+
528
+ | Component | Weight | What it measures | Detail |
529
+ |---|---|---|---|
530
+ | `normalized_engagement` | 25% | Raw engagement quality | Same as Task 1 |
531
+ | `tag_score` | 20% | Tag strategy quality | Discovery + exploitation (Task 2 formula) |
532
+ | `normalized_growth` | 20% | Follower growth over the week | `target_growth_rate` = 5% (500 new followers on 10K base) |
533
+ | `normalized_outperformance` | 15% | Beat your competitors | Your avg engagement / competitor avg. Capped at 1.0 when you're 1.5x better |
534
+ | `differentiation` | 10% | Posting unique angles | % of your posts where topic wasn't posted by competitors in last 12hrs |
535
+ | `min_energy_floor` | 10% | Never crashed | `min(energy_history)` — lowest energy point. Rewards agents that never dipped dangerously low |
536
+
537
+ **What a smart agent does**:
538
+ 1. Days 1-2: Explore tags, observe competitor patterns
539
+ 2. Days 3-4: Exploit best tags, counter-program competitors (post when they rest, pick gaps)
540
+ 3. Days 5-7: Maximize engagement with learned strategy, maintain energy, diversify content types
541
+
542
+ **What a dumb agent scores**: Random ≈ 0.08. Copy-competitor-strategy ≈ 0.20 (no differentiation). Smart ≈ 0.50–0.75.
543
+
544
+ ---
545
+
546
+ ## Grading Strategy — In Depth
547
+
548
+ ### Why Weekly for All Tasks
549
+
550
+ - **Consistency**: Same horizon (168 steps) makes graders comparable
551
+ - **Runtime**: 168 steps × 3 tasks = 504 total LLM calls. At ~2s per call = ~17 minutes. Under the 20-minute limit
552
+ - **Meaningful cycle**: A week is the natural content planning cycle for creators. Days are too short to show learning. Months are too long for inference budget
553
+
554
+ ### Grading Philosophy
555
+
556
+ The grading is designed so that **each task requires mastering the previous task's skills plus new ones**:
557
+
558
+ ```
559
+ Task 1 (Easy) → Can you post well?
560
+ (timing + content type + energy)
561
+
562
+ Task 2 (Medium) → Can you post SMART?
563
+ (Task 1 + tag discovery + tag exploitation)
564
+
565
+ Task 3 (Hard) → Can you OUTCOMPETE?
566
+ (Task 2 + competitor awareness + differentiation + growth)
567
+ ```
568
+
569
+ ### Why These Weights
570
+
571
+ **Task 1** — Engagement is everything (100% engagement-derived). Pure skill test.
572
+
573
+ **Task 2** — Split focus:
574
+ - 35% engagement (still important, but not enough alone)
575
+ - 25% tags (new skill: must explore AND exploit)
576
+ - 25% energy (sustainability matters now)
577
+ - 15% consistency (rhythm matters)
578
+
579
+ **Task 3** — Multi-dimensional:
580
+ - No single component dominates (max 25%)
581
+ - Agent must be good at everything, great at nothing is fine
582
+ - `differentiation` (10%) is small but acts as tiebreaker between otherwise similar agents
583
+ - `min_energy_floor` (10%) punishes agents that nearly crashed even if they recovered
584
+
585
+ ### Anti-Gaming Properties
586
+
587
+ | Potential Exploit | Why it fails |
588
+ |---|---|
589
+ | Post every hour | Audience fatigue kills engagement → low `normalized_engagement` |
590
+ | Always rest | Zero engagement, zero tag score, zero growth → score ≈ 0.05 |
591
+ | Use same 2 tags always | `tag_discovery` tanks in Task 2/3. Score × 0.7 penalty if < 5/8 tags |
592
+ | Copy competitor topics | `differentiation` = 0, `niche_saturation` high → engagement × 0.6 |
593
+ | Post only reels | Score × 0.5 in Task 3 (need ≥ 3 types) |
594
+ | Ignore competitors entirely | Random overlap → sometimes lucky, but `differentiation` averages low |
595
+ | Post gibberish topics | Topic validation + no trending match → low engagement |
596
+
597
+ ### Score Distribution (Expected)
598
+
599
+ | Agent Type | Task 1 | Task 2 | Task 3 |
600
+ |---|---|---|---|
601
+ | Random | 0.08–0.12 | 0.10–0.15 | 0.06–0.10 |
602
+ | Always rest | 0.02 | 0.05 | 0.02 |
603
+ | Spam (post every step) | 0.15–0.25 | 0.12–0.18 | 0.08–0.15 |
604
+ | Fixed strategy (no learning) | 0.30–0.40 | 0.25–0.35 | 0.20–0.30 |
605
+ | Smart LLM agent | 0.55–0.80 | 0.45–0.70 | 0.40–0.65 |
606
+
607
+ Task 3 is intentionally hardest — even a good agent won't ace it because competitor dynamics add noise and require adaptation.
608
+
609
+ ---
610
+
611
+ ## Anti-Exploit Guards
612
+
613
+ | Exploit | Guard |
614
+ |---|---|
615
+ | Reward hacking (long gibberish) | Cap reward per step at 1.0, validate topic, max 200 chars |
616
+ | Grader gaming | Random agent must score < 0.15, spam agent < 0.30 |
617
+ | State reset abuse | Reset only works between tasks, mid-episode reset ignored |
618
+ | Invalid actions | Strict field validation, invalid → 0 reward + error |
619
+ | Rest farming | Rest → reward ≈ 0, energy is a resource not a goal |
620
+ | Repetitive posting | Same type 3x → engagement -20% + energy penalty |
621
+ | Tag spamming | Max 5 tags per post, must be from known pool |
622
+ | Competitor copying | Niche saturation penalty, differentiation score = 0 |
623
+
624
+ ### Sanity Test Agents
625
+
626
+ Run before submitting:
627
+
628
+ | Agent | Expected Score (Task 3) | Red Flag If |
629
+ |---|---|---|
630
+ | Random agent | < 0.10 | Reward too easy |
631
+ | Always-rest | < 0.05 | Resting rewarded |
632
+ | Spam (post every step, same type) | < 0.15 | No fatigue working |
633
+ | Fixed (same action every time) | < 0.30 | Environment too simple |
634
+ | Smart (LLM-driven) | 0.40–0.65 | This is the real range |
635
+
636
+ ---
637
+
638
+ ## Simulation Mechanics
639
+
640
+ ### Energy Dynamics (research-backed)
641
+
642
+ ```python
643
+ energy -= content_cost[action.content_type]
644
+
645
+ # Repetition fatigue (creative fatigue = 40% of burnout)
646
+ if action.content_type == last_3_posts_type:
647
+ energy -= 0.05
648
+
649
+ # Recovery: slow, not instant
650
+ if action.action_type == "rest":
651
+ energy = min(1.0, energy + 0.12)
652
+
653
+ # Quality modifier (30-52% productivity drop at burnout)
654
+ quality = 1.0 if energy > 0.5 else max(0.48, energy * 1.5)
655
+ ```
656
+
657
+ ### Extended Features
658
+
659
+ #### A. Content Repetition Fatigue
660
+ Same content type 3x in a row → engagement drops 20%. Based on creative fatigue being #1 burnout cause (40%).
661
+
662
+ #### B. Platform Activity / Competition Window
663
+ `niche_saturation` (0.0–1.0) in observation. When many competitors post same topic → per-post engagement drops. From the broadcast scheduling paper (Preprints.org 2025).
664
+
665
+ #### C. Follower Tier Response
666
+ Small accounts (<10K) get more from reels (reach). Large accounts (>50K) benefit from carousels (depth). From CreatorsJet 10K post study.
667
+
668
+ #### D. Trending Topic & Tag Bonus
669
+ If topic or tags match trending → 1.5x and +10% respectively. Topics and tags rotate daily (seeded). Forces adaptive behavior.
670
+
671
+ #### E. Algorithm Penalty for Inconsistency
672
+ No post for 48+ hours → next 2 posts get 0.6x engagement. Based on algorithmic content selection research (arxiv:2410.13108).
673
+
674
+ #### F. Tag Engagement Tracking
675
+ Full per-tag engagement history. Agent sees which tags produce results and must balance exploration (try new tags) vs exploitation (reuse winners). See Tag Engagement System section.
676
+
677
+ #### G. Competitor Awareness
678
+ 3 simulated rival creators with deterministic posting schedules. Agent sees their recent posts, topics, tags, and engagement. Must differentiate to avoid saturation. See Competitor Intelligence System section.
679
+
680
+ ---
681
+
682
+ ## Research Backing
683
+
684
+ ### Engagement Data
685
+
686
+ - **Buffer 2026**: 9.6M posts analyzed — peak posting times, day-of-week effects
687
+ - **SocialInsider 2025**: Engagement rates by content type (carousel 0.55%, reel 0.52%, image 0.37%)
688
+ - **CreatorsJet 10K post study**: Reels give 2.25x reach vs images, carousels give depth
689
+
690
+ ### Burnout Data
691
+
692
+ - **Sozee 2026**: 90% creators experience burnout, 30-52% productivity drop
693
+ - **TastyEdits Creator Study**: 57% spend 4+ hrs/day, 79% have experienced burnout
694
+ - **Creative fatigue**: #1 cause at 40%, algorithm pressure at 38%
695
+
696
+ ### Academic Papers
697
+
698
+ | Paper | Relevance |
699
+ |---|---|
700
+ | "Review Old Strategies, New Environments: RL on Social Media" (ScienceDirect 2024) | RL framework for social media — validates env design |
701
+ | arxiv:2410.13108 "Algorithmic Content Selection and User Disengagement" | Over-optimizing immediate engagement causes churn — justifies burnout mechanic |
702
+ | arxiv:2211.13585 "Learning Optimal Break Policies" | Strategic breaks sustain engagement — supports "rest" action |
703
+ | "Optimizing Broadcast Scheduling" (Preprints.org 2025) | Low-competition windows > frequency — competition variable |
704
+ | RLNVR arxiv:2508.12165 | RL from noisy social media signals — proves this is active research |
705
+
706
+ ### Data Sources
707
+
708
+ - **Meta Content Library**: Real engagement data for public Instagram/Facebook posts ([docs](https://developers.facebook.com/docs/content-library-and-api))
709
+ - **Meta Graph API — Creator Marketplace Insights**: Real creator metrics ([docs](https://developers.facebook.com/docs/graph-api/reference/creator-marketplace-content/insights/))
710
+
711
+ ---
712
+
713
+ ## Inference Script Structure
714
+
715
+ ```python
716
+ import os
717
+ from openai import OpenAI
718
+ from viraltest import ViraltestEnv, ViraltestAction
719
+
720
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
721
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
722
+ MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
723
+ TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
724
+ MAX_STEPS = 168 # 7 days × 24 hours (same for all tasks)
725
+
726
+ client = OpenAI(api_key=API_KEY, base_url=API_BASE_URL)
727
+
728
+ for task in TASKS:
729
+ log_start(task, "viraltest", MODEL_NAME)
730
+ env = ViraltestEnv(base_url="http://localhost:8000")
731
+ result = env.reset(task=task)
732
+ rewards = []
733
+
734
+ for step in range(MAX_STEPS):
735
+ obs = result.observation
736
+ user_msg = format_observation(obs)
737
+ response = client.chat.completions.create(
738
+ model=MODEL_NAME,
739
+ messages=[
740
+ {"role": "system", "content": SYSTEM_PROMPT},
741
+ {"role": "user", "content": user_msg}
742
+ ],
743
+ temperature=0.7, max_tokens=150
744
+ )
745
+ action = parse_action(response.choices[0].message.content)
746
+ result = env.step(action)
747
+ rewards.append(result.reward)
748
+ log_step(step+1, str(action), result.reward, result.done, None)
749
+ if result.done:
750
+ break
751
+
752
+ score = grader_score(task, rewards, obs)
753
+ log_end(score > 0.1, len(rewards), score, rewards)
754
+ env.close()
755
+ ```
756
+
757
+ Log format:
758
+
759
+ ```
760
+ [START] task=weekly_competitive env=viraltest model=Qwen/Qwen2.5-72B-Instruct
761
+ [STEP] step=1 action=post(reel,"AI trends",["ai","tech"]) reward=0.67 done=false error=null
762
+ [STEP] step=2 action=rest() reward=0.05 done=false error=null
763
+ ...
764
+ [END] success=true steps=168 score=0.624 rewards=0.67,0.05,...,0.55
765
+ ```
766
+
767
+ ---
768
+
769
+ ## Judging Alignment
770
+
771
+ | Criteria | Weight | What backs us |
772
+ |---|---|---|
773
+ | Real-world utility | 30% | Meta Content Library, Buffer study, creator burnout stats, tag analytics, competitor analysis |
774
+ | Task & grader quality | 25% | 3 weekly tasks with progressive difficulty, multi-component graders, deterministic |
775
+ | Environment design | 20% | Energy from burnout studies, engagement from SocialInsider, tag + competitor systems |
776
+ | Code quality & spec | 15% | OpenEnv compliant, typed models, Dockerfile works |
777
+ | Creativity & novelty | 10% | Multi-objective (engagement vs burnout vs tags vs competition), backed by 5+ papers |
778
+
779
+ ---
780
+
781
+ ## File Map
782
+
783
+ | File | Purpose |
784
+ |---|---|
785
+ | `models.py` | `ViraltestAction` and `ViraltestObservation` Pydantic models |
786
+ | `server/viraltest_environment.py` | Simulation logic, task switching, graders, reward calc, tag + competitor systems |
787
+ | `client.py` | `ViraltestEnv` client — `_step_payload`, `_parse_result`, `_parse_state` |
788
+ | `inference.py` | LLM-driven agent with `[START]`/`[STEP]`/`[END]` logging |
789
+ | `openenv.yaml` | Environment metadata |
790
+ | `Dockerfile` | Container build |
791
+ | `README.md` | User-facing docs |
792
+ | `DESIGN.md` | This file |
Dockerfile ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=viraltest
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ ENV ENABLE_WEB_INTERFACE=true
75
+
76
+ # Health check
77
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
78
+ CMD curl -f http://localhost:8000/health || exit 1
79
+
80
+ # Run the FastAPI server
81
+ # The module path is constructed to work with the /app/env structure
82
+ CMD ["sh", "-c", "cd /app/env && uvicorn viraltest.server.app:app --host 0.0.0.0 --port 8000"]
README.md ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Viraltest — Creator Optimization Agent
3
+ emoji: 📊
4
+ colorFrom: yellow
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # Viraltest — RL-Based Creator Optimization Environment
15
+
16
+ An [OpenEnv](https://github.com/meta-pytorch/OpenEnv) environment that simulates a social media creator’s weekly posting lifecycle. An AI agent learns **when to post**, **what format**, **which tags**, and **how to differentiate from competitors** — maximizing engagement while managing burnout and sleep.
17
+
18
+ ## Submission requirements — how this repo maps
19
+
20
+ Use this table to confirm Phase 1 (automated) gates before you submit.
21
+
22
+ | Requirement | Status in this repo | Where to verify |
23
+ |---------------|---------------------|-----------------|
24
+ | Real-world task (not a toy/game) | **Met** — creator scheduling, energy, trends, competitors | `server/viraltest_environment.py`, `DESIGN.md` |
25
+ | Full OpenEnv spec: `openenv.yaml`, typed models, HTTP API | **Met** | `openenv.yaml`, `models.py`, `server/app.py` (`create_app`) |
26
+ | `step()` / `reset()` / `state()` | **Met** — standard OpenEnv HTTP endpoints | Run `openenv validate` |
27
+ | ≥3 tasks with graders (easy → hard), scores in **0.0–1.0** | **Met** — `weekly_engage`, `weekly_strategic`, `weekly_competitive` | `_run_grader()` in `server/viraltest_environment.py` |
28
+ | Meaningful reward + partial progress | **Met** — per-step `_compute_reward()` | `_compute_reward()` |
29
+ | Baseline inference script, reproducible | **Met** — root `inference.py` | See **Baseline inference** below |
30
+ | `Dockerfile` builds | **Expected** — root `Dockerfile` | `docker build -t viraltest .` (run locally) |
31
+ | HF Space deploys; `POST /reset` returns **200** | **You must configure** | See **Hugging Face Spaces** — ping **Space root**, not only `/web` |
32
+ | `openenv validate` passes | **Met** in dev (`.venv/bin/openenv validate`) | CI / local |
33
+ | Env vars: `API_BASE_URL`, `MODEL_NAME`, `HF_TOKEN` | **Documented** — `inference.py` reads them (see **Environment variables**) | HF Space **Settings → Secrets** |
34
+ | `inference.py` at repo root; OpenAI client for LLM calls | **Met** | `inference.py` |
35
+ | Structured stdout: `[START]`, `[STEP]`, `[END]` | **Met** — match field order in `log_*` helpers | `inference.py` |
36
+ | Inference under 20 minutes; 2 vCPU / 8 GB | **Check** — 3 tasks × up to 168 steps each = many LLM calls; use a fast endpoint and sensible `MAX_TOKENS` | `inference.py` |
37
+
38
+ ### Minor items to double-check before judging
39
+
40
+ 1. **`[STEP]` `error=` field** — The spec asks for the raw `last_action_error` or `null`. This repo logs errors with spaces replaced by underscores so each line stays a single token after `error=`. If the organizer’s parser expects literal spaces inside unquoted messages, align with their sample; otherwise this is fine for one-line logs.
41
+ 2. **Default `API_BASE_URL` in `inference.py`** — Defaults are for local dev. On Hugging Face, set **`API_BASE_URL`** (e.g. `https://router.huggingface.co/v1`) and **`MODEL_NAME`** in Secrets so evaluation matches your setup.
42
+ 3. **Space URL for the validator** — The official script POSTs to `{your_space_url}/reset` with body `{}`. That must be the **root** of the Space (e.g. `https://YOURNAME-spacename.hf.space`), not the Gradio path under `base_path: /web`. Confirm with curl (see **Pre-submission validation**).
43
+
44
+ ---
45
+
46
+ ## Why this matters
47
+
48
+ Many creators burn out while optimizing posting times and formats. This environment turns that tradeoff into a reproducible simulation so agents can be trained and compared on the same weekly horizon (**168** hourly steps).
49
+
50
+ ---
51
+
52
+ ## Quick Start (Python)
53
+
54
+ The HTTP client is **async** (same pattern as root `inference.py`):
55
+
56
+ ```python
57
+ import asyncio
58
+ from viraltest import ViraltestAction, ViraltestEnv
59
+
60
+ async def main():
61
+ env = ViraltestEnv(base_url="http://localhost:8000")
62
+ try:
63
+ result = await env.reset(task="weekly_engage")
64
+ action = ViraltestAction(
65
+ action_type="post",
66
+ content_type="reel",
67
+ topic="AI trends",
68
+ tags=["ai", "coding", "devtools"],
69
+ )
70
+ result = await env.step(action)
71
+ print(result.observation.engagement_rate, result.observation.creator_energy)
72
+ finally:
73
+ await env.close()
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Action space
81
+
82
+ | Field | Type | Description |
83
+ |-------|------|-------------|
84
+ | `action_type` | `"post" \| "rest" \| "create_content"` | What the agent does this hour |
85
+ | `content_type` | `"reel" \| "story" \| "carousel" \| "text_post"` | Required when posting |
86
+ | `topic` | `str` (≤200 chars) | Post topic |
87
+ | `tags` | `list[str]` (≤5) | Tags from the environment tag pool |
88
+
89
+ ---
90
+
91
+ ## Observation space (high level)
92
+
93
+ | Field | Description |
94
+ |-------|-------------|
95
+ | `current_hour`, `day_of_week`, `days_elapsed` | Simulated calendar |
96
+ | `creator_energy`, `hours_since_sleep`, `sleep_debt` | Burnout and sleep |
97
+ | `follower_count`, `engagement_rate` | Growth and rolling engagement |
98
+ | `trending_topics`, `trending_tags`, `tag_performance` | Trends and learned tag quality |
99
+ | `competitor_recent_posts`, `competitor_avg_engagement`, `niche_saturation` | Competition |
100
+ | `error`, `reward`, `done`, `metadata` | Errors, shaping reward, termination, **`metadata["grader_score"]` at episode end** |
101
+
102
+ Full schema: `GET /schema` when the server is running.
103
+
104
+ ---
105
+
106
+ ## Tasks and graders (168 steps each)
107
+
108
+ | Task | Difficulty | Grader focus |
109
+ |------|------------|--------------|
110
+ | `weekly_engage` | Easier | Total engagement vs theoretical max; burnout penalty |
111
+ | `weekly_strategic` | Medium | Engagement + tag discovery/exploitation + energy + consistency |
112
+ | `weekly_competitive` | Hard | Adds growth vs competitors, differentiation, diversity constraints |
113
+
114
+ Episode ends after **168** steps or if **energy ≤ 0**. Final normalized score is in **`observation.metadata["grader_score"]`** in **\[0, 1\]**.
115
+
116
+ ---
117
+
118
+ ## Reward shaping
119
+
120
+ Per-step reward in **`[0, 1]`** combines engagement, energy change, posting consistency, tags, and competitor differentiation (`_compute_reward` in `server/viraltest_environment.py`). It is dense enough for learning signals before the terminal grader runs.
121
+
122
+ ---
123
+
124
+ ## Local development
125
+
126
+ ```bash
127
+ git clone <your-repo-url>
128
+ cd viral-posts-env # or your fork name
129
+
130
+ # Install (uv recommended; pip works too)
131
+ uv sync
132
+ # source .venv/bin/activate # optional
133
+
134
+ # Terminal 1 — API server
135
+ uvicorn viraltest.server.app:app --host 0.0.0.0 --port 8000
136
+
137
+ # Terminal 2 — optional UI
138
+ # Open http://localhost:8000/dashboard (see server routes in server/app.py)
139
+ ```
140
+
141
+ Validate the OpenEnv layout:
142
+
143
+ ```bash
144
+ .venv/bin/openenv validate
145
+ # Expect: [OK] ... Ready for multi-mode deployment
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Docker
151
+
152
+ From the repository root (same directory as `Dockerfile`):
153
+
154
+ ```bash
155
+ docker build -t viraltest-env:latest .
156
+ docker run --rm -p 8000:8000 viraltest-env:latest
157
+ ```
158
+
159
+ Smoke test:
160
+
161
+ ```bash
162
+ curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{}' http://localhost:8000/reset
163
+ # Expect: 200
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Hugging Face Spaces — deploy
169
+
170
+ 1. **Create a Space** with **Docker** SDK (this repo’s README frontmatter uses `sdk: docker`).
171
+ 2. **Push this repository** (or connect GitHub) so the Space builds from the root `Dockerfile`.
172
+ 3. **Settings → Variables and secrets** — add at least:
173
+ - **`HF_TOKEN`** — Hugging Face API token for inference (and Space pull if private).
174
+ - **`API_BASE_URL`** — OpenAI-compatible base URL (e.g. `https://router.huggingface.co/v1`).
175
+ - **`MODEL_NAME`** — Model id for that router (e.g. `Qwen/Qwen2.5-72B-Instruct`).
176
+ 4. **App port** — `8000` (see frontmatter `app_port: 8000`).
177
+ 5. **`base_path: /web`** — Used for the bundled web UI; the **REST** endpoints (`/reset`, `/step`, `/state`) remain on the **Space root host** as required by the submission validator. **Always test** `https://<your-space>.hf.space/reset` (not only `/web/...`).
178
+
179
+ Optional CLI (if you use OpenEnv’s tooling):
180
+
181
+ ```bash
182
+ pip install openenv-core
183
+ openenv push # follow OpenEnv docs for auth and target Space
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Baseline inference (`inference.py`)
189
+
190
+ **Location:** repository root — **`inference.py`** (required by the hackathon).
191
+
192
+ **LLM client:** OpenAI-compatible client (`from openai import OpenAI`) using:
193
+
194
+ | Variable | Role |
195
+ |----------|------|
196
+ | `API_BASE_URL` | OpenAI-compatible API base |
197
+ | `MODEL_NAME` | Model name for `chat.completions` |
198
+ | `HF_TOKEN` | Preferred API key (fallbacks: `OPENAI_API_KEY`, `API_KEY`) |
199
+ | `IMAGE_NAME` / `LOCAL_IMAGE_NAME` | If using `ViraltestEnv.from_docker_image(...)` instead of HTTP |
200
+ | `ENV_BASE_URL` | HTTP server URL (default `http://localhost:8000`) |
201
+
202
+ **Stdout format (must not change field names or order):**
203
+
204
+ ```text
205
+ [START] task=<name> env=<benchmark> model=<model>
206
+ [STEP] step=<n> action=<str> reward=<0.00> done=<true|false> error=<msg|null>
207
+ [END] success=<true|false> steps=<n> score=<0.00> rewards=<r1,r2,...>
208
+ ```
209
+
210
+ Run locally (server on port 8000):
211
+
212
+ ```bash
213
+ export HF_TOKEN=hf_...
214
+ export API_BASE_URL=https://router.huggingface.co/v1
215
+ export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
216
+ uv sync && .venv/bin/python inference.py
217
+ ```
218
+
219
+ **Short episodes for debugging** — `ALLOW_SHORT_EPISODE=1` and `MAX_STEPS` can shorten runs; full weekly tasks still use **168** steps unless you override (see comments in `inference.py`).
220
+
221
+ ---
222
+
223
+ ## Pre-submission validation
224
+
225
+ Use the provided script (same checks as the official template: ping Space, Docker build, `openenv validate`):
226
+
227
+ ```bash
228
+ chmod +x validate-submission.sh
229
+ ./validate-submission.sh https://YOUR-SPACE.hf.space /path/to/viral-posts-env
230
+ ```
231
+
232
+ Or download the organizer’s script from their repo and pass your Space URL.
233
+
234
+ **Manual ping (required to pass automated gate):**
235
+
236
+ ```bash
237
+ curl -s -o /dev/null -w "%{http_code}\n" -X POST \
238
+ -H "Content-Type: application/json" -d '{}' \
239
+ https://YOUR-SPACE.hf.space/reset
240
+ # Must print: 200
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Baseline scores (reference)
246
+
247
+ Deterministic dashboard agents (not the LLM) — see `README` tables in-repo history / `DESIGN.md` for methodology. Your **`inference.py`** scores will vary by model and endpoint; keep runs under the **20-minute** inference budget.
248
+
249
+ ---
250
+
251
+ ## Project structure
252
+
253
+ ```
254
+ .
255
+ ├── inference.py # Hackathon-required baseline (LLM + [START]/[STEP]/[END])
256
+ ├── openenv.yaml # OpenEnv manifest
257
+ ├── models.py # ViraltestAction, ViraltestObservation
258
+ ├── client.py # ViraltestEnv client
259
+ ├── Dockerfile
260
+ ├── validate-submission.sh # Local preflight
261
+ ├── test_scenarios.py # Offline env tests
262
+ ├── DESIGN.md # Deep design / research notes
263
+ └── server/
264
+ ├── app.py # FastAPI + create_app
265
+ ├── viraltest_environment.py
266
+ └── dashboard.html
267
+ ```
268
+
269
+ ---
270
+
271
+ ## License
272
+
273
+ See `LICENSE` in the repository root (BSD-style per upstream OpenEnv examples).
SIMULATION_REPORT.md ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Viraltest Simulation Report
2
+
3
+ **Task:** Hard — Competitive (weekly_competitive)
4
+ **Episode Length:** 168 steps (7 days x 24 hours)
5
+ **Starting Followers:** 10,000 | **Starting Energy:** 1.00
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ 11 agent strategies were evaluated on the Hard — Competitive task. The **Balanced Creator** (0.8775) and **Smart Agent** (0.8745) achieved the highest scores by combining strategic posting, energy management, and tag diversity. Two agents (**Spam Post**, **No Rest**) burned out within 8 steps, scoring 0.0000. The **Always Rest** agent lost 45% of its followers from inactivity.
12
+
13
+ ---
14
+
15
+ ## Leaderboard
16
+
17
+ | Rank | Scenario | Score | Followers | Delta | Energy | Burned Out |
18
+ |------|----------|-------|-----------|-------|--------|------------|
19
+ | 1 | Balanced Creator | **0.8775** | 12,534 | +2,534 (+25.3%) | 1.00 | No |
20
+ | 2 | Smart Agent | **0.8745** | 12,200 | +2,200 (+22.0%) | 1.00 | No |
21
+ | 3 | Tag Explorer | **0.8323** | 11,351 | +1,351 (+13.5%) | 0.94 | No |
22
+ | 4 | Copycat | **0.6136** | 11,589 | +1,589 (+15.9%) | 1.00 | No |
23
+ | 5 | Burst Poster | **0.6111** | 11,701 | +1,701 (+17.0%) | 0.44 | No |
24
+ | 6 | Queue Optimizer | **0.3520** | 11,215 | +1,215 (+12.2%) | 1.00 | No |
25
+ | 7 | Weekend Warrior | **0.1257** | 7,659 | -2,341 (-23.4%) | 1.00 | No |
26
+ | 8 | Night Poster | **0.0937** | 10,237 | +237 (+2.4%) | 0.59 | No |
27
+ | 9 | Always Rest | **0.0350** | 5,497 | -4,503 (-45.0%) | 1.00 | No |
28
+ | 10 | Spam Post | **0.0000** | 10,625 | +625 (+6.3%) | 0.00 | **YES** |
29
+ | 11 | No Rest | **0.0000** | 10,213 | +213 (+2.1%) | 0.00 | **YES** |
30
+
31
+ ---
32
+
33
+ ## Detailed Agent Analysis
34
+
35
+ ### 1. Balanced Creator — Score: 0.8775 (BEST)
36
+
37
+ | Metric | Value |
38
+ |--------|-------|
39
+ | Steps Completed | 168 / 168 |
40
+ | Final Energy | 1.00 |
41
+ | Final Followers | 12,534 (+25.3%) |
42
+ | Engagement Rate | 0.827 |
43
+ | Total Posts | 28 |
44
+ | Total Rests | 84 |
45
+ | Content Created | 56 |
46
+ | Unique Tags | 19 |
47
+ | Min Energy | 0.795 (never dipped below safe zone) |
48
+ | Avg Reward | 0.219 |
49
+ | Max Reward | 0.738 |
50
+
51
+ **Strategy:** Create → Post → Rest cycle. Uses the content queue (56 items created, 28 posted from queue at 50% energy cost). Posts during peak hours with trending topics. Never risks burnout.
52
+
53
+ **Top Tags:** #food (1.32), #election (1.31), #coding (1.16), #saas (1.03), #crypto (1.02)
54
+
55
+ **Why it won:** Highest follower growth (+2,534), perfect energy management (never below 0.795), excellent tag diversity (19 unique), and consistent daily posting.
56
+
57
+ ---
58
+
59
+ ### 2. Smart Agent — Score: 0.8745
60
+
61
+ | Metric | Value |
62
+ |--------|-------|
63
+ | Steps Completed | 168 / 168 |
64
+ | Final Energy | 1.00 |
65
+ | Final Followers | 12,200 (+22.0%) |
66
+ | Engagement Rate | 1.556 |
67
+ | Total Posts | 14 |
68
+ | Total Rests | 154 |
69
+ | Unique Tags | 19 |
70
+ | Min Energy | 0.55 |
71
+ | Avg Reward | 0.230 |
72
+ | Max Reward | 0.760 |
73
+
74
+ **Strategy:** Posts only during peak hours (9-20) when energy > 0.4 and posts < 2/day. Uses trending topics and tags. Rests aggressively.
75
+
76
+ **Top Tags:** #ai (3.56), #wellness (2.55), #summer (2.36), #crypto (2.18), #newyear (2.01)
77
+
78
+ **Why it's strong:** Highest individual tag performance (#ai at 3.56), highest engagement rate (1.556), but fewer posts (14 vs 28) cost it the top spot.
79
+
80
+ ---
81
+
82
+ ### 3. Tag Explorer — Score: 0.8323
83
+
84
+ | Metric | Value |
85
+ |--------|-------|
86
+ | Steps Completed | 168 / 168 |
87
+ | Final Energy | 0.94 |
88
+ | Final Followers | 11,351 (+13.5%) |
89
+ | Engagement Rate | 0.774 |
90
+ | Total Posts | 15 |
91
+ | Unique Tags | **30** (highest) |
92
+ | Min Energy | 0.69 |
93
+
94
+ **Strategy:** New tag combination every post. Maximizes tag discovery — 30 unique tags used (the highest of all agents).
95
+
96
+ **Why it scored high:** The grading formula rewards tag diversity heavily. 30 unique tags gave a massive tag_discovery bonus.
97
+
98
+ ---
99
+
100
+ ### 4. Copycat — Score: 0.6136
101
+
102
+ | Metric | Value |
103
+ |--------|-------|
104
+ | Steps Completed | 168 / 168 |
105
+ | Final Energy | 1.00 |
106
+ | Final Followers | 11,589 (+15.9%) |
107
+ | Total Posts | 21 |
108
+ | Unique Tags | 8 |
109
+ | Min Energy | 0.10 (dangerous dip!) |
110
+
111
+ **Strategy:** Copies competitor topics and content types. Posts when competitors are active.
112
+
113
+ **Weakness:** High niche saturation from copying rivals. Only 8 unique tags (penalized). Min energy hit 0.10 — nearly burned out.
114
+
115
+ ---
116
+
117
+ ### 5. Burst Poster — Score: 0.6111
118
+
119
+ | Metric | Value |
120
+ |--------|-------|
121
+ | Steps Completed | 168 / 168 |
122
+ | Final Energy | 0.44 |
123
+ | Final Followers | 11,701 (+17.0%) |
124
+ | Total Posts | **57** (highest) |
125
+ | Unique Tags | 13 |
126
+ | Min Energy | 0.25 |
127
+
128
+ **Strategy:** 3 posts in rapid succession, then rests until recovered. Repeat.
129
+
130
+ **Weakness:** Ended with only 0.44 energy. 57 posts caused audience fatigue (posts > 3/day get heavy penalty). Low per-post engagement (0.208) despite high volume.
131
+
132
+ ---
133
+
134
+ ### 6. Queue Optimizer — Score: 0.3520
135
+
136
+ | Metric | Value |
137
+ |--------|-------|
138
+ | Steps Completed | 168 / 168 |
139
+ | Final Energy | 1.00 |
140
+ | Final Followers | 11,215 (+12.2%) |
141
+ | Total Posts | 14 |
142
+ | Content Created | 17 |
143
+ | Unique Tags | 12 |
144
+
145
+ **Strategy:** Creates content first (builds queue), then posts from queue at half energy cost.
146
+
147
+ **Weakness:** Spent too long in "prep" phase creating content. Only 14 actual posts despite 17 items queued. Score penalized for under-utilizing the queue.
148
+
149
+ ---
150
+
151
+ ### 7. Weekend Warrior — Score: 0.1257
152
+
153
+ | Metric | Value |
154
+ |--------|-------|
155
+ | Steps Completed | 168 / 168 |
156
+ | Final Followers | 7,659 **(-23.4%)** |
157
+ | Total Posts | 6 |
158
+ | Unique Tags | 6 |
159
+
160
+ **Strategy:** Only posts on Saturday and Sunday. Rests Mon-Fri.
161
+
162
+ **Weakness:** 5 days of inactivity triggered follower decay (-2,341) and algorithm penalty. Only 6 posts total. Weekend posting also gets a 0.7x penalty multiplier.
163
+
164
+ ---
165
+
166
+ ### 8. Night Poster — Score: 0.0937
167
+
168
+ | Metric | Value |
169
+ |--------|-------|
170
+ | Steps Completed | 168 / 168 |
171
+ | Final Followers | 10,237 (+2.4%) |
172
+ | Total Posts | 49 |
173
+ | Unique Tags | 2 |
174
+ | Engagement Rate | 0.036 |
175
+
176
+ **Strategy:** Posts exclusively at night (23:00-06:00) with boring topics.
177
+
178
+ **Weakness:** Night hours get 0.5x multiplier. Only 2 unique tags (#stoic, #minimalism) — severe tag penalty. Despite 49 posts, engagement was near-zero (0.036).
179
+
180
+ ---
181
+
182
+ ### 9. Always Rest — Score: 0.0350
183
+
184
+ | Metric | Value |
185
+ |--------|-------|
186
+ | Steps Completed | 168 / 168 |
187
+ | Final Followers | 5,497 **(-45.0%)** |
188
+ | Total Posts | 0 |
189
+ | Engagement Rate | 0.000 |
190
+
191
+ **Strategy:** Never posts. Rests every step.
192
+
193
+ **Result:** Zero engagement. Lost 4,503 followers (45%) to decay. Algorithm penalty stacked from inactivity. Energy stayed at 1.00 — completely wasted.
194
+
195
+ ---
196
+
197
+ ### 10. Spam Post — Score: 0.0000
198
+
199
+ | Metric | Value |
200
+ |--------|-------|
201
+ | Steps Completed | **4** / 168 |
202
+ | Final Energy | **0.00 (BURNED OUT)** |
203
+ | Final Followers | 10,625 (+6.3%) |
204
+
205
+ **Strategy:** Posts the same reel with "AI tools" topic every step. No rest.
206
+
207
+ **Result:** Burned out at step 4. Each reel costs 0.25 energy. 4 reels = 1.00 energy drained. Episode ended at step 4 with score 0.0000 (burnout = automatic fail on competitive task).
208
+
209
+ ---
210
+
211
+ ### 11. No Rest — Score: 0.0000
212
+
213
+ | Metric | Value |
214
+ |--------|-------|
215
+ | Steps Completed | **8** / 168 |
216
+ | Final Energy | **0.00 (BURNED OUT)** |
217
+ | Final Followers | 10,213 (+2.1%) |
218
+
219
+ **Strategy:** Posts varied content types but never rests.
220
+
221
+ **Result:** Burned out at step 8. Mixed content types (reel, carousel, story, text_post) averaged ~0.125 energy cost. 8 posts without rest = burnout. Score: 0.0000.
222
+
223
+ ---
224
+
225
+ ## Key Metrics Comparison
226
+
227
+ ### Energy Management
228
+ | Agent | Min Energy | Final Energy | Energy Safety |
229
+ |-------|-----------|--------------|---------------|
230
+ | Always Rest | 1.000 | 1.00 | Wasted |
231
+ | Balanced | 0.795 | 1.00 | Excellent |
232
+ | Tag Explorer | 0.690 | 0.94 | Good |
233
+ | Queue Optimizer | 0.610 | 1.00 | Good |
234
+ | Smart Agent | 0.550 | 1.00 | Good |
235
+ | Burst Poster | 0.250 | 0.44 | Risky |
236
+ | Night Poster | 0.230 | 0.59 | Dangerous |
237
+ | Copycat | 0.100 | 1.00 | Near-fatal dip |
238
+ | Weekend | 0.100 | 1.00 | Near-fatal dip |
239
+ | No Rest | 0.000 | 0.00 | BURNED OUT |
240
+ | Spam Post | 0.000 | 0.00 | BURNED OUT |
241
+
242
+ ### Posting Volume vs Quality
243
+ | Agent | Posts | Engagement Rate | Engagement per Post |
244
+ |-------|-------|----------------|---------------------|
245
+ | Burst | 57 | 0.208 | Low (fatigue) |
246
+ | Night Poster | 49 | 0.036 | Very low (timing) |
247
+ | Balanced | 28 | 0.827 | High |
248
+ | Copycat | 21 | 0.497 | Medium |
249
+ | Tag Explorer | 15 | 0.774 | High |
250
+ | Smart Agent | 14 | 1.556 | Very high |
251
+ | Queue Opt | 14 | 0.870 | High |
252
+ | Weekend | 6 | 0.635 | Medium |
253
+ | Spam | 4 | 1.567 | High (but burned out) |
254
+
255
+ ---
256
+
257
+ ## Lessons Learned
258
+
259
+ 1. **Burnout is fatal** — On the competitive task, burnout = score 0.0000. Energy management is the #1 priority.
260
+
261
+ 2. **Quality > Quantity** — Smart Agent posted only 14 times but had the highest engagement rate (1.556). Burst posted 57 times but scored lower.
262
+
263
+ 3. **Tag diversity matters** — Tag Explorer's 30 unique tags boosted its score to 0.8323 despite moderate engagement. Night Poster's 2 tags destroyed its score.
264
+
265
+ 4. **Content queue is powerful** — Balanced Creator used create_content (56 times) to build a queue, then posted at half energy cost. This enabled 28 posts while maintaining 0.795+ energy.
266
+
267
+ 5. **Timing is critical** — Night Poster proved that posting at wrong hours (0.5x multiplier) wastes energy for near-zero engagement.
268
+
269
+ 6. **Copying competitors backfires** — Copycat achieved decent followers but niche saturation penalty and low tag diversity (8) capped its score at 0.6136.
270
+
271
+ 7. **Consistency beats bursts** — Posting 1-2/day consistently (Balanced, Smart) scored higher than bursting 3+ posts then resting (Burst).
272
+
273
+ ---
274
+
275
+ *Report generated from Viraltest Creator Intelligence Center*
276
+ *Task: weekly_competitive | 168 hourly steps | 3 competitor profiles*
__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Viraltest Environment."""
8
+
9
+ from .client import ViraltestEnv
10
+ from .models import ScheduledAction, ViraltestAction, ViraltestObservation
11
+
12
+ __all__ = [
13
+ "ScheduledAction",
14
+ "ViraltestAction",
15
+ "ViraltestObservation",
16
+ "ViraltestEnv",
17
+ ]
client.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Viraltest Environment Client."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from openenv.core import EnvClient
6
+ from openenv.core.client_types import StepResult
7
+ from openenv.core.env_server.types import State
8
+
9
+ from .models import ViraltestAction, ViraltestObservation
10
+
11
+
12
+ class ViraltestEnv(
13
+ EnvClient[ViraltestAction, ViraltestObservation, State]
14
+ ):
15
+ """
16
+ Client for the Viraltest Creator Optimization Environment.
17
+
18
+ Maintains a persistent WebSocket connection to the environment server.
19
+
20
+ Example:
21
+ >>> with ViraltestEnv(base_url="http://localhost:8000") as client:
22
+ ... result = client.reset(task="weekly_engage")
23
+ ... result = client.step(ViraltestAction(
24
+ ... scheduled_actions=[
25
+ ... {"hour": 12, "action_type": "post", "content_type": "reel",
26
+ ... "topic": "AI trends", "tags": ["ai", "tech"]},
27
+ ... ]
28
+ ... ))
29
+ """
30
+
31
+ def _step_payload(self, action: ViraltestAction) -> Dict[str, Any]:
32
+ actions_list = []
33
+ for sa in action.scheduled_actions:
34
+ item: Dict[str, Any] = {
35
+ "hour": sa.hour,
36
+ "action_type": sa.action_type,
37
+ }
38
+ if sa.content_type is not None:
39
+ item["content_type"] = sa.content_type
40
+ if sa.topic is not None:
41
+ item["topic"] = sa.topic
42
+ if sa.tags is not None:
43
+ item["tags"] = sa.tags
44
+ actions_list.append(item)
45
+ return {"scheduled_actions": actions_list}
46
+
47
+ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ViraltestObservation]:
48
+ obs_data = payload.get("observation", {})
49
+ grader_score = obs_data.get("grader_score")
50
+ meta = obs_data.get("metadata", {})
51
+ if grader_score is not None:
52
+ meta["grader_score"] = grader_score
53
+ observation = ViraltestObservation(
54
+ current_hour=obs_data.get("current_hour", 0),
55
+ day_of_week=obs_data.get("day_of_week", 0),
56
+ days_elapsed=obs_data.get("days_elapsed", 0),
57
+ creator_energy=obs_data.get("creator_energy", 1.0),
58
+ follower_count=obs_data.get("follower_count", 0),
59
+ engagement_rate=obs_data.get("engagement_rate", 0.0),
60
+ hours_since_sleep=obs_data.get("hours_since_sleep", 0),
61
+ posts_today=obs_data.get("posts_today", 0),
62
+ sleep_debt=obs_data.get("sleep_debt", 0.0),
63
+ time_since_last_post=obs_data.get("time_since_last_post", 0),
64
+ trending_topics=obs_data.get("trending_topics", []),
65
+ content_queue_size=obs_data.get("content_queue_size", 0),
66
+ last_post_type=obs_data.get("last_post_type", "none"),
67
+ tag_performance=obs_data.get("tag_performance", {}),
68
+ trending_tags=obs_data.get("trending_tags", []),
69
+ competitor_recent_posts=obs_data.get("competitor_recent_posts", []),
70
+ competitor_avg_engagement=obs_data.get("competitor_avg_engagement", 0.0),
71
+ niche_saturation=obs_data.get("niche_saturation", 0.0),
72
+ daily_total_engagement=obs_data.get("daily_total_engagement", 0.0),
73
+ daily_posts_made=obs_data.get("daily_posts_made", 0),
74
+ daily_energy_min=obs_data.get("daily_energy_min", 1.0),
75
+ grader_score=grader_score,
76
+ error=obs_data.get("error"),
77
+ done=payload.get("done", False),
78
+ reward=payload.get("reward"),
79
+ metadata=meta,
80
+ )
81
+ return StepResult(
82
+ observation=observation,
83
+ reward=payload.get("reward"),
84
+ done=payload.get("done", False),
85
+ )
86
+
87
+ def _parse_state(self, payload: Dict[str, Any]) -> State:
88
+ return State(
89
+ episode_id=payload.get("episode_id"),
90
+ step_count=payload.get("step_count", 0),
91
+ )
inference.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Viraltest Inference Script — RL-Based Creator Optimization Agent
3
+ ===================================
4
+ MANDATORY
5
+ - Before submitting, ensure the following variables are defined in your environment configuration:
6
+ API_BASE_URL The API endpoint for the LLM.
7
+ MODEL_NAME The model identifier to use for inference.
8
+ HF_TOKEN or OPENAI_API_KEY or API_KEY API key for the LLM client.
9
+ IMAGE_NAME or LOCAL_IMAGE_NAME Docker image when using ViraltestEnv.from_docker_image()
10
+
11
+ Optional:
12
+ ALLOW_SHORT_EPISODE=1 Allow MAX_STEPS below 7 (final grader score stays 0 if episode never ends).
13
+ MAX_STEPS Step cap (default 7). Without ALLOW_SHORT_EPISODE, cap is at least 7 so graders run.
14
+
15
+ Each step = one full day. The agent submits a sparse daily plan (only posts and create_content
16
+ actions at specific hours). Unlisted hours automatically become rest.
17
+
18
+ STDOUT FORMAT (single space after tag; score two decimals) — match hackathon sample exactly.
19
+ """
20
+
21
+ import asyncio
22
+ import json
23
+ import os
24
+ import textwrap
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ from openai import OpenAI
28
+
29
+ from viraltest import ViraltestAction, ViraltestEnv
30
+ from viraltest.server.viraltest_environment import TAG_POOL, TASK_HORIZON
31
+
32
+ DOCKER_IMAGE = os.getenv("IMAGE_NAME") or os.getenv("LOCAL_IMAGE_NAME")
33
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
34
+ API_BASE_URL = os.getenv("API_BASE_URL") or "http://127.0.0.1:1337/v1"
35
+ MODEL_NAME = os.getenv("MODEL_NAME") or "gemma-4-E4B-it-IQ4_XS"
36
+ BENCHMARK = os.getenv("VIRALTEST_BENCHMARK", "viraltest")
37
+
38
+ TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
39
+ _ALLOW_SHORT = os.getenv("ALLOW_SHORT_EPISODE", "").lower() in ("1", "true", "yes")
40
+ _REQUESTED_MAX = int(os.getenv("MAX_STEPS", str(TASK_HORIZON)))
41
+ MAX_STEPS = _REQUESTED_MAX if _ALLOW_SHORT else max(_REQUESTED_MAX, TASK_HORIZON)
42
+ TEMPERATURE = 0.7
43
+ MAX_TOKENS = 512
44
+ SUCCESS_SCORE_THRESHOLD = 0.1
45
+
46
+ VALID_TAGS_TEXT = ", ".join(TAG_POOL)
47
+
48
+ SYSTEM_PROMPT = textwrap.dedent(f"""\
49
+ You are a social media content strategy agent. Each step is one full day (24 hours).
50
+ You receive the current day's state and must plan your actions for the entire day.
51
+
52
+ Reply with a JSON object containing "scheduled_actions" — a list of actions at specific hours.
53
+ Hours you don't list will automatically be rest. Only include posts and create_content actions.
54
+
55
+ FORMAT (JSON only, no markdown, no prose):
56
+ {{
57
+ "scheduled_actions": [
58
+ {{"hour": 10, "action_type": "create_content"}},
59
+ {{"hour": 12, "action_type": "post", "content_type": "reel", "topic": "AI trends", "tags": ["ai", "coding"]}},
60
+ {{"hour": 18, "action_type": "post", "content_type": "carousel", "topic": "startup tips", "tags": ["startup", "growth"]}}
61
+ ]
62
+ }}
63
+
64
+ RULES:
65
+ - hour: 0-23 (which hour of the day to perform the action)
66
+ - action_type: "post" or "create_content" (rest is automatic for unlisted hours)
67
+ - For posts: content_type (reel|story|carousel|text_post), topic, and tags are required
68
+ - Tags must be from this pool: {VALID_TAGS_TEXT}
69
+ - Max 5 tags per post
70
+ - Empty scheduled_actions means rest all day
71
+ - Peak posting hours: 9-12 (1.3x), 12-15 Tue-Thu (1.4x), 18-20 (1.25x)
72
+ - Posting 3+ times/day causes audience fatigue; 1-2 posts/day is optimal
73
+ - If energy hits 0, episode ends (burnout = game over)
74
+
75
+ Plan strategically: schedule posts at peak hours, rest during off-hours to recover energy,
76
+ and use create_content to build a content queue for cheaper posts later.""")
77
+
78
+
79
+ def should_force_rest_day(obs: Any) -> bool:
80
+ """If energy is critically low, submit an empty schedule (all rest)."""
81
+ energy = float(getattr(obs, "creator_energy", 1.0))
82
+ return energy <= 0.15
83
+
84
+
85
+ def log_start(task: str, env: str, model: str) -> None:
86
+ print(f"[START] task={task} env={env} model={model}", flush=True)
87
+
88
+
89
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
90
+ error_val = error.replace(" ", "_") if error else "null"
91
+ done_val = str(done).lower()
92
+ print(
93
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
94
+ f"done={done_val} error={error_val}",
95
+ flush=True,
96
+ )
97
+
98
+
99
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
100
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
101
+ print(
102
+ f"[END] success={str(success).lower()} steps={steps} "
103
+ f"score={score:.2f} rewards={rewards_str}",
104
+ flush=True,
105
+ )
106
+
107
+
108
+ def format_observation(obs: Any) -> str:
109
+ """Serialize observation into a readable prompt for the LLM."""
110
+ tag_perf = obs.tag_performance or {}
111
+ top_tags = sorted(tag_perf.items(), key=lambda x: x[1], reverse=True)[:5]
112
+ top_tags_str = ", ".join(f"{t}={v:.2f}" for t, v in top_tags) if top_tags else "none yet"
113
+
114
+ comp_posts = obs.competitor_recent_posts or []
115
+ comp_str = ""
116
+ for p in comp_posts[:3]:
117
+ comp_str += (
118
+ f" - {p.get('content_type','?')} on '{p.get('topic','?')}' "
119
+ f"tags={p.get('tags',[])} eng={p.get('engagement',0):.2f} "
120
+ f"({p.get('hours_ago',0)}h ago)\n"
121
+ )
122
+ if not comp_str:
123
+ comp_str = " none\n"
124
+
125
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
126
+ day_name = days[obs.day_of_week] if 0 <= obs.day_of_week < 7 else "?"
127
+
128
+ daily_eng = getattr(obs, "daily_total_engagement", 0.0)
129
+ daily_posts = getattr(obs, "daily_posts_made", 0)
130
+ daily_emin = getattr(obs, "daily_energy_min", 1.0)
131
+
132
+ return textwrap.dedent(f"""\
133
+ Day: {day_name} (day_of_week={obs.day_of_week}, 0=Mon) | days_elapsed={obs.days_elapsed}
134
+ Hours since sleep: {obs.hours_since_sleep} | Sleep debt: {obs.sleep_debt:.3f}
135
+ Energy: {obs.creator_energy:.2f} | Followers: {obs.follower_count} | Engagement rate: {obs.engagement_rate:.3f}
136
+ Hours since last post: {obs.time_since_last_post}
137
+ Content queue: {obs.content_queue_size} | Last post type: {obs.last_post_type}
138
+ Yesterday's engagement: {daily_eng:.3f} | Yesterday's posts: {daily_posts} | Yesterday's min energy: {daily_emin:.2f}
139
+ Trending topics: {', '.join(obs.trending_topics)}
140
+ Trending tags: {', '.join(obs.trending_tags)}
141
+ Your top tags: {top_tags_str}
142
+ Niche saturation: {obs.niche_saturation:.2f} | Competitor avg engagement: {obs.competitor_avg_engagement:.3f}
143
+ Competitor recent posts:
144
+ {comp_str}Plan your actions for today (list only posts and create_content at specific hours):""")
145
+
146
+
147
+ def parse_daily_plan(response_text: str) -> ViraltestAction:
148
+ """Parse LLM JSON into ViraltestAction with scheduled_actions; fallback to empty (all rest)."""
149
+ text = response_text.strip()
150
+ if text.startswith("```"):
151
+ lines = text.split("\n")
152
+ lines = [l for l in lines if not l.strip().startswith("```")]
153
+ text = "\n".join(lines).strip()
154
+
155
+ try:
156
+ data: Dict[str, Any] = json.loads(text)
157
+ actions_raw = data.get("scheduled_actions", [])
158
+ if not isinstance(actions_raw, list):
159
+ return ViraltestAction(scheduled_actions=[])
160
+ return ViraltestAction(scheduled_actions=actions_raw)
161
+ except (json.JSONDecodeError, Exception):
162
+ return ViraltestAction(scheduled_actions=[])
163
+
164
+
165
+ def format_action_str(action: ViraltestAction) -> str:
166
+ """Format daily plan for [STEP] log line."""
167
+ if not action.scheduled_actions:
168
+ return "daily_plan(rest_all)"
169
+ parts = []
170
+ for sa in action.scheduled_actions:
171
+ if sa.action_type == "post":
172
+ tags_str = ",".join(sa.tags) if sa.tags else ""
173
+ parts.append(f"h{sa.hour}:post({sa.content_type},\"{sa.topic}\",[{tags_str}])")
174
+ else:
175
+ parts.append(f"h{sa.hour}:{sa.action_type}()")
176
+ return "daily_plan(" + ";".join(parts) + ")"
177
+
178
+
179
+ _model_exhausted = False
180
+
181
+
182
+ def get_model_daily_plan(
183
+ client: OpenAI, obs: Any, history: List[Dict[str, str]]
184
+ ) -> ViraltestAction:
185
+ """Call the LLM to get a daily plan. Falls back to rest permanently after an unrecoverable error."""
186
+ global _model_exhausted
187
+ if _model_exhausted:
188
+ return ViraltestAction(scheduled_actions=[])
189
+
190
+ user_prompt = format_observation(obs)
191
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
192
+ messages.extend(history[-12:])
193
+ messages.append({"role": "user", "content": user_prompt})
194
+
195
+ try:
196
+ completion = client.chat.completions.create(
197
+ model=MODEL_NAME,
198
+ messages=messages,
199
+ temperature=TEMPERATURE,
200
+ max_tokens=MAX_TOKENS,
201
+ stream=False,
202
+ )
203
+ text = (completion.choices[0].message.content or "").strip()
204
+ return parse_daily_plan(text) if text else ViraltestAction(scheduled_actions=[])
205
+ except Exception as exc:
206
+ err_str = str(exc)
207
+ print(f"[DEBUG] Model request failed: {exc}", flush=True)
208
+ if "402" in err_str or "429" in err_str or "credit" in err_str.lower() or "quota" in err_str.lower():
209
+ _model_exhausted = True
210
+ print("[DEBUG] Token/credit limit reached — falling back to rest for remaining steps", flush=True)
211
+ return ViraltestAction(scheduled_actions=[])
212
+
213
+
214
+ async def run_task(client: OpenAI, task: str) -> None:
215
+ """Run a single task episode (7 daily steps)."""
216
+ global _model_exhausted
217
+ _model_exhausted = False
218
+
219
+ rewards: List[float] = []
220
+ steps_taken = 0
221
+ score = 0.0
222
+ success = False
223
+ env: Optional[ViraltestEnv] = None
224
+
225
+ log_start(task=task, env=BENCHMARK, model=MODEL_NAME)
226
+
227
+ try:
228
+ if DOCKER_IMAGE:
229
+ env = await ViraltestEnv.from_docker_image(DOCKER_IMAGE)
230
+ else:
231
+ env = ViraltestEnv(base_url=os.getenv("ENV_BASE_URL", "http://localhost:8000"))
232
+
233
+ result = await env.reset(task=task)
234
+ history: List[Dict[str, str]] = []
235
+
236
+ for step in range(1, MAX_STEPS + 1):
237
+ if result.done:
238
+ break
239
+
240
+ obs = result.observation
241
+ if should_force_rest_day(obs):
242
+ action = ViraltestAction(scheduled_actions=[])
243
+ else:
244
+ action = get_model_daily_plan(client, obs, history)
245
+
246
+ result = await env.step(action)
247
+
248
+ reward = result.reward or 0.0
249
+ done = result.done
250
+ error = getattr(result.observation, "error", None)
251
+
252
+ rewards.append(reward)
253
+ steps_taken = step
254
+
255
+ log_step(
256
+ step=step,
257
+ action=format_action_str(action),
258
+ reward=reward,
259
+ done=done,
260
+ error=error,
261
+ )
262
+
263
+ history.append({
264
+ "role": "assistant",
265
+ "content": json.dumps({
266
+ "scheduled_actions": [
267
+ {
268
+ "hour": sa.hour,
269
+ "action_type": sa.action_type,
270
+ "content_type": sa.content_type,
271
+ "topic": sa.topic,
272
+ "tags": sa.tags,
273
+ }
274
+ for sa in action.scheduled_actions
275
+ ]
276
+ }),
277
+ })
278
+
279
+ if done:
280
+ score = float(getattr(result.observation, "grader_score", 0) or 0)
281
+ if score == 0:
282
+ meta = getattr(result.observation, "metadata", {}) or {}
283
+ score = float(meta.get("grader_score", 0.0))
284
+ break
285
+
286
+ success = score >= SUCCESS_SCORE_THRESHOLD
287
+
288
+ finally:
289
+ if env is not None:
290
+ try:
291
+ await env.close()
292
+ except Exception as e:
293
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
294
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
295
+
296
+
297
+ async def main() -> None:
298
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "not-needed")
299
+ for task in TASKS:
300
+ await run_task(client, task)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the Viraltest Creator Optimization Environment."""
2
+
3
+ from typing import Any, Dict, List, Literal, Optional
4
+
5
+ from openenv.core.env_server.types import Action, Observation
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ VALID_CONTENT_TYPES = ("reel", "story", "carousel", "text_post")
9
+ VALID_ACTION_TYPES = ("post", "create_content")
10
+
11
+
12
+ class ScheduledAction(BaseModel):
13
+ """A single non-rest action scheduled at a specific hour of the day."""
14
+
15
+ hour: int = Field(..., ge=0, le=23, description="Hour of the day (0-23)")
16
+ action_type: Literal["post", "create_content"] = Field(
17
+ ..., description="What to do at this hour (unlisted hours default to rest)"
18
+ )
19
+ content_type: Optional[Literal["reel", "story", "carousel", "text_post"]] = Field(
20
+ default=None, description="Format of the post (required if posting)"
21
+ )
22
+ topic: Optional[str] = Field(
23
+ default=None, max_length=200, description="Topic of the post"
24
+ )
25
+ tags: Optional[List[str]] = Field(
26
+ default=None, description="Hashtags for the post (max 5)"
27
+ )
28
+
29
+ @field_validator("tags")
30
+ @classmethod
31
+ def validate_tags(cls, v: Optional[List[str]]) -> Optional[List[str]]:
32
+ if v is not None and len(v) > 5:
33
+ return v[:5]
34
+ return v
35
+
36
+
37
+ class ViraltestAction(Action):
38
+ """Sparse daily plan: only non-rest actions. Unlisted hours default to rest."""
39
+
40
+ scheduled_actions: List[ScheduledAction] = Field(
41
+ default_factory=list,
42
+ description="Actions scheduled at specific hours; unlisted hours are rest",
43
+ )
44
+
45
+ @field_validator("scheduled_actions")
46
+ @classmethod
47
+ def validate_no_duplicate_hours(cls, v: List[ScheduledAction]) -> List[ScheduledAction]:
48
+ seen: set = set()
49
+ deduped: List[ScheduledAction] = []
50
+ for a in v:
51
+ if a.hour not in seen:
52
+ seen.add(a.hour)
53
+ deduped.append(a)
54
+ return deduped
55
+
56
+
57
+ class ViraltestObservation(Observation):
58
+ """Observation the agent receives after each daily step."""
59
+
60
+ current_hour: int = Field(default=0, ge=0, le=23)
61
+ day_of_week: int = Field(default=0, ge=0, le=6)
62
+ days_elapsed: int = Field(default=0, ge=0)
63
+ creator_energy: float = Field(default=1.0, ge=0.0, le=1.0)
64
+ hours_since_sleep: int = Field(default=0, ge=0, description="Hours since last sleep period")
65
+ sleep_debt: float = Field(default=0.0, ge=0.0, le=1.0, description="Accumulated sleep debt (0=rested, 1=severe)")
66
+ follower_count: int = Field(default=0, ge=0)
67
+ engagement_rate: float = Field(default=0.0, ge=0.0)
68
+ posts_today: int = Field(default=0, ge=0)
69
+ time_since_last_post: int = Field(default=0, ge=0)
70
+ trending_topics: List[str] = Field(default_factory=list)
71
+ content_queue_size: int = Field(default=0, ge=0)
72
+ last_post_type: str = Field(default="none")
73
+
74
+ tag_performance: Dict[str, float] = Field(default_factory=dict)
75
+ trending_tags: List[str] = Field(default_factory=list)
76
+
77
+ competitor_recent_posts: List[Dict[str, Any]] = Field(default_factory=list)
78
+ competitor_avg_engagement: float = Field(default=0.0, ge=0.0)
79
+ niche_saturation: float = Field(default=0.0, ge=0.0, le=1.0)
80
+
81
+ daily_total_engagement: float = Field(default=0.0, ge=0.0, description="Total engagement earned this day")
82
+ daily_posts_made: int = Field(default=0, ge=0, description="Number of posts made this day")
83
+ daily_energy_min: float = Field(default=1.0, ge=0.0, le=1.0, description="Lowest energy during this day")
84
+
85
+ grader_score: Optional[float] = Field(default=None, description="Final grader score (set on last step when done=True)")
86
+
87
+ error: Optional[str] = Field(default=None)
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: viraltest
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
pyproject.toml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-viraltest"
13
+ version = "0.1.0"
14
+ description = "Viraltest environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
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]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m viraltest.server.app
40
+ server = "viraltest.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["viraltest", "viraltest.server"]
45
+ package-dir = { "viraltest" = ".", "viraltest.server" = "server" }
46
+
47
+ [tool.setuptools.package-data]
48
+ "viraltest.server" = ["*.html"]
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Viraltest environment server components."""
8
+
9
+ from .viraltest_environment import ViraltestEnvironment
10
+
11
+ __all__ = ["ViraltestEnvironment"]
server/app.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the Viraltest Environment.
9
+
10
+ This module creates an HTTP server that exposes the ViraltestEnvironment
11
+ over HTTP and WebSocket endpoints, compatible with EnvClient.
12
+
13
+ Endpoints:
14
+ - POST /reset: Reset the environment
15
+ - POST /step: Execute an action
16
+ - GET /state: Get current environment state
17
+ - GET /schema: Get action/observation schemas
18
+ - WS /ws: WebSocket endpoint for persistent sessions
19
+
20
+ Usage:
21
+ # Development (with auto-reload):
22
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
23
+
24
+ # Production:
25
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
26
+
27
+ # Or run directly:
28
+ python -m server.app
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import random as stdlib_random
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ from fastapi import Body
39
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
40
+
41
+ try:
42
+ from openenv.core.env_server.http_server import create_app
43
+ except Exception as e: # pragma: no cover
44
+ raise ImportError(
45
+ "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
46
+ ) from e
47
+
48
+ # OpenEnv Gradio UI lives at /web; Dockerfile sets this — default on for local parity with HF Spaces.
49
+ if "ENABLE_WEB_INTERFACE" not in os.environ:
50
+ os.environ["ENABLE_WEB_INTERFACE"] = "true"
51
+
52
+ try:
53
+ from ..models import ScheduledAction, ViraltestAction, ViraltestObservation
54
+ from .viraltest_environment import ViraltestEnvironment
55
+ except ImportError:
56
+ from models import ScheduledAction, ViraltestAction, ViraltestObservation
57
+ from server.viraltest_environment import ViraltestEnvironment
58
+
59
+ _DASHBOARD_HTML = (Path(__file__).parent / "dashboard.html").read_text()
60
+
61
+ app = create_app(
62
+ ViraltestEnvironment,
63
+ ViraltestAction,
64
+ ViraltestObservation,
65
+ env_name="viraltest",
66
+ max_concurrent_envs=1,
67
+ )
68
+
69
+ _gradio_web = os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
70
+ if not _gradio_web:
71
+
72
+ @app.get("/", include_in_schema=False)
73
+ async def _root_redirect():
74
+ return RedirectResponse("/dashboard", status_code=302)
75
+
76
+ @app.get("/web", include_in_schema=False)
77
+ @app.get("/web/", include_in_schema=False)
78
+ async def _web_disabled_redirect():
79
+ return RedirectResponse("/dashboard", status_code=302)
80
+
81
+ _dash_env: Optional[ViraltestEnvironment] = None
82
+ _HISTORY_FILE = Path(__file__).parent / "simulation_history.json"
83
+
84
+
85
+ def _obs_to_dict(obs: ViraltestObservation) -> Dict[str, Any]:
86
+ return {
87
+ "observation": obs.model_dump(),
88
+ "reward": obs.reward,
89
+ "done": obs.done,
90
+ }
91
+
92
+
93
+ def _load_history() -> List[Dict[str, Any]]:
94
+ if _HISTORY_FILE.exists():
95
+ try:
96
+ return json.loads(_HISTORY_FILE.read_text())
97
+ except (json.JSONDecodeError, OSError):
98
+ return []
99
+ return []
100
+
101
+
102
+ def _save_history_entry(entry: Dict[str, Any]) -> None:
103
+ history = _load_history()
104
+ history.append(entry)
105
+ if len(history) > 100:
106
+ history = history[-100:]
107
+ _HISTORY_FILE.write_text(json.dumps(history, indent=2))
108
+
109
+
110
+ @app.get("/dashboard", response_class=HTMLResponse)
111
+ async def dashboard():
112
+ return _DASHBOARD_HTML
113
+
114
+
115
+ @app.get("/dashboard/history")
116
+ async def dashboard_history():
117
+ history = _load_history()
118
+ out: List[Dict[str, Any]] = []
119
+ for row in history:
120
+ entry = dict(row)
121
+ if not entry.get("description"):
122
+ sid = entry.get("scenario_id")
123
+ if sid and sid in SCENARIOS:
124
+ entry["description"] = SCENARIOS[sid][1]
125
+ out.append(entry)
126
+ return out
127
+
128
+
129
+ @app.delete("/dashboard/history")
130
+ async def dashboard_history_clear():
131
+ if _HISTORY_FILE.exists():
132
+ _HISTORY_FILE.unlink()
133
+ return {"status": "cleared"}
134
+
135
+
136
+ @app.post("/dashboard/reset")
137
+ async def dashboard_reset(body: Dict[str, Any] = Body(default={})):
138
+ global _dash_env
139
+ _dash_env = ViraltestEnvironment()
140
+ task = body.get("task", "weekly_engage")
141
+ obs = _dash_env.reset(task=task)
142
+ return _obs_to_dict(obs)
143
+
144
+
145
+ @app.post("/dashboard/step")
146
+ async def dashboard_step(body: Dict[str, Any] = Body(...)):
147
+ global _dash_env
148
+ if _dash_env is None:
149
+ _dash_env = ViraltestEnvironment()
150
+ _dash_env.reset()
151
+ action_data = body.get("action", body)
152
+ action = ViraltestAction(**action_data)
153
+ obs = _dash_env.step(action)
154
+ return _obs_to_dict(obs)
155
+
156
+
157
+ try:
158
+ from .viraltest_environment import TAG_POOL
159
+ except ImportError:
160
+ from server.viraltest_environment import TAG_POOL
161
+
162
+ _SIM_RNG = stdlib_random.Random(99)
163
+ _CONTENT_TYPES = ["reel", "carousel", "story", "text_post"]
164
+ _TOPICS = ["AI tools", "fitness routine", "growth hacks", "travel guide", "food recipe", "wellness tips"]
165
+
166
+
167
+ def _make_daily_plan(actions: list) -> ViraltestAction:
168
+ """Helper: build a ViraltestAction from a list of ScheduledAction-like dicts."""
169
+ return ViraltestAction(scheduled_actions=[ScheduledAction(**a) for a in actions])
170
+
171
+
172
+ def _plan_always_rest(obs: dict, day: int) -> ViraltestAction:
173
+ return _make_daily_plan([])
174
+
175
+
176
+ def _plan_spam(obs: dict, day: int) -> ViraltestAction:
177
+ actions = [{"hour": h, "action_type": "post", "content_type": "reel",
178
+ "topic": "AI tools", "tags": ["ai"]} for h in range(24)]
179
+ return _make_daily_plan(actions)
180
+
181
+
182
+ def _plan_smart(obs: dict, day: int) -> ViraltestAction:
183
+ trending = (obs.get("trending_topics") or ["AI tools"])[0]
184
+ t_tags = list((obs.get("trending_tags") or [])[:2])
185
+ pool_tag = TAG_POOL[(day * 2) % len(TAG_POOL)]
186
+ pool_tag2 = TAG_POOL[(day * 2 + 1) % len(TAG_POOL)]
187
+ ct1 = _CONTENT_TYPES[(day * 2) % 4]
188
+ ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
189
+ actions = [
190
+ {"hour": 8, "action_type": "create_content"},
191
+ {"hour": 12, "action_type": "post", "content_type": ct1, "topic": trending, "tags": t_tags + [pool_tag]},
192
+ {"hour": 19, "action_type": "post", "content_type": ct2, "topic": trending, "tags": t_tags + [pool_tag2]},
193
+ ]
194
+ return _make_daily_plan(actions)
195
+
196
+
197
+ def _plan_no_rest(obs: dict, day: int) -> ViraltestAction:
198
+ actions = []
199
+ for h in range(24):
200
+ ct = _CONTENT_TYPES[h % 4]
201
+ topic = _SIM_RNG.choice(_TOPICS)
202
+ tags = _SIM_RNG.sample(TAG_POOL, 3)
203
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
204
+ return _make_daily_plan(actions)
205
+
206
+
207
+ def _plan_minimal(obs: dict, day: int) -> ViraltestAction:
208
+ trending = (obs.get("trending_topics") or ["minimalism"])[0]
209
+ tags = list((obs.get("trending_tags") or [])[:3])
210
+ return _make_daily_plan([
211
+ {"hour": 12, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
212
+ ])
213
+
214
+
215
+ def _plan_reel_max(obs: dict, day: int) -> ViraltestAction:
216
+ trending = (obs.get("trending_topics") or ["viral content"])[0]
217
+ tags = list((obs.get("trending_tags") or [])[:3])
218
+ return _make_daily_plan([
219
+ {"hour": 12, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
220
+ {"hour": 14, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
221
+ ])
222
+
223
+
224
+ def _plan_split_schedule(obs: dict, day: int) -> ViraltestAction:
225
+ trending = (obs.get("trending_topics") or ["daily content"])[0]
226
+ tags = list((obs.get("trending_tags") or [])[:2]) + ["tips"]
227
+ return _make_daily_plan([
228
+ {"hour": 9, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
229
+ {"hour": 19, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
230
+ ])
231
+
232
+
233
+ def _plan_double_peak(obs: dict, day: int) -> ViraltestAction:
234
+ trending = (obs.get("trending_topics") or ["peak time content"])[0]
235
+ tags = list((obs.get("trending_tags") or [])[:3])
236
+ return _make_daily_plan([
237
+ {"hour": 9, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
238
+ {"hour": 15, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
239
+ ])
240
+
241
+
242
+ def _plan_tag_explorer(obs: dict, day: int) -> ViraltestAction:
243
+ trending = (obs.get("trending_topics") or ["devtools"])[0]
244
+ start = (day * 6) % len(TAG_POOL)
245
+ tags1 = [TAG_POOL[(start + i) % len(TAG_POOL)] for i in range(3)]
246
+ tags2 = [TAG_POOL[(start + 3 + i) % len(TAG_POOL)] for i in range(3)]
247
+ ct1 = _CONTENT_TYPES[(day * 2) % 4]
248
+ ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
249
+ return _make_daily_plan([
250
+ {"hour": 10, "action_type": "post", "content_type": ct1, "topic": trending, "tags": tags1},
251
+ {"hour": 18, "action_type": "post", "content_type": ct2, "topic": trending, "tags": tags2},
252
+ ])
253
+
254
+
255
+ def _plan_queue_optimizer(obs: dict, day: int) -> ViraltestAction:
256
+ trending = (obs.get("trending_topics") or ["productivity"])[0]
257
+ tags = list((obs.get("trending_tags") or [])[:2]) + ["growth"]
258
+ queue = obs.get("content_queue_size", 0)
259
+ if day < 2 or queue < 2:
260
+ return _make_daily_plan([
261
+ {"hour": 8, "action_type": "create_content"},
262
+ {"hour": 10, "action_type": "create_content"},
263
+ {"hour": 14, "action_type": "create_content"},
264
+ ])
265
+ ct = _CONTENT_TYPES[day % 4]
266
+ return _make_daily_plan([
267
+ {"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
268
+ {"hour": 19, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": trending, "tags": tags},
269
+ ])
270
+
271
+
272
+ def _plan_weekend(obs: dict, day: int) -> ViraltestAction:
273
+ dow = obs.get("day_of_week", 0)
274
+ if dow not in (5, 6):
275
+ return _make_daily_plan([])
276
+ trending = (obs.get("trending_topics") or ["travel"])[0]
277
+ tags = list((obs.get("trending_tags") or [])[:3])
278
+ return _make_daily_plan([
279
+ {"hour": 11, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
280
+ {"hour": 17, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
281
+ ])
282
+
283
+
284
+ def _plan_weekday_only(obs: dict, day: int) -> ViraltestAction:
285
+ dow = obs.get("day_of_week", 0)
286
+ if dow >= 5:
287
+ return _make_daily_plan([])
288
+ trending = (obs.get("trending_topics") or ["weekday content"])[0]
289
+ tags = list((obs.get("trending_tags") or [])[:2]) + ["productivity"]
290
+ ct = _CONTENT_TYPES[day % 4]
291
+ return _make_daily_plan([
292
+ {"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
293
+ ])
294
+
295
+
296
+ def _plan_random(obs: dict, day: int) -> ViraltestAction:
297
+ actions = []
298
+ for h in range(24):
299
+ r = _SIM_RNG.random()
300
+ if r < 0.1:
301
+ ct = _SIM_RNG.choice(_CONTENT_TYPES)
302
+ topic = _SIM_RNG.choice(["random topic", "AI tools", "fitness", "travel"])
303
+ tags = _SIM_RNG.sample(TAG_POOL, 2)
304
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
305
+ elif r < 0.15:
306
+ actions.append({"hour": h, "action_type": "create_content"})
307
+ return _make_daily_plan(actions)
308
+
309
+
310
+ def _plan_sleep_conscious(obs: dict, day: int) -> ViraltestAction:
311
+ trending = (obs.get("trending_topics") or ["wellness"])[0]
312
+ tags = list((obs.get("trending_tags") or [])[:2]) + ["productivity"]
313
+ ct = _CONTENT_TYPES[day % 4]
314
+ return _make_daily_plan([
315
+ {"hour": 10, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
316
+ {"hour": 16, "action_type": "create_content"},
317
+ ])
318
+
319
+
320
+ def _plan_sleep_deprived(obs: dict, day: int) -> ViraltestAction:
321
+ trending = (obs.get("trending_topics") or ["coding"])[0]
322
+ tags = list((obs.get("trending_tags") or [])[:2])
323
+ actions = []
324
+ for h in range(24):
325
+ if 9 <= h <= 20 and len([a for a in actions if a["action_type"] == "post"]) < 2:
326
+ ct = _CONTENT_TYPES[h % 4]
327
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags})
328
+ else:
329
+ actions.append({"hour": h, "action_type": "create_content"})
330
+ return _make_daily_plan(actions)
331
+
332
+
333
+ def _plan_growth_focus(obs: dict, day: int) -> ViraltestAction:
334
+ trending = (obs.get("trending_topics") or ["growth hacks"])[0]
335
+ return _make_daily_plan([
336
+ {"hour": 13, "action_type": "post", "content_type": "reel", "topic": trending, "tags": ["viral", "growth", "trending"]},
337
+ ])
338
+
339
+
340
+ def _plan_tech_niche(obs: dict, day: int) -> ViraltestAction:
341
+ ct = _CONTENT_TYPES[day % 4]
342
+ return _make_daily_plan([
343
+ {"hour": 12, "action_type": "post", "content_type": ct, "topic": "AI tools and coding tips", "tags": ["ai", "coding", "devtools"]},
344
+ {"hour": 18, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": "AI tools and coding tips", "tags": ["ai", "ml", "startup"]},
345
+ ])
346
+
347
+
348
+ def _plan_conservative(obs: dict, day: int) -> ViraltestAction:
349
+ trending = (obs.get("trending_topics") or ["quick tip"])[0]
350
+ tags = list((obs.get("trending_tags") or [])[:2])
351
+ return _make_daily_plan([
352
+ {"hour": 13, "action_type": "post", "content_type": "text_post", "topic": trending, "tags": tags},
353
+ ])
354
+
355
+
356
+ SCENARIOS = {
357
+ "always_rest": ("Always Rest", "Never posts. Tests follower decay + zero engagement.", _plan_always_rest),
358
+ "spam": ("Spam Post", "Same reel every hour. Burns out fast.", _plan_spam),
359
+ "no_rest": ("No Rest", "Posts every hour, never rests. Burns out fast.", _plan_no_rest),
360
+ "smart": ("Smart Agent", "Optimal: peak hours, trending, varied types, rests.", _plan_smart),
361
+ "queue_optimizer": ("Queue Optimizer", "Creates content first, posts from queue.", _plan_queue_optimizer),
362
+ "weekend": ("Weekend Warrior", "Only posts on Sat/Sun.", _plan_weekend),
363
+ "tag_explorer": ("Tag Explorer", "New tag combo every post. Max discovery.", _plan_tag_explorer),
364
+ "sleep_deprived": ("Sleep Deprived", "Never rests. Tests sleep deprivation.", _plan_sleep_deprived),
365
+ "sleep_conscious": ("Sleep Conscious", "Proper sleep schedule.", _plan_sleep_conscious),
366
+ "minimal": ("Minimal Poster", "1 post per day at noon.", _plan_minimal),
367
+ "reel_max": ("Reel Maximizer", "Reels at peak hours for max reach.", _plan_reel_max),
368
+ "split_schedule": ("Split Schedule", "Morning and evening posts.", _plan_split_schedule),
369
+ "double_peak": ("Double Peak", "Posts at 9am and 3pm.", _plan_double_peak),
370
+ "growth_focus": ("Growth Focus", "Maximizes follower growth.", _plan_growth_focus),
371
+ "weekday_only": ("Weekday Only", "No weekend posting.", _plan_weekday_only),
372
+ "tech_niche": ("Tech Niche", "AI/coding content focus.", _plan_tech_niche),
373
+ "conservative": ("Conservative", "One text post at 1pm.", _plan_conservative),
374
+ "random": ("Random Actor", "Random actions. Baseline test.", _plan_random),
375
+ }
376
+
377
+
378
+ @app.get("/dashboard/scenarios")
379
+ async def dashboard_scenarios():
380
+ """List all simulation strategies for the dashboard UI."""
381
+ items = [{"id": k, "label": v[0], "description": v[1]} for k, v in SCENARIOS.items()]
382
+ items.sort(key=lambda x: (x["label"].lower()))
383
+ return JSONResponse(
384
+ content={"count": len(items), "scenarios": items},
385
+ headers={"Cache-Control": "no-store, max-age=0, must-revalidate"},
386
+ )
387
+
388
+
389
+ @app.post("/dashboard/simulate")
390
+ async def dashboard_simulate(body: Dict[str, Any] = Body(...)):
391
+ global _SIM_RNG
392
+ _SIM_RNG = stdlib_random.Random(99)
393
+
394
+ scenario_id = body.get("scenario", "smart")
395
+ task = body.get("task", "weekly_competitive")
396
+ if scenario_id not in SCENARIOS:
397
+ return {"error": f"Unknown scenario: {scenario_id}"}
398
+
399
+ label, desc, plan_fn = SCENARIOS[scenario_id]
400
+ env = ViraltestEnvironment()
401
+ obs = env.reset(task=task, seed=42)
402
+ obs_dict = obs.model_dump()
403
+
404
+ steps: List[Dict[str, Any]] = []
405
+ for day in range(1, 8):
406
+ action = plan_fn(obs_dict, day)
407
+ obs = env.step(action)
408
+ obs_dict = obs.model_dump()
409
+ r = obs.reward if obs.reward is not None else 0.0
410
+
411
+ n_posts = len([sa for sa in action.scheduled_actions if sa.action_type == "post"])
412
+ n_create = len([sa for sa in action.scheduled_actions if sa.action_type == "create_content"])
413
+ action_str = f"day{day}(posts={n_posts},creates={n_create})"
414
+
415
+ steps.append({
416
+ "step": day,
417
+ "action": action_str,
418
+ "reward": round(r, 4),
419
+ "done": obs.done,
420
+ "error": obs.error,
421
+ "energy": round(obs.creator_energy, 3),
422
+ "hours_since_sleep": obs.hours_since_sleep,
423
+ "sleep_debt": round(obs.sleep_debt, 3),
424
+ "followers": obs.follower_count,
425
+ "engagement_rate": round(obs.engagement_rate, 4),
426
+ "niche_saturation": round(obs.niche_saturation, 3),
427
+ "posts_today": obs.posts_today,
428
+ "hour": obs.current_hour,
429
+ "day": obs.day_of_week,
430
+ "days_elapsed": obs.days_elapsed,
431
+ "queue": obs.content_queue_size,
432
+ "tag_performance": obs.tag_performance,
433
+ "trending_topics": obs.trending_topics,
434
+ "trending_tags": obs.trending_tags,
435
+ "competitor_avg_engagement": round(obs.competitor_avg_engagement, 4),
436
+ "daily_total_engagement": round(obs.daily_total_engagement, 4),
437
+ "daily_posts_made": obs.daily_posts_made,
438
+ "daily_energy_min": round(obs.daily_energy_min, 3),
439
+ })
440
+ if obs.done:
441
+ break
442
+
443
+ score = (obs.metadata or {}).get("grader_score", 0.0)
444
+ result = {
445
+ "scenario": label,
446
+ "description": desc,
447
+ "task": task,
448
+ "steps": steps,
449
+ "total_steps": len(steps),
450
+ "score": round(score, 4),
451
+ "final": {
452
+ "energy": round(obs.creator_energy, 3),
453
+ "hours_since_sleep": obs.hours_since_sleep,
454
+ "sleep_debt": round(obs.sleep_debt, 3),
455
+ "followers": obs.follower_count,
456
+ "engagement_rate": round(obs.engagement_rate, 4),
457
+ "burned_out": obs.creator_energy <= 0,
458
+ },
459
+ }
460
+
461
+ rewards = [s["reward"] for s in steps]
462
+ total_posts = sum(s.get("daily_posts_made", 0) for s in steps)
463
+ _save_history_entry({
464
+ "id": datetime.now(timezone.utc).isoformat(),
465
+ "scenario": label,
466
+ "scenario_id": scenario_id,
467
+ "description": desc,
468
+ "task": task,
469
+ "score": round(score, 4),
470
+ "total_steps": len(steps),
471
+ "total_posts": total_posts,
472
+ "avg_reward": round(sum(rewards) / len(rewards), 4) if rewards else 0,
473
+ "final": result["final"],
474
+ })
475
+
476
+ return result
477
+
478
+
479
+ def main(host: str = "0.0.0.0", port: int = 8000):
480
+ """
481
+ Entry point for direct execution via uv run or python -m.
482
+
483
+ This function enables running the server without Docker:
484
+ uv run --project . server
485
+ uv run --project . server --port 8001
486
+ python -m viraltest.server.app
487
+
488
+ Args:
489
+ host: Host address to bind to (default: "0.0.0.0")
490
+ port: Port number to listen on (default: 8000)
491
+
492
+ For production deployments, consider using uvicorn directly with
493
+ multiple workers:
494
+ uvicorn viraltest.server.app:app --workers 4
495
+ """
496
+ import uvicorn
497
+
498
+ uvicorn.run(app, host=host, port=port)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ import argparse
503
+
504
+ parser = argparse.ArgumentParser()
505
+ parser.add_argument("--port", type=int, default=None)
506
+ args = parser.parse_args()
507
+ if args.port is not None:
508
+ main(port=args.port)
509
+ else:
510
+ main()
server/dashboard.html ADDED
@@ -0,0 +1,1306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html class="dark" lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta content="width=device-width,initial-scale=1.0" name="viewport"/>
6
+ <title>Growth Copilot — Simulation</title>
7
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet"/>
9
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
10
+ <script>
11
+ tailwind.config={darkMode:"class",theme:{extend:{colors:{"surface":"#0b1326","surface-low":"#131b2e","surface-high":"#222a3d","surface-top":"#2d3449","surface-lowest":"#060e20","on-surface":"#dae2fd","on-surface-dim":"#cbc3d7","primary":"#d0bcff","primary-ctr":"#a078ff","secondary":"#7bd0ff","secondary-ctr":"#00a6e0","tertiary":"#ffb2b9","tertiary-ctr":"#ea6479","outline":"#494454","error":"#ffb4ab"},fontFamily:{headline:["Inter"],body:["Inter"],label:["Space Grotesk"]}}}}
12
+ </script>
13
+ <style>
14
+ body{background:#0b1326;color:#dae2fd;font-family:'Inter',sans-serif}
15
+ .material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24}
16
+ .glass{background:rgba(34,42,61,.6);backdrop-filter:blur(24px);border:1px solid rgba(73,68,84,.2)}
17
+ .glass-solid{background:#131b2e;border:1px solid rgba(73,68,84,.15)}
18
+ .energy-bar{transition:width .6s ease}
19
+ .fade-in{animation:fadeIn .3s ease}
20
+ @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
21
+ @keyframes pulse-glow{0%,100%{box-shadow:0 0 8px rgba(208,188,255,.2)}50%{box-shadow:0 0 20px rgba(208,188,255,.4)}}
22
+ .pulse-glow{animation:pulse-glow 2s ease-in-out infinite}
23
+ ::-webkit-scrollbar{width:6px}
24
+ ::-webkit-scrollbar-track{background:transparent}
25
+ ::-webkit-scrollbar-thumb{background:rgba(73,68,84,.4);border-radius:3px}
26
+ .sim-btn{transition:all .2s ease}
27
+ .sim-btn:hover{transform:translateY(-1px)}
28
+ .action-btn{transition:all .15s ease}
29
+ .action-btn:active{transform:scale(.97)}
30
+ </style>
31
+ </head>
32
+ <body class="min-h-screen flex">
33
+
34
+ <!-- Sidebar -->
35
+ <aside class="flex flex-col sticky top-0 h-screen w-64 border-r border-white/5 bg-surface-lowest shadow-2xl shadow-slate-950/50 shrink-0 z-50">
36
+ <div class="p-6 pb-4">
37
+ <div class="text-xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-br from-primary to-primary-ctr mb-1">Growth Copilot</div>
38
+ <div class="text-[9px] font-label uppercase tracking-[.2em] text-on-surface-dim/50">Weekly growth simulation</div>
39
+ </div>
40
+ <nav class="flex-1 px-3 space-y-1">
41
+ <a href="/dashboard" class="flex items-center gap-3 px-4 py-2.5 rounded-lg text-primary font-bold border-r-2 border-primary bg-gradient-to-r from-primary/10 to-transparent transition-all">
42
+ <span class="material-symbols-outlined text-[20px]">dashboard</span><span class="font-label text-sm">Dashboard</span>
43
+ </a>
44
+ <a href="/web/" class="flex items-center gap-3 px-4 py-2.5 rounded-lg text-slate-400 font-medium hover:text-slate-200 hover:bg-white/5 transition-all">
45
+ <span class="material-symbols-outlined text-[20px]">web</span><span class="font-label text-sm">OpenEnv UI</span>
46
+ </a>
47
+ </nav>
48
+ <!-- Task Selector in Sidebar -->
49
+ <div class="p-4 border-t border-white/5 space-y-3">
50
+ <div class="text-[9px] font-label uppercase tracking-widest text-on-surface-dim/60 mb-1">Task</div>
51
+ <select id="taskSelect" onchange="refreshTaskScoreBlurb()" class="w-full bg-surface border border-outline/30 rounded-lg px-3 py-2 text-sm font-label focus:ring-1 focus:ring-primary focus:outline-none">
52
+ <option value="weekly_engage">Easy — Engage</option>
53
+ <option value="weekly_strategic">Medium — Strategic</option>
54
+ <option value="weekly_competitive" selected>Hard — Competitive</option>
55
+ </select>
56
+ <button onclick="doReset()" class="w-full py-3 rounded-lg bg-gradient-to-br from-primary to-primary-ctr text-[#23005c] font-bold text-sm hover:opacity-90 transition active:scale-[.97]">
57
+ <span class="material-symbols-outlined text-[16px] align-middle mr-1">restart_alt</span>Reset
58
+ </button>
59
+ </div>
60
+ </aside>
61
+
62
+ <!-- Main -->
63
+ <div class="flex-1 flex flex-col min-w-0">
64
+
65
+ <!-- Top Bar -->
66
+ <header class="flex justify-between items-center px-6 h-14 border-b border-white/5 bg-surface/60 backdrop-blur-xl sticky top-0 z-40">
67
+ <div class="flex items-center gap-5">
68
+ <span id="statusDot" class="flex items-center gap-2 text-xs font-label text-secondary"><span class="w-2 h-2 rounded-full bg-secondary"></span>Ready</span>
69
+ <span class="text-xs font-label text-on-surface-dim">Step <span id="stepNum" class="text-on-surface font-bold">0</span> / 168</span>
70
+ </div>
71
+ <div class="flex items-center gap-3">
72
+ <span id="rewardBadge" class="text-xs font-label text-on-surface-dim">Last reward: —</span>
73
+ <span class="text-xs font-label text-on-surface-dim/40">|</span>
74
+ <span id="timeBadge" class="text-xs font-label text-on-surface-dim"><span class="material-symbols-outlined text-[14px] align-middle">schedule</span> <span id="timeVal">9:00</span> <span id="dayVal" class="text-on-surface-dim/60">Mon</span></span>
75
+ </div>
76
+ </header>
77
+
78
+ <main class="flex-1 p-6 space-y-5 overflow-y-auto">
79
+
80
+ <!-- Hero Stat Cards -->
81
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
82
+
83
+ <!-- Energy -->
84
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
85
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">bolt</span></div>
86
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Energy</div>
87
+ <div id="energyVal" class="text-3xl font-black tracking-tight">1.00</div>
88
+ <div class="mt-3 h-2 bg-surface-top rounded-full overflow-hidden">
89
+ <div id="energyBar" class="h-full bg-gradient-to-r from-tertiary-ctr to-tertiary energy-bar rounded-full" style="width:100%"></div>
90
+ </div>
91
+ <div id="energyHint" class="mt-1.5 text-[9px] font-label text-tertiary">FULL</div>
92
+ </div>
93
+
94
+ <!-- Followers -->
95
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
96
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">group</span></div>
97
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Followers</div>
98
+ <div id="followersVal" class="text-3xl font-black tracking-tight">10,000</div>
99
+ <div id="followersDelta" class="mt-1.5 text-[9px] font-label text-on-surface-dim">+0 since start</div>
100
+ </div>
101
+
102
+ <!-- Engagement -->
103
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
104
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">trending_up</span></div>
105
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Engagement</div>
106
+ <div id="engVal" class="text-3xl font-black tracking-tight text-secondary">0.000</div>
107
+ <div id="engVsComp" class="mt-1.5 text-[9px] font-label text-on-surface-dim">vs competitors: —</div>
108
+ </div>
109
+
110
+ <!-- Posts Today -->
111
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
112
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">send</span></div>
113
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Posts Today</div>
114
+ <div id="postsVal" class="text-3xl font-black tracking-tight">0</div>
115
+ <div class="mt-1.5 text-[9px] font-label text-on-surface-dim">max 2-3 optimal</div>
116
+ </div>
117
+
118
+ <!-- Queue -->
119
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
120
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">inventory_2</span></div>
121
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Content Queue</div>
122
+ <div id="queueVal" class="text-3xl font-black tracking-tight text-secondary">0</div>
123
+ <div class="mt-1.5 text-[9px] font-label text-on-surface-dim">posts cost 50% less</div>
124
+ </div>
125
+
126
+ <!-- Saturation -->
127
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
128
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">layers</span></div>
129
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Niche Saturation</div>
130
+ <div id="satVal" class="text-3xl font-black tracking-tight text-primary">0.00</div>
131
+ <div id="satHint" class="mt-1.5 text-[9px] font-label text-primary">LOW — post unique topics</div>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="glass-solid border border-outline/20 rounded-xl px-4 py-3 space-y-3">
136
+ <div class="flex gap-3 items-start">
137
+ <span class="material-symbols-outlined text-secondary text-lg shrink-0">info</span>
138
+ <p class="text-[11px] font-label text-on-surface-dim leading-relaxed flex-1 min-w-0">
139
+ <span class="text-on-surface font-semibold">Simulation only</span> — not live social data. Each <span class="text-on-surface">step</span> is ~1 hour; <span class="text-on-surface">Post</span> drives engagement and tags; <span class="text-on-surface">Rest</span> restores energy while rivals keep posting.
140
+ </p>
141
+ </div>
142
+ <div class="border-t border-white/5 pt-3 space-y-2">
143
+ <div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Niche saturation</div>
144
+ <p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
145
+ Shown after each step for your <span class="text-on-surface">last post topic</span>. The sim collects competitor posts from the last <span class="text-on-surface">12 simulated hours</span>, counts how many topics overlap yours (≥50% shared words), and divides by the number of those recent competitor posts. Result is capped at 1.0. High saturation usually means more crowd overlap; the environment can lower engagement when you post into a crowded topic.
146
+ </p>
147
+ </div>
148
+ <div class="border-t border-white/5 pt-3 space-y-2">
149
+ <div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Final score &amp; viral meter</div>
150
+ <p id="taskScoreBlurb" class="text-[10px] font-label text-on-surface-dim leading-relaxed"></p>
151
+ <p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
152
+ <span class="text-on-surface font-semibold">Viral probability</span> (dashboard only): <code class="text-on-surface/90">min(100, round(engagement_rate × 1000))</code> with LOW / MEDIUM / HIGH labels at 40% and 70%. It is not the grader and not a forecast of real-world reach.
153
+ </p>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Charts Row -->
158
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
159
+ <!-- Reward history chart -->
160
+ <div class="lg:col-span-2 glass-solid p-5 rounded-xl overflow-hidden">
161
+ <div class="flex justify-between items-center mb-2">
162
+ <div>
163
+ <h3 class="text-sm font-bold">Reward history</h3>
164
+ <p class="text-[10px] text-on-surface-dim mt-0.5">Per-step RL reward after each action (axes: step index × reward)</p>
165
+ </div>
166
+ <span class="flex items-center gap-1.5 text-[10px] font-label text-on-surface-dim"><span class="w-2 h-2 rounded-full bg-secondary"></span>Reward</span>
167
+ </div>
168
+ <div class="h-52 relative">
169
+ <svg id="engagementChart" class="w-full h-full" viewBox="0 0 760 208" preserveAspectRatio="xMidYMid meet"></svg>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Burnout Meter -->
174
+ <div class="glass-solid p-5 rounded-xl flex flex-col items-center overflow-hidden">
175
+ <div class="flex justify-between items-center w-full mb-3">
176
+ <h3 class="text-sm font-bold">Burnout Meter</h3>
177
+ <span class="material-symbols-outlined text-tertiary text-lg">monitor_heart</span>
178
+ </div>
179
+ <div class="relative w-40 h-40 mb-3">
180
+ <svg viewBox="0 0 120 120" class="w-full h-full -rotate-90">
181
+ <circle cx="60" cy="60" r="50" fill="none" stroke="#222a3d" stroke-width="10"/>
182
+ <circle id="burnoutArc" cx="60" cy="60" r="50" fill="none" stroke="url(#burnoutGrad)" stroke-width="10" stroke-linecap="round" stroke-dasharray="0 314" style="transition:stroke-dasharray .6s ease"/>
183
+ <defs><linearGradient id="burnoutGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#ffb2b9"/><stop offset="100%" style="stop-color:#ea6479"/></linearGradient></defs>
184
+ </svg>
185
+ <div class="absolute inset-0 flex flex-col items-center justify-center">
186
+ <span id="burnoutPct" class="text-4xl font-black tracking-tight">0%</span>
187
+ <span class="text-[8px] font-label text-tertiary uppercase tracking-widest mt-0.5">Cortisol Level</span>
188
+ </div>
189
+ </div>
190
+ <div id="burnoutRec" class="p-3 rounded-lg bg-surface border border-outline/15 text-[10px] font-label text-on-surface-dim text-center leading-relaxed w-full">
191
+ Recommendation: Start with a balanced create-rest cycle.
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- Second Charts Row -->
197
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
198
+ <!-- Follower Growth -->
199
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
200
+ <h3 class="text-sm font-bold mb-3">Follower Growth</h3>
201
+ <div class="h-32 relative">
202
+ <svg id="followerChart" class="w-full h-full" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet"></svg>
203
+ </div>
204
+ <div class="flex items-baseline gap-3 mt-2">
205
+ <span id="followerTotal" class="text-2xl font-black tracking-tight text-secondary">+0</span>
206
+ <span id="followerDeltaPct" class="text-xs font-label text-secondary/60">+0% vs start</span>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- Top Performing Tags -->
211
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
212
+ <h3 class="text-sm font-bold mb-3">Top Performing Tags</h3>
213
+ <div id="topTagsList" class="space-y-3">
214
+ <div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- Recent RL Actions -->
219
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
220
+ <h3 class="text-sm font-bold mb-3">Recent RL Actions</h3>
221
+ <div id="recentActions" class="space-y-3 max-h-44 overflow-y-auto">
222
+ <div class="text-on-surface-dim italic text-[10px]">No actions yet</div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ <!-- Step & hour analytics -->
228
+ <div class="space-y-3">
229
+ <div class="flex items-center gap-2 px-1">
230
+ <span class="material-symbols-outlined text-secondary text-lg">show_chart</span>
231
+ <h2 class="text-sm font-bold">Step &amp; hour analytics</h2>
232
+ <span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">X = simulation step (~1h); posts histogram = clock hour (0–23)</span>
233
+ </div>
234
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-3">
235
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
236
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Energy / step</div>
237
+ <svg id="tsEnergy" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
238
+ </div>
239
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
240
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Followers / step</div>
241
+ <svg id="tsFollowers" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
242
+ </div>
243
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
244
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Follower Δ / step</div>
245
+ <svg id="tsFollowDelta" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
246
+ </div>
247
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
248
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Engagement rate / step</div>
249
+ <svg id="tsEngagement" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
250
+ </div>
251
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
252
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Reward / step</div>
253
+ <svg id="tsReward" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
254
+ </div>
255
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
256
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Niche saturation / step</div>
257
+ <svg id="tsSat" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
258
+ </div>
259
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
260
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Content queue / step</div>
261
+ <svg id="tsQueue" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
262
+ </div>
263
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
264
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Competitor avg engagement / step</div>
265
+ <svg id="tsComp" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
266
+ </div>
267
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
268
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Sleep debt / step</div>
269
+ <svg id="tsSleep" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
270
+ </div>
271
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
272
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Hours since sleep / step</div>
273
+ <svg id="tsAwake" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
274
+ </div>
275
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
276
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Posts by clock hour (0–23)</div>
277
+ <svg id="tsPostsHour" class="w-full h-20" viewBox="0 0 320 72" preserveAspectRatio="xMidYMid meet"></svg>
278
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mt-2 mb-0.5">Action counts (run)</div>
279
+ <svg id="tsActionMix" class="w-full h-14" viewBox="0 0 320 52" preserveAspectRatio="xMidYMid meet"></svg>
280
+ </div>
281
+ </div>
282
+ </div>
283
+
284
+ <!-- Bottom Stats -->
285
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
286
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
287
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Avg Reward</div>
288
+ <div id="bottomAvgReward" class="text-3xl font-black tracking-tight">0.00</div>
289
+ <div id="bottomAvgDelta" class="text-[10px] font-label text-on-surface-dim mt-1">—</div>
290
+ </div>
291
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
292
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Total Posts</div>
293
+ <div id="bottomTotalPosts" class="text-3xl font-black tracking-tight">0</div>
294
+ <div class="text-[10px] font-label text-on-surface-dim mt-1">across episode</div>
295
+ </div>
296
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
297
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Viral Probability</div>
298
+ <div id="bottomViralProb" class="text-3xl font-black tracking-tight">LOW (0%)</div>
299
+ <p id="viralFormulaNote" class="text-[9px] font-label text-on-surface-dim/90 leading-snug mt-2">From current engagement rate only (UI heuristic).</p>
300
+ <div class="absolute bottom-0 right-0 w-2/3 h-10 opacity-30 pointer-events-none">
301
+ <svg viewBox="0 0 200 30" class="w-full h-full" preserveAspectRatio="none">
302
+ <defs><linearGradient id="viralGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#d0bcff;stop-opacity:.5"/><stop offset="50%" style="stop-color:#ea6479;stop-opacity:.5"/><stop offset="100%" style="stop-color:#7bd0ff;stop-opacity:.5"/></linearGradient></defs>
303
+ <path d="M0,25 Q30,5 60,20 Q90,30 120,10 Q150,0 180,15 Q200,25 200,30 L0,30Z" fill="url(#viralGrad)"/>
304
+ </svg>
305
+ </div>
306
+ </div>
307
+ </div>
308
+
309
+ <!-- Main Grid: Actions / History / Intelligence -->
310
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-5">
311
+
312
+ <!-- Left: Actions + History -->
313
+ <div class="lg:col-span-8 space-y-5">
314
+
315
+ <!-- Action Panel -->
316
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
317
+ <h3 class="text-sm font-bold mb-4 flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">gamepad</span>Send Action</h3>
318
+ <div class="grid grid-cols-3 gap-3 mb-3">
319
+ <button type="button" title="Advance one hour, recover energy, reduce burnout. Competitors still simulate." onclick="doAction('rest')" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-tertiary/5 to-tertiary/10 border border-tertiary/15 hover:border-tertiary/40 hover:from-tertiary/10 hover:to-tertiary/20 text-center">
320
+ <span class="material-symbols-outlined text-tertiary text-3xl group-hover:scale-110 transition-transform">hotel</span>
321
+ <div class="text-sm font-bold text-tertiary mt-1">Rest</div>
322
+ <div class="text-[9px] text-on-surface-dim mt-0.5">+0.12 energy recovery</div>
323
+ </button>
324
+ <button type="button" title="Add one item to the queue. Costs a little energy; use before Post for cheaper publishes." onclick="doAction('create_content')" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-secondary/5 to-secondary/10 border border-secondary/15 hover:border-secondary/40 hover:from-secondary/10 hover:to-secondary/20 text-center">
325
+ <span class="material-symbols-outlined text-secondary text-3xl group-hover:scale-110 transition-transform">edit_note</span>
326
+ <div class="text-sm font-bold text-secondary mt-1">Create</div>
327
+ <div class="text-[9px] text-on-surface-dim mt-0.5">-0.05 energy, +1 queue</div>
328
+ </button>
329
+ <button type="button" title="Publish to the feed: choose format, topic, and tags. Drives engagement and tag stats." onclick="showPostForm()" id="postBtn" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/15 hover:border-primary/40 hover:from-primary/10 hover:to-primary/20 text-center">
330
+ <span class="material-symbols-outlined text-primary text-3xl group-hover:scale-110 transition-transform">send</span>
331
+ <div class="text-sm font-bold text-primary mt-1">Post</div>
332
+ <div class="text-[9px] text-on-surface-dim mt-0.5">type + topic + tags</div>
333
+ </button>
334
+ </div>
335
+ <!-- Post Form -->
336
+ <div id="postForm" class="hidden fade-in space-y-2.5 p-4 rounded-xl bg-surface border border-outline/30">
337
+ <div class="grid grid-cols-2 gap-2.5">
338
+ <select id="contentType" class="bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm font-label focus:ring-1 focus:ring-primary focus:outline-none">
339
+ <option value="reel">Reel (-0.25 energy)</option>
340
+ <option value="carousel">Carousel (-0.20)</option>
341
+ <option value="story">Story (-0.08)</option>
342
+ <option value="text_post">Text Post (-0.06)</option>
343
+ </select>
344
+ <input id="topicInput" class="bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none" placeholder="Topic (e.g. AI trends)"/>
345
+ </div>
346
+ <input id="tagsInput" class="w-full bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none" placeholder="Tags comma-separated (ai, ml, coding)"/>
347
+ <div class="flex gap-2">
348
+ <button type="button" onclick="doPost()" class="px-5 py-2 rounded-lg bg-primary text-[#23005c] font-bold text-sm hover:opacity-90 transition">Send Post</button>
349
+ <button type="button" onclick="hidePostForm()" class="px-5 py-2 rounded-lg border border-outline/30 text-sm text-on-surface-dim hover:bg-white/5 transition">Cancel</button>
350
+ </div>
351
+ </div>
352
+ </div>
353
+
354
+ <!-- Simulate Scenarios (loaded from /dashboard/scenarios) -->
355
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
356
+ <div class="flex flex-wrap justify-between items-center gap-2 mb-3">
357
+ <h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-secondary text-lg">science</span>Simulate Scenarios</h3>
358
+ <div class="flex flex-col items-end gap-0.5">
359
+ <div class="flex items-center gap-2">
360
+ <span id="scenarioCount" class="text-[9px] font-label text-primary font-bold">…</span>
361
+ <span class="text-[9px] font-label text-on-surface-dim">168-step episode</span>
362
+ </div>
363
+ <span class="text-[8px] font-label text-on-surface-dim/70 max-w-[16rem] text-right leading-tight">All strategies below — scroll the grid or search. Count updates after load.</span>
364
+ </div>
365
+ </div>
366
+ <div class="mb-3 space-y-2">
367
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Easy</div>
368
+ <div class="flex flex-wrap gap-2">
369
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_morning_story')">Morning story</button>
370
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_one_a_day')">One text @ 1pm</button>
371
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_relaxed')">Afternoon story</button>
372
+ </div>
373
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Medium</div>
374
+ <div class="flex flex-wrap gap-2">
375
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_queue_cycle')">Create → post</button>
376
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_trend_rotate')">Trend + formats</button>
377
+ <button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_two_format')">Reel + carousel</button>
378
+ </div>
379
+ </div>
380
+ <input type="search" id="scenarioFilter" autocomplete="off" placeholder="Search strategies by name or description…" class="w-full mb-2 bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none"/>
381
+ <div id="scenarioGrid" tabindex="0" role="region" aria-label="Strategy list, scroll for all scenarios" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2 mb-3 max-h-[min(52vh,36rem)] min-h-[14rem] overflow-y-auto overscroll-y-contain pr-1 py-1 rounded-lg border border-outline/15 bg-surface-low/40 scrollbar-thin shadow-inner">
382
+ <div class="col-span-full text-on-surface-dim text-[10px] italic py-4 text-center">Loading strategies…</div>
383
+ </div>
384
+ <!-- Sim Progress -->
385
+ <div id="simProgress" class="hidden">
386
+ <div class="flex items-center gap-3 mb-2">
387
+ <div class="h-2 flex-1 bg-surface-top rounded-full overflow-hidden"><div id="simBar" class="h-full bg-gradient-to-r from-primary to-secondary transition-all duration-100 rounded-full" style="width:0%"></div></div>
388
+ <span id="simPct" class="text-[10px] font-label text-on-surface-dim w-8 text-right">0%</span>
389
+ </div>
390
+ <div id="simResult" class="hidden"></div>
391
+ </div>
392
+ </div>
393
+
394
+ <!-- Step History -->
395
+ <div class="glass-solid rounded-xl overflow-hidden">
396
+ <div class="p-4 border-b border-white/5 flex justify-between items-center">
397
+ <h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-on-surface-dim text-lg">history</span>Step History</h3>
398
+ </div>
399
+ <div id="historyLog" class="p-4 space-y-1.5 max-h-72 overflow-y-auto text-[11px] font-mono leading-relaxed">
400
+ <div class="text-on-surface-dim italic">Reset the environment to begin...</div>
401
+ </div>
402
+ </div>
403
+ </div>
404
+
405
+ <!-- Right: Intelligence Panels -->
406
+ <div class="lg:col-span-4 space-y-5">
407
+
408
+ <!-- Grader Score (shown when done) -->
409
+ <div id="graderCard" class="hidden glass-solid p-5 rounded-xl border-2 border-primary pulse-glow overflow-hidden">
410
+ <div class="flex justify-between items-start">
411
+ <div>
412
+ <div class="text-[9px] font-label text-primary uppercase tracking-widest">Final Score</div>
413
+ <div id="graderScore" class="text-5xl font-black text-primary tracking-tighter mt-1">—</div>
414
+ </div>
415
+ <span class="material-symbols-outlined text-primary/20 text-5xl">emoji_events</span>
416
+ </div>
417
+ <div id="graderLabel" class="mt-2 text-xs font-label text-on-surface-dim">Episode complete</div>
418
+ </div>
419
+
420
+ <!-- Trending -->
421
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
422
+ <h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-secondary text-lg">trending_up</span>Trending Now</h3>
423
+ <div class="mb-3">
424
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Topics</div>
425
+ <div id="trendTopics" class="flex flex-wrap gap-1.5"></div>
426
+ </div>
427
+ <div>
428
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Tags</div>
429
+ <div id="trendTags" class="flex flex-wrap gap-1.5"></div>
430
+ </div>
431
+ </div>
432
+
433
+ <!-- Tag Performance -->
434
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
435
+ <h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">science</span>Tag Performance</h3>
436
+ <div id="tagPerf" class="space-y-2.5 text-xs">
437
+ <div class="text-on-surface-dim italic">No data yet</div>
438
+ </div>
439
+ </div>
440
+
441
+ <!-- Competitors -->
442
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
443
+ <h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-tertiary text-lg">groups</span>Competitors</h3>
444
+ <div class="mb-3 flex justify-between items-center">
445
+ <span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Avg Engagement</span>
446
+ <span id="compEng" class="text-sm font-bold text-tertiary">0.000</span>
447
+ </div>
448
+ <div id="compPosts" class="space-y-2 text-xs">
449
+ <div class="text-on-surface-dim italic">No competitor posts yet</div>
450
+ </div>
451
+ </div>
452
+ </div>
453
+ </div>
454
+
455
+ <!-- Simulation History -->
456
+ <div class="glass-solid rounded-xl overflow-hidden">
457
+ <div class="p-4 border-b border-white/5 flex justify-between items-center">
458
+ <h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">history</span>Simulation History</h3>
459
+ <div class="flex items-center gap-2">
460
+ <button onclick="loadHistory()" class="text-[9px] font-label text-secondary hover:text-secondary/80 transition">Refresh</button>
461
+ <button onclick="clearHistory()" class="text-[9px] font-label text-on-surface-dim/50 hover:text-tertiary transition">Clear</button>
462
+ </div>
463
+ </div>
464
+ <div class="overflow-x-auto">
465
+ <table class="w-full text-[11px] font-label">
466
+ <thead>
467
+ <tr class="text-on-surface-dim/60 uppercase tracking-wider border-b border-white/5">
468
+ <th class="text-left px-4 py-2.5">Time</th>
469
+ <th class="text-left px-4 py-2.5">Scenario</th>
470
+ <th class="text-left px-4 py-2.5">Task</th>
471
+ <th class="text-right px-4 py-2.5">Score</th>
472
+ <th class="text-right px-4 py-2.5">Steps</th>
473
+ <th class="text-right px-4 py-2.5">Posts</th>
474
+ <th class="text-right px-4 py-2.5">Followers</th>
475
+ <th class="text-right px-4 py-2.5">Delta</th>
476
+ <th class="text-right px-4 py-2.5">Energy</th>
477
+ <th class="text-center px-4 py-2.5">Status</th>
478
+ </tr>
479
+ </thead>
480
+ <tbody id="historyTable">
481
+ <tr><td colspan="10" class="px-4 py-6 text-center text-on-surface-dim italic">No history yet — run a simulation</td></tr>
482
+ </tbody>
483
+ </table>
484
+ </div>
485
+ </div>
486
+
487
+ </main>
488
+ </div>
489
+
490
+ <script>
491
+ const API=window.location.origin;
492
+ const DAYS=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
493
+ function fmtAxisNum(v){
494
+ const a=Math.abs(v);
495
+ if(a>=1e6)return (v/1e6).toFixed(1)+"M";
496
+ if(a>=1e3)return (v/1e3).toFixed(1)+"k";
497
+ if(a>=100)return v.toFixed(0);
498
+ if(a>=10)return v.toFixed(1);
499
+ return v.toFixed(2);
500
+ }
501
+ function refreshTaskScoreBlurb(){
502
+ const el=document.getElementById("taskScoreBlurb");
503
+ if(!el)return;
504
+ const t=document.getElementById("taskSelect").value;
505
+ if(t==="weekly_engage"){
506
+ el.innerHTML="<span class=\"text-on-surface font-semibold\">Easy (Engage):</span> final score = min(1, total episode engagement ÷ theoretical maximum). If energy hits 0 at the end, the score is multiplied by 0.3.";
507
+ }else if(t==="weekly_strategic"){
508
+ el.innerHTML="<span class=\"text-on-surface font-semibold\">Medium (Strategic):</span> 35% normalized engagement + 25% tag mix (discovery + top-tag performance) + 25% average energy + 15% days with solid posts. Penalties if energy ever crashes low or you use fewer than 5 unique tags.";
509
+ }else{
510
+ el.innerHTML="<span class=\"text-on-surface font-semibold\">Hard (Competitive):</span> 25% engagement + 20% tags + 20% follower growth + 15% beating rival avg engagement + 10% differentiated topics + 10% minimum energy floor. Score is 0 if burned out; ×0.5 if fewer than 3 content types; ×0.7 if fewer than 8 unique tags.";
511
+ }
512
+ }
513
+ let currentObs=null;
514
+ const energyHistory=[];
515
+ const rewardHistory=[];
516
+ const followerHistory=[];
517
+ const actionLog=[];
518
+ const timelineHistory=[];
519
+ let totalPostsCount=0;
520
+
521
+ function recordTimelineFromObs(d, actionType){
522
+ const o=d.observation||d;
523
+ const step=o.metadata?.step??timelineHistory.length;
524
+ timelineHistory.push({
525
+ step,
526
+ simHour:(o.days_elapsed??0)*24+(o.current_hour??0),
527
+ hour:o.current_hour??0,
528
+ day:o.day_of_week??0,
529
+ energy:o.creator_energy??0,
530
+ followers:o.follower_count??0,
531
+ engagement:o.engagement_rate??0,
532
+ reward:d.reward??0,
533
+ sat:o.niche_saturation??0,
534
+ queue:o.content_queue_size??0,
535
+ postsToday:o.posts_today??0,
536
+ compAvg:o.competitor_avg_engagement??0,
537
+ sleepDebt:o.sleep_debt??0,
538
+ hoursSinceSleep:o.hours_since_sleep??0,
539
+ action:actionType||null,
540
+ });
541
+ }
542
+
543
+ function simActionType(actionStr){
544
+ const a=actionStr||"";
545
+ if(a.startsWith("post"))return "post";
546
+ if(a.startsWith("rest"))return "rest";
547
+ if(a.startsWith("create"))return "create_content";
548
+ return null;
549
+ }
550
+
551
+ function redrawTimelineCharts(){
552
+ drawStepLineChart("tsEnergy","energy","#ffb2b9");
553
+ drawStepLineChart("tsFollowers","followers","#7bd0ff");
554
+ drawFollowerDeltaChart("tsFollowDelta");
555
+ drawStepLineChart("tsEngagement","engagement","#a078ff");
556
+ drawStepLineChart("tsReward","reward","#d0bcff");
557
+ drawStepLineChart("tsSat","sat","#ea6479");
558
+ drawStepLineChart("tsQueue","queue","#00a6e0");
559
+ drawStepLineChart("tsComp","compAvg","#7bd0ff");
560
+ drawStepLineChart("tsSleep","sleepDebt","#958ea0");
561
+ drawStepLineChart("tsAwake","hoursSinceSleep","#cbc3d7");
562
+ drawPostsByHour("tsPostsHour");
563
+ drawActionMix("tsActionMix");
564
+ }
565
+
566
+ function drawStepLineChart(svgId,key,color){
567
+ const svg=document.getElementById(svgId);
568
+ const data=timelineHistory;
569
+ if(!svg)return;
570
+ const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
571
+ const plotW=W-pL-pR,plotH=H-pT-pB;
572
+ if(!data.length){
573
+ svg.innerHTML=`<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No steps yet</text>`;
574
+ return;
575
+ }
576
+ const vals=data.map(d=>Number(d[key]??0));
577
+ let minV=Math.min(...vals),maxV=Math.max(...vals);
578
+ if(maxV-minV<1e-9){minV-=0.5;maxV+=0.5;}
579
+ const n=data.length;
580
+ const pts=data.map((d,i)=>{
581
+ const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
582
+ const v=Number(d[key]??0);
583
+ const y=pT+(1-(v-minV)/(maxV-minV))*plotH;
584
+ return {x,y};
585
+ });
586
+ let lineD;
587
+ if(pts.length===1)lineD=`M${pts[0].x},${pts[0].y} L${(pts[0].x+1)},${pts[0].y}`;
588
+ else lineD=smoothPath(pts);
589
+ const last=pts[pts.length-1],first=pts[0];
590
+ const areaD=lineD+` L${last.x},${H-pB} L${first.x},${H-pB} Z`;
591
+ const gid="g_"+svgId.replace(/[^a-zA-Z0-9_]/g,"_");
592
+ let h="";
593
+ for(let g=0;g<=4;g++){
594
+ const y=pT+(g/4)*plotH;
595
+ const val=maxV-(g/4)*(maxV-minV);
596
+ h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
597
+ h+=`<text x="${pL-5}" y="${y+3}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${fmtAxisNum(val)}</text>`;
598
+ }
599
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
600
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
601
+ h+=`<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity="0.22"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>`;
602
+ h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="${color}" stroke-width="2"/>`;
603
+ const lastI=n-1;
604
+ h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
605
+ h+=`<text x="${pL+plotW/2}" y="${H-8}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${Math.floor(lastI/2)}</text>`;
606
+ h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
607
+ h+=`<text x="${pL+plotW/2}" y="${H-1}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif" opacity="0.75">step</text>`;
608
+ svg.innerHTML=h;
609
+ }
610
+
611
+ function drawFollowerDeltaChart(svgId){
612
+ const svg=document.getElementById(svgId);
613
+ const data=timelineHistory;
614
+ if(!svg)return;
615
+ const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
616
+ const plotW=W-pL-pR,plotH=H-pT-pB;
617
+ if(data.length<2){
618
+ svg.innerHTML=`<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">Need 2+ steps</text>`;
619
+ return;
620
+ }
621
+ const dlt=data.map((d,i)=>i===0?0:d.followers-data[i-1].followers);
622
+ const maxA=Math.max(...dlt.map(a=>Math.abs(a)),1);
623
+ const midY=pT+plotH/2;
624
+ const amp=(plotH/2-4);
625
+ const n=data.length;
626
+ const pts=dlt.map((dv,i)=>{
627
+ const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
628
+ const y=midY-(dv/maxA)*amp;
629
+ return {x,y};
630
+ });
631
+ const lineD=smoothPath(pts);
632
+ let h="";
633
+ h+=`<line x1="${pL}" y1="${midY}" x2="${W-pR}" y2="${midY}" stroke="#494454" stroke-width="0.6" opacity="0.45"/>`;
634
+ h+=`<text x="${pL-5}" y="${pT+8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">+${fmtAxisNum(maxA)}</text>`;
635
+ h+=`<text x="${pL-5}" y="${H-pB}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${fmtAxisNum(-maxA)}</text>`;
636
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
637
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
638
+ h+=`<path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2"/>`;
639
+ const lastI=n-1;
640
+ h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
641
+ h+=`<text x="${pL+plotW/2}" y="${H-8}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${Math.floor(lastI/2)}</text>`;
642
+ h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
643
+ h+=`<text x="${pL+plotW/2}" y="${H-1}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif" opacity="0.75">step · Δ followers</text>`;
644
+ svg.innerHTML=h;
645
+ }
646
+
647
+ function drawPostsByHour(svgId){
648
+ const svg=document.getElementById(svgId);
649
+ if(!svg)return;
650
+ const buckets=new Array(24).fill(0);
651
+ for(const p of timelineHistory){
652
+ if(p.action==="post")buckets[p.hour]++;
653
+ }
654
+ const postN=buckets.reduce((a,b)=>a+b,0);
655
+ if(!postN){
656
+ svg.innerHTML='<text x="160" y="40" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No posts yet — histogram fills when you post</text>';
657
+ return;
658
+ }
659
+ const max=Math.max(...buckets,1);
660
+ const W=320,H=64,pL=16,pR=4,pT=4,pB=16;
661
+ const slot=(W-pL-pR)/24;
662
+ const bw=slot*0.72;
663
+ let rects="";
664
+ for(let h=0;h<24;h++){
665
+ const bh=(buckets[h]/max)*(H-pT-pB);
666
+ const x=pL+h*slot+(slot-bw)/2;
667
+ const y=H-pB-Math.max(bh,0.5);
668
+ rects+=`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${bw.toFixed(2)}" height="${Math.max(bh,0.5).toFixed(2)}" fill="#d0bcff" rx="1"/>`;
669
+ }
670
+ let labels="";
671
+ for(let h=0;h<24;h+=6){
672
+ labels+=`<text x="${(pL+h*slot+bw/2).toFixed(1)}" y="${H-3}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${h}h</text>`;
673
+ }
674
+ svg.innerHTML=rects+labels;
675
+ }
676
+
677
+ function drawActionMix(svgId){
678
+ const svg=document.getElementById(svgId);
679
+ if(!svg)return;
680
+ if(!timelineHistory.length){
681
+ svg.innerHTML='<text x="160" y="28" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No steps yet</text>';
682
+ return;
683
+ }
684
+ let r=0,c=0,p=0;
685
+ for(const x of timelineHistory){
686
+ if(x.action==="rest")r++;
687
+ else if(x.action==="create_content")c++;
688
+ else if(x.action==="post")p++;
689
+ }
690
+ const W=320,H=44,pT=6,pB=4;
691
+ const labels=[["Rest",r,"#ffb2b9"],["Create",c,"#7bd0ff"],["Post",p,"#d0bcff"]];
692
+ const max=Math.max(r,c,p,1);
693
+ const bw=90;
694
+ let out="";
695
+ labels.forEach(([lab,n,col],i)=>{
696
+ const x=20+i*100;
697
+ const bh=(n/max)*(H-pT-pB);
698
+ const y=H-pB-bh;
699
+ out+=`<rect x="${x}" y="${y}" width="${bw}" height="${Math.max(bh,2)}" fill="${col}" rx="2"/>`;
700
+ out+=`<text x="${x+bw/2}" y="${H+2}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lab} ${n}</text>`;
701
+ });
702
+ svg.innerHTML=out;
703
+ }
704
+
705
+ async function doReset(){
706
+ setStatus("Resetting...");
707
+ const task=document.getElementById("taskSelect").value;
708
+ energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;actionLog.length=0;timelineHistory.length=0;totalPostsCount=0;
709
+ try{
710
+ const r=await fetch(API+"/dashboard/reset",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({task})});
711
+ const d=await r.json();
712
+ updateUI(d);
713
+ document.getElementById("historyLog").innerHTML='<div class="text-secondary font-bold">Environment reset — task: '+task+'</div>';
714
+ document.getElementById("graderCard").classList.add("hidden");
715
+ document.getElementById("engagementChart").innerHTML="";
716
+ document.getElementById("followerChart").innerHTML="";
717
+ document.getElementById("recentActions").innerHTML='<div class="text-on-surface-dim italic text-[10px]">No actions yet</div>';
718
+ drawBurnoutMeter(1);
719
+ setStatus("Running");
720
+ }catch(e){setStatus("Error: "+e.message)}
721
+ }
722
+
723
+ async function doAction(type){
724
+ setStatus("Stepping...");
725
+ try{
726
+ const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:{action_type:type}})});
727
+ const d=await r.json();
728
+ updateUI(d,{actionType:type});
729
+ addLog(type+"()",d.reward,d.done,d.observation?.error);
730
+ }catch(e){setStatus("Error: "+e.message)}
731
+ }
732
+
733
+ async function doPost(){
734
+ const ct=document.getElementById("contentType").value;
735
+ const topic=document.getElementById("topicInput").value.trim();
736
+ const tagsRaw=document.getElementById("tagsInput").value.trim();
737
+ const tags=tagsRaw?tagsRaw.split(",").map(t=>t.trim()).filter(Boolean):[];
738
+ if(!topic){alert("Enter a topic");return}
739
+ setStatus("Stepping...");
740
+ try{
741
+ const body={action:{action_type:"post",content_type:ct,topic,tags:tags.length?tags:undefined}};
742
+ const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
743
+ const d=await r.json();
744
+ updateUI(d,{actionType:"post"});
745
+ addLog(`post(${ct},"${topic}",[${tags.join(",")}])`,d.reward,d.done,d.observation?.error);
746
+ hidePostForm();
747
+ }catch(e){setStatus("Error: "+e.message)}
748
+ }
749
+
750
+ function updateUI(d, opts={}){
751
+ const o=d.observation||d;
752
+ currentObs=o;
753
+ recordTimelineFromObs(d, opts.actionType);
754
+ const energy=o.creator_energy??1;
755
+ const followers=o.follower_count??0;
756
+ const eng=o.engagement_rate??0;
757
+ const sat=o.niche_saturation??0;
758
+ const compAvg=o.competitor_avg_engagement??0;
759
+ const reward=d.reward??0;
760
+
761
+ document.getElementById("energyVal").textContent=energy.toFixed(2);
762
+ document.getElementById("energyBar").style.width=(energy*100)+"%";
763
+ const eHint=document.getElementById("energyHint");
764
+ if(energy<=0){eHint.textContent="BURNED OUT";eHint.className="mt-1.5 text-[9px] font-label text-error"}
765
+ else if(energy<0.3){eHint.textContent="CRITICAL";eHint.className="mt-1.5 text-[9px] font-label text-tertiary-ctr"}
766
+ else if(energy<0.5){eHint.textContent="LOW — REST NOW";eHint.className="mt-1.5 text-[9px] font-label text-tertiary"}
767
+ else if(energy<0.8){eHint.textContent="MODERATE";eHint.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
768
+ else{eHint.textContent="FULL";eHint.className="mt-1.5 text-[9px] font-label text-secondary"}
769
+
770
+ document.getElementById("followersVal").textContent=followers.toLocaleString();
771
+ const delta=followers-10000;
772
+ const dEl=document.getElementById("followersDelta");
773
+ dEl.textContent=(delta>=0?"+":"")+delta+" since start";
774
+ dEl.className="mt-1.5 text-[9px] font-label "+(delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim");
775
+
776
+ document.getElementById("engVal").textContent=eng.toFixed(3);
777
+ const diff=eng-compAvg;
778
+ const evc=document.getElementById("engVsComp");
779
+ evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
780
+ evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
781
+
782
+ document.getElementById("timeVal").textContent=(o.current_hour??0)+":00";
783
+ document.getElementById("dayVal").textContent=DAYS[o.day_of_week??0];
784
+ document.getElementById("postsVal").textContent=o.posts_today??0;
785
+ document.getElementById("queueVal").textContent=o.content_queue_size??0;
786
+ document.getElementById("satVal").textContent=sat.toFixed(2);
787
+ const sH=document.getElementById("satHint");
788
+ if(sat>0.7){sH.textContent="HIGH — diversify topics";sH.className="mt-1.5 text-[9px] font-label text-tertiary"}
789
+ else if(sat>0.4){sH.textContent="MEDIUM — some room";sH.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
790
+ else{sH.textContent="LOW — post unique topics";sH.className="mt-1.5 text-[9px] font-label text-primary"}
791
+ document.getElementById("stepNum").textContent=o.metadata?.step??0;
792
+
793
+ // Charts
794
+ energyHistory.push(energy);
795
+ rewardHistory.push(reward);
796
+ followerHistory.push(followers);
797
+ drawEngagementChart();
798
+ drawBurnoutMeter(energy);
799
+ drawFollowerBars();
800
+ updateBottomStats();
801
+ if(d.action_type||d.observation?.metadata)addRecentAction(d);
802
+
803
+ // Trending
804
+ const tt=document.getElementById("trendTopics");
805
+ tt.innerHTML=(o.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
806
+ const tg=document.getElementById("trendTags");
807
+ tg.innerHTML=(o.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
808
+
809
+ // Tag perf — sidebar panel
810
+ const tp=document.getElementById("tagPerf");
811
+ const perf=o.tag_performance||{};
812
+ const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
813
+ if(entries.length){
814
+ const maxV=Math.max(...entries.map(e=>e[1]),0.01);
815
+ tp.innerHTML=entries.slice(0,6).map(([tag,val],i)=>{
816
+ const w=Math.min(100,(val/maxV)*100);
817
+ const c=i%2===0?"primary":"secondary";
818
+ return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
819
+ }).join("");
820
+ }else{tp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
821
+
822
+ // Top tags styled list
823
+ const ttl=document.getElementById("topTagsList");
824
+ const colors=["secondary","primary","tertiary","on-surface-dim"];
825
+ if(entries.length){
826
+ ttl.innerHTML=entries.slice(0,4).map(([tag,val],i)=>{
827
+ const c=colors[i%colors.length];
828
+ const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
829
+ return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
830
+ }).join("");
831
+ }else{ttl.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
832
+
833
+ // Competitors
834
+ document.getElementById("compEng").textContent=compAvg.toFixed(3);
835
+ const cp=document.getElementById("compPosts");
836
+ const posts=o.competitor_recent_posts||[];
837
+ if(posts.length){
838
+ const icons={reel:"movie",carousel:"view_carousel",story:"auto_stories",text_post:"article"};
839
+ cp.innerHTML=posts.slice(0,4).map(p=>`<div class="p-2.5 rounded-lg bg-surface border border-outline/15 flex items-start gap-2.5"><span class="material-symbols-outlined text-tertiary/40 text-lg mt-0.5">${icons[p.content_type]||"article"}</span><div class="flex-1 min-w-0"><div class="flex justify-between text-[10px]"><span class="font-bold text-on-surface truncate">${p.topic||"—"}</span><span class="text-on-surface-dim shrink-0 ml-2">${p.hours_ago}h</span></div><div class="text-[9px] text-on-surface-dim mt-0.5">${p.content_type} · eng: <span class="text-tertiary">${(p.engagement??0).toFixed(3)}</span></div></div></div>`).join("");
840
+ }else{cp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No competitor posts yet</div>'}
841
+
842
+ // Done state
843
+ if(d.done){
844
+ setStatus("Episode Done");
845
+ document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=true;b.classList.add("opacity-30","pointer-events-none")});
846
+ const score=o.metadata?.grader_score;
847
+ if(score!=null){
848
+ const gc=document.getElementById("graderCard");
849
+ gc.classList.remove("hidden");
850
+ document.getElementById("graderScore").textContent=score.toFixed(4);
851
+ const lbl=document.getElementById("graderLabel");
852
+ if(score>=0.7)lbl.textContent="Excellent performance!";
853
+ else if(score>=0.4)lbl.textContent="Decent strategy, room for improvement";
854
+ else lbl.textContent="Poor performance — agent needs better strategy";
855
+ }
856
+ }else{
857
+ document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=false;b.classList.remove("opacity-30","pointer-events-none")});
858
+ setStatus("Running");
859
+ }
860
+ redrawTimelineCharts();
861
+ }
862
+
863
+ function smoothPath(pts){
864
+ if(pts.length<2)return pts.map((p,i)=>(i===0?"M":"L")+p.x.toFixed(1)+","+p.y.toFixed(1)).join(" ");
865
+ let d="M"+pts[0].x.toFixed(1)+","+pts[0].y.toFixed(1);
866
+ for(let i=1;i<pts.length;i++){
867
+ const cp=(pts[i].x-pts[i-1].x)/3;
868
+ d+=` C${(pts[i-1].x+cp).toFixed(1)},${pts[i-1].y.toFixed(1)} ${(pts[i].x-cp).toFixed(1)},${pts[i].y.toFixed(1)} ${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)}`;
869
+ }
870
+ return d;
871
+ }
872
+
873
+ function drawEngagementChart(){
874
+ const svg=document.getElementById("engagementChart");
875
+ const data=rewardHistory;
876
+ if(!svg||!data.length)return;
877
+ const W=760,H=200,pL=56,pR=14,pT=12,pB=40;
878
+ const plotW=W-pL-pR,plotH=H-pT-pB;
879
+ const minR=Math.min(0,Math.min(...data));
880
+ const maxR=Math.max(...data,0.01);
881
+ const span=Math.max(maxR-minR,1e-6)*1.08;
882
+ const y0=minR;
883
+ const pts=data.map((v,i)=>({
884
+ x:pL+(i/Math.max(data.length-1,1))*plotW,
885
+ y:pT+(1-(v-y0)/span)*plotH,
886
+ }));
887
+ const lineD=smoothPath(pts);
888
+ const areaD=lineD+` L${pts[pts.length-1].x.toFixed(1)},${(H-pB).toFixed(1)} L${pts[0].x.toFixed(1)},${(H-pB).toFixed(1)} Z`;
889
+ const gid="eng_reward_grad";
890
+ let h="";
891
+ for(let g=0;g<=4;g++){
892
+ const y=pT+(g/4)*plotH;
893
+ const val=y0+(1-g/4)*span;
894
+ h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
895
+ h+=`<text x="${pL-6}" y="${y+3}" text-anchor="end" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">${val.toFixed(2)}</text>`;
896
+ }
897
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
898
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
899
+ h+=`<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7bd0ff" stop-opacity="0.28"/><stop offset="1" stop-color="#7bd0ff" stop-opacity="0"/></linearGradient></defs>`;
900
+ h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2.5"/>`;
901
+ const lastI=data.length-1;
902
+ h+=`<text x="${pL}" y="${H-18}" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step 0</text>`;
903
+ h+=`<text x="${pL+plotW/2}" y="${H-18}" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step ${Math.floor(lastI/2)}</text>`;
904
+ h+=`<text x="${W-pR}" y="${H-18}" text-anchor="end" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step ${lastI}</text>`;
905
+ h+=`<text x="${pL+plotW/2}" y="${H-4}" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif" opacity="0.85">simulation step index</text>`;
906
+ h+=`<text x="12" y="${pT+plotH/2}" transform="rotate(-90 12 ${pT+plotH/2})" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif" opacity="0.85">reward</text>`;
907
+ svg.innerHTML=h;
908
+ }
909
+
910
+ function drawBurnoutMeter(energy){
911
+ const burnout=Math.round((1-energy)*100);
912
+ const circ=2*Math.PI*50;
913
+ const fill=(burnout/100)*circ;
914
+ document.getElementById("burnoutArc").setAttribute("stroke-dasharray",fill.toFixed(1)+" "+circ.toFixed(1));
915
+ document.getElementById("burnoutPct").textContent=burnout+"%";
916
+ const rec=document.getElementById("burnoutRec");
917
+ if(burnout>=70)rec.textContent="Recommendation: Limit posting for 45 mins to prevent creative fatigue.";
918
+ else if(burnout>=40)rec.textContent="Recommendation: Alternate between creating and resting to maintain output quality.";
919
+ else rec.textContent="Recommendation: Energy levels healthy. Good window for high-effort content.";
920
+ }
921
+
922
+ function drawFollowerBars(){
923
+ const svg=document.getElementById("followerChart");
924
+ const data=followerHistory;
925
+ if(data.length<2){svg.innerHTML="";return}
926
+ const W=300,H=120,pL=40,pR=8,pT=6,pB=22,plotW=W-pL-pR,plotH=H-pT-pB;
927
+ const chunks=Math.min(data.length,7);
928
+ const chunkSize=Math.max(1,Math.floor(data.length/chunks));
929
+ const bars=[];
930
+ for(let i=0;i<chunks;i++){
931
+ const start=i*chunkSize;
932
+ const end=Math.min(start+chunkSize,data.length);
933
+ const avg=data.slice(start,end).reduce((a,b)=>a+b,0)/(end-start);
934
+ bars.push(avg);
935
+ }
936
+ const fMin=Math.min(...bars),fMax=Math.max(...bars);
937
+ const base=fMin*0.998;
938
+ const maxDelta=Math.max(...bars.map(b=>b-base),1);
939
+ const barW=plotW/bars.length*0.58;
940
+ const gap=plotW/bars.length*0.42;
941
+ let html="";
942
+ html+=`<text x="4" y="${pT+10}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMax)}</text>`;
943
+ html+=`<text x="4" y="${pT+plotH}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMin)}</text>`;
944
+ html+=`<text transform="rotate(-90 14 ${pT+plotH/2})" x="14" y="${pT+plotH/2}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">followers</text>`;
945
+ bars.forEach((v,i)=>{
946
+ const h=Math.max(4,((v-base)/maxDelta)*plotH);
947
+ const x=pL+i*(plotW/bars.length)+(gap/2);
948
+ const y=pT+plotH-h;
949
+ const opacity=0.5+0.5*(i/bars.length);
950
+ html+=`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="3" fill="#7bd0ff" opacity="${opacity.toFixed(2)}"/>`;
951
+ html+=`<text x="${(x+barW/2).toFixed(1)}" y="${H-4}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${DAYS[i%7]}</text>`;
952
+ });
953
+ svg.innerHTML=html;
954
+ const delta=data[data.length-1]-data[0];
955
+ const pct=((delta/data[0])*100);
956
+ document.getElementById("followerTotal").textContent=(delta>=0?"+":"")+Math.round(delta).toLocaleString();
957
+ document.getElementById("followerDeltaPct").textContent=(pct>=0?"+":"")+pct.toFixed(0)+"% vs start";
958
+ }
959
+
960
+ function updateBottomStats(){
961
+ if(rewardHistory.length){
962
+ const avg=rewardHistory.reduce((a,b)=>a+b,0)/rewardHistory.length;
963
+ document.getElementById("bottomAvgReward").textContent=avg.toFixed(2);
964
+ if(rewardHistory.length>10){
965
+ const recent=rewardHistory.slice(-10).reduce((a,b)=>a+b,0)/10;
966
+ const old=rewardHistory.slice(0,10).reduce((a,b)=>a+b,0)/Math.min(10,rewardHistory.length);
967
+ const d=((recent-old)/Math.max(Math.abs(old),0.001)*100);
968
+ document.getElementById("bottomAvgDelta").textContent=(d>=0?"+":"")+d.toFixed(0)+"%";
969
+ document.getElementById("bottomAvgDelta").className="text-[10px] font-label mt-1 "+(d>=0?"text-secondary":"text-tertiary");
970
+ }
971
+ }
972
+ document.getElementById("bottomTotalPosts").textContent=totalPostsCount;
973
+ const eng=currentObs?.engagement_rate??0;
974
+ const viral=Math.min(100,Math.round(eng*1000));
975
+ const label=viral>=70?"HIGH":viral>=40?"MEDIUM":"LOW";
976
+ document.getElementById("bottomViralProb").textContent=label+" ("+viral+"%)";
977
+ const vn=document.getElementById("viralFormulaNote");
978
+ if(vn)vn.textContent="min(100, round("+eng.toFixed(3)+" × 1000)) = "+viral+" — labels LOW/MED/HIGH at 40 and 70 (display only).";
979
+ }
980
+
981
+ function addRecentAction(d){
982
+ const el=document.getElementById("recentActions");
983
+ const step=currentObs?.metadata?.step??0;
984
+ const reward=d.reward??0;
985
+ const icons={rest:"hotel",create_content:"edit_note",post:"send"};
986
+ const colors={rest:"tertiary",create_content:"secondary",post:"primary"};
987
+ const action=d.action_type||d.observation?.last_action||"step";
988
+ const icon=icons[action]||"play_arrow";
989
+ const c=colors[action]||"on-surface-dim";
990
+ const entry=`<div class="flex items-start gap-2.5 fade-in"><span class="material-symbols-outlined text-${c} text-lg mt-0.5 shrink-0">${icon}</span><div class="flex-1 min-w-0"><div class="text-xs font-bold text-on-surface truncate">${action.replace("_"," ")}</div><div class="text-[9px] text-on-surface-dim">${step} steps ago · r=${reward.toFixed(2)}</div></div></div>`;
991
+ if(el.querySelector(".italic"))el.innerHTML="";
992
+ el.innerHTML=entry+el.innerHTML;
993
+ if(el.children.length>8)el.removeChild(el.lastChild);
994
+ }
995
+
996
+ function addLog(action,reward,done,error){
997
+ if(action.startsWith("post"))totalPostsCount++;
998
+ const step=currentObs?.metadata?.step??0;
999
+ const log=document.getElementById("historyLog");
1000
+ const errStr=error?` <span class="text-error">err=${error}</span>`:"";
1001
+ const color=reward>0.5?"text-secondary":reward>0.2?"text-primary":"text-on-surface-dim";
1002
+ const doneStr=done?'<span class="text-tertiary font-bold"> DONE</span>':"";
1003
+ log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[${step}]</span> <span class="text-on-surface">${action}</span> <span class="${color}">r=${(reward??0).toFixed(2)}</span>${doneStr}${errStr}</div>`;
1004
+ log.scrollTop=log.scrollHeight;
1005
+ document.getElementById("rewardBadge").textContent="Last reward: "+(reward??0).toFixed(2);
1006
+ }
1007
+
1008
+ let simRunning=false;
1009
+ async function runSim(scenario){
1010
+ if(simRunning)return;
1011
+ simRunning=true;
1012
+ const task=document.getElementById("taskSelect").value;
1013
+ document.querySelectorAll(".sim-btn").forEach(b=>b.classList.add("opacity-30","pointer-events-none"));
1014
+ document.getElementById("simProgress").classList.remove("hidden");
1015
+ document.getElementById("simResult").classList.add("hidden");
1016
+ document.getElementById("simBar").style.width="0%";
1017
+ document.getElementById("simPct").textContent="0%";
1018
+ document.getElementById("graderCard").classList.add("hidden");
1019
+ energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;timelineHistory.length=0;totalPostsCount=0;
1020
+ setStatus("Simulating...");
1021
+
1022
+ try{
1023
+ const r=await fetch(API+"/dashboard/simulate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({scenario,task})});
1024
+ const d=await r.json();
1025
+ if(d.error){setStatus("Error: "+d.error);simRunning=false;return}
1026
+
1027
+ const log=document.getElementById("historyLog");
1028
+ log.innerHTML=`<div class="text-secondary font-bold mb-1">Sim: ${d.scenario} — ${task}</div><div class="text-on-surface-dim text-[9px] mb-2">${d.description}</div>`;
1029
+
1030
+ const total=d.steps.length;
1031
+ for(let i=0;i<total;i++){
1032
+ const s=d.steps[i];
1033
+ rewardHistory.push(s.reward);
1034
+ energyHistory.push(s.energy);
1035
+ followerHistory.push(s.followers);
1036
+ timelineHistory.push({
1037
+ step:s.step,
1038
+ simHour:(s.days_elapsed??0)*24+(s.hour??0),
1039
+ hour:s.hour??0,
1040
+ day:s.day??0,
1041
+ energy:s.energy,
1042
+ followers:s.followers,
1043
+ engagement:s.engagement_rate,
1044
+ reward:s.reward,
1045
+ sat:s.niche_saturation,
1046
+ queue:s.queue,
1047
+ postsToday:s.posts_today,
1048
+ compAvg:s.competitor_avg_engagement,
1049
+ sleepDebt:s.sleep_debt??0,
1050
+ hoursSinceSleep:s.hours_since_sleep??0,
1051
+ action:simActionType(s.action),
1052
+ });
1053
+ if(s.action.startsWith("post"))totalPostsCount++;
1054
+
1055
+ const pct=Math.round((i+1)/total*100);
1056
+ document.getElementById("simBar").style.width=pct+"%";
1057
+ document.getElementById("simPct").textContent=pct+"%";
1058
+
1059
+ // Live stat card updates every 5 steps
1060
+ if(i%5===0||i===total-1){
1061
+ document.getElementById("energyVal").textContent=s.energy.toFixed(2);
1062
+ document.getElementById("energyBar").style.width=(s.energy*100)+"%";
1063
+ document.getElementById("followersVal").textContent=s.followers.toLocaleString();
1064
+ document.getElementById("engVal").textContent=s.engagement_rate.toFixed(3);
1065
+ document.getElementById("stepNum").textContent=s.step;
1066
+ document.getElementById("timeVal").textContent=s.hour+":00";
1067
+ document.getElementById("dayVal").textContent=DAYS[s.day];
1068
+ document.getElementById("postsVal").textContent=s.posts_today;
1069
+ document.getElementById("queueVal").textContent=s.queue;
1070
+ document.getElementById("satVal").textContent=s.niche_saturation.toFixed(2);
1071
+ document.getElementById("compEng").textContent=s.competitor_avg_engagement.toFixed(3);
1072
+ const diff=s.engagement_rate-s.competitor_avg_engagement;
1073
+ const evc=document.getElementById("engVsComp");
1074
+ evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
1075
+ evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
1076
+ const fdelta=s.followers-10000;
1077
+ const fdEl=document.getElementById("followersDelta");
1078
+ fdEl.textContent=(fdelta>=0?"+":"")+fdelta+" since start";
1079
+ fdEl.className="mt-1.5 text-[9px] font-label "+(fdelta>0?"text-secondary":fdelta<0?"text-tertiary":"text-on-surface-dim");
1080
+
1081
+ drawEngagementChart();
1082
+ drawBurnoutMeter(s.energy);
1083
+ drawFollowerBars();
1084
+ updateBottomStats();
1085
+ redrawTimelineCharts();
1086
+
1087
+ // Update trending
1088
+ const tt=document.getElementById("trendTopics");
1089
+ tt.innerHTML=(s.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
1090
+ const tg=document.getElementById("trendTags");
1091
+ tg.innerHTML=(s.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
1092
+
1093
+ // Update tag perf
1094
+ const perf=s.tag_performance||{};
1095
+ const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
1096
+ const tp=document.getElementById("tagPerf");
1097
+ if(entries.length){
1098
+ const maxV=Math.max(...entries.map(e=>e[1]),0.01);
1099
+ tp.innerHTML=entries.slice(0,6).map(([tag,val],j)=>{
1100
+ const c=j%2===0?"primary":"secondary";
1101
+ const w=Math.min(100,(val/maxV)*100);
1102
+ return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
1103
+ }).join("");
1104
+ }
1105
+ const ttl=document.getElementById("topTagsList");
1106
+ const colors=["secondary","primary","tertiary","on-surface-dim"];
1107
+ if(entries.length){
1108
+ ttl.innerHTML=entries.slice(0,4).map(([tag,val],j)=>{
1109
+ const c=colors[j%colors.length];
1110
+ const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
1111
+ return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
1112
+ }).join("");
1113
+ }
1114
+
1115
+ await new Promise(r=>setTimeout(r,12));
1116
+ }
1117
+
1118
+ const color=s.reward>0.5?"text-secondary":s.reward>0.2?"text-primary":"text-on-surface-dim";
1119
+ const err=s.error?` <span class="text-error">err=${s.error}</span>`:"";
1120
+ const dn=s.done?'<span class="text-tertiary font-bold"> DONE</span>':"";
1121
+ log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[${s.step}]</span> <span class="text-on-surface">${s.action}</span> <span class="${color}">r=${s.reward.toFixed(2)}</span>${dn}${err}</div>`;
1122
+ log.scrollTop=log.scrollHeight;
1123
+ }
1124
+
1125
+ const f=d.final;
1126
+ const sc=d.score;
1127
+ redrawTimelineCharts();
1128
+
1129
+ // Final update of all panels using last step data
1130
+ const lastStep=d.steps[d.steps.length-1];
1131
+ if(lastStep){
1132
+ const tt=document.getElementById("trendTopics");
1133
+ tt.innerHTML=(lastStep.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
1134
+ const tg=document.getElementById("trendTags");
1135
+ tg.innerHTML=(lastStep.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
1136
+
1137
+ const perf=lastStep.tag_performance||{};
1138
+ const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
1139
+ const tp=document.getElementById("tagPerf");
1140
+ if(entries.length){
1141
+ const maxV=Math.max(...entries.map(e=>e[1]),0.01);
1142
+ tp.innerHTML=entries.slice(0,6).map(([tag,val],j)=>{
1143
+ const c=j%2===0?"primary":"secondary";
1144
+ const w=Math.min(100,(val/maxV)*100);
1145
+ return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
1146
+ }).join("");
1147
+ }
1148
+ const ttl=document.getElementById("topTagsList");
1149
+ const colors=["secondary","primary","tertiary","on-surface-dim"];
1150
+ if(entries.length){
1151
+ ttl.innerHTML=entries.slice(0,4).map(([tag,val],j)=>{
1152
+ const c=colors[j%colors.length];
1153
+ const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
1154
+ return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
1155
+ }).join("");
1156
+ }
1157
+
1158
+ document.getElementById("compEng").textContent=lastStep.competitor_avg_engagement.toFixed(3);
1159
+ currentObs={engagement_rate:lastStep.engagement_rate,metadata:{}};
1160
+ }
1161
+
1162
+ // Show grader card
1163
+ const gc=document.getElementById("graderCard");
1164
+ gc.classList.remove("hidden");
1165
+ document.getElementById("graderScore").textContent=sc.toFixed(4);
1166
+ const lbl=document.getElementById("graderLabel");
1167
+ if(sc>=0.7)lbl.textContent="Excellent performance!";
1168
+ else if(sc>=0.4)lbl.textContent="Decent strategy, room for improvement";
1169
+ else lbl.textContent="Poor performance — agent needs better strategy";
1170
+
1171
+ const res=document.getElementById("simResult");
1172
+ res.classList.remove("hidden");
1173
+ const scoreColor=sc>=0.7?"text-primary":sc>=0.3?"text-secondary":"text-tertiary";
1174
+ const scoreBg=sc>=0.7?"border-primary/30 bg-primary/5":sc>=0.3?"border-secondary/30 bg-secondary/5":"border-tertiary/30 bg-tertiary/5";
1175
+ res.innerHTML=`
1176
+ <div class="p-4 rounded-xl border ${scoreBg} space-y-2">
1177
+ <div class="flex justify-between items-center"><span class="text-[10px] font-label text-on-surface-dim uppercase tracking-widest">Grader Score</span><span class="text-3xl font-black ${scoreColor}">${sc.toFixed(4)}</span></div>
1178
+ <div class="grid grid-cols-2 gap-x-6 gap-y-1 text-[10px] font-label">
1179
+ <div class="flex justify-between"><span class="text-on-surface-dim">Steps</span><span>${d.total_steps}</span></div>
1180
+ <div class="flex justify-between"><span class="text-on-surface-dim">Burned Out</span><span class="${f.burned_out?"text-tertiary":"text-secondary"}">${f.burned_out?"YES":"NO"}</span></div>
1181
+ <div class="flex justify-between"><span class="text-on-surface-dim">Final Energy</span><span>${f.energy.toFixed(2)}</span></div>
1182
+ <div class="flex justify-between"><span class="text-on-surface-dim">Followers</span><span>${f.followers.toLocaleString()}</span></div>
1183
+ <div class="flex justify-between"><span class="text-on-surface-dim">Engagement</span><span>${f.engagement_rate.toFixed(4)}</span></div>
1184
+ <div class="flex justify-between"><span class="text-on-surface-dim">Total Posts</span><span>${totalPostsCount}</span></div>
1185
+ </div>
1186
+ </div>`;
1187
+ updateBottomStats();
1188
+ setStatus("Simulation Done");
1189
+ loadHistory();
1190
+ }catch(e){setStatus("Error: "+e.message)}
1191
+ document.querySelectorAll(".sim-btn").forEach(b=>b.classList.remove("opacity-30","pointer-events-none"));
1192
+ simRunning=false;
1193
+ }
1194
+
1195
+ function showPostForm(){document.getElementById("postForm").classList.remove("hidden")}
1196
+ function hidePostForm(){document.getElementById("postForm").classList.add("hidden")}
1197
+ function setStatus(s){
1198
+ const el=document.getElementById("statusDot");
1199
+ const color=s.includes("Error")?"text-error":s==="Running"?"text-secondary":s.includes("Done")?"text-primary":"text-on-surface-dim";
1200
+ el.className="flex items-center gap-2 text-xs font-label "+color;
1201
+ el.innerHTML=`<span class="w-2 h-2 rounded-full ${color.replace("text-","bg-")}"></span>${s}`;
1202
+ }
1203
+
1204
+ async function loadHistory(){
1205
+ try{
1206
+ const r=await fetch(API+"/dashboard/history");
1207
+ const data=await r.json();
1208
+ const tb=document.getElementById("historyTable");
1209
+ if(!data.length){tb.innerHTML='<tr><td colspan="10" class="px-4 py-6 text-center text-on-surface-dim italic">No history yet — run a simulation</td></tr>';return}
1210
+ const taskLabels={weekly_engage:"Easy",weekly_strategic:"Medium",weekly_competitive:"Hard"};
1211
+ tb.innerHTML=data.slice().reverse().map(h=>{
1212
+ const dt=new Date(h.id);
1213
+ const time=dt.toLocaleDateString("en-US",{month:"short",day:"numeric"})+' '+dt.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"});
1214
+ const f=h.final||{};
1215
+ const delta=f.followers-10000;
1216
+ const deltaStr=(delta>=0?"+":"")+delta.toLocaleString();
1217
+ const deltaClass=delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim";
1218
+ const scoreColor=h.score>=0.7?"text-primary":h.score>=0.3?"text-secondary":"text-tertiary";
1219
+ const status=f.burned_out?'<span class="text-tertiary font-bold">BURNED</span>':h.total_steps>=168?'<span class="text-secondary">DONE</span>':'<span class="text-on-surface-dim">PARTIAL</span>';
1220
+ const energyColor=f.energy>=0.5?"text-secondary":f.energy>0?"text-tertiary":"text-error";
1221
+ const desc=(h.description||"").trim();
1222
+ return `<tr class="border-b border-white/5 hover:bg-white/[.02] transition">
1223
+ <td class="px-4 py-2.5 text-on-surface-dim whitespace-nowrap">${time}</td>
1224
+ <td class="px-4 py-2.5 min-w-[14rem] max-w-lg align-top">
1225
+ <div class="text-on-surface font-bold">${_escapeHtml(h.scenario)}</div>
1226
+ ${desc?`<div class="text-[10px] text-on-surface/75 mt-1 leading-relaxed whitespace-normal">${_escapeHtml(desc)}</div>`:""}
1227
+ </td>
1228
+ <td class="px-4 py-2.5 text-on-surface-dim">${taskLabels[h.task]||h.task}</td>
1229
+ <td class="px-4 py-2.5 text-right ${scoreColor} font-bold">${h.score.toFixed(4)}</td>
1230
+ <td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_steps}</td>
1231
+ <td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_posts}</td>
1232
+ <td class="px-4 py-2.5 text-right text-on-surface">${(f.followers||0).toLocaleString()}</td>
1233
+ <td class="px-4 py-2.5 text-right ${deltaClass}">${deltaStr}</td>
1234
+ <td class="px-4 py-2.5 text-right ${energyColor}">${(f.energy||0).toFixed(2)}</td>
1235
+ <td class="px-4 py-2.5 text-center">${status}</td>
1236
+ </tr>`;
1237
+ }).join("");
1238
+ }catch(e){console.error("History load failed",e)}
1239
+ }
1240
+
1241
+ async function clearHistory(){
1242
+ if(!confirm("Clear all simulation history?"))return;
1243
+ await fetch(API+"/dashboard/history",{method:"DELETE"});
1244
+ loadHistory();
1245
+ }
1246
+
1247
+ function _escapeHtml(t){
1248
+ const d=document.createElement("div");
1249
+ d.textContent=t??"";
1250
+ return d.innerHTML;
1251
+ }
1252
+
1253
+ let _scenarioItems=[];
1254
+
1255
+ async function loadScenarioButtons(){
1256
+ const grid=document.getElementById("scenarioGrid");
1257
+ const countEl=document.getElementById("scenarioCount");
1258
+ const filterEl=document.getElementById("scenarioFilter");
1259
+ if(!grid)return;
1260
+ try{
1261
+ const r=await fetch(API+"/dashboard/scenarios",{cache:"no-store",headers:{"Cache-Control":"no-cache"}});
1262
+ const data=await r.json();
1263
+ _scenarioItems=data.scenarios||[];
1264
+ if(countEl)countEl.textContent=_scenarioItems.length+" strategies";
1265
+ const pin=new Set(["easy_morning_story","easy_one_a_day","easy_relaxed","medium_queue_cycle","medium_trend_rotate","medium_two_format","smart","balanced","high_freq","optimal_sleep","sleep_conscious","sleep_debt_aware"]);
1266
+ _scenarioItems.sort((a,b)=>{
1267
+ const pa=pin.has(a.id)?0:1,pb=pin.has(b.id)?0:1;
1268
+ if(pa!==pb)return pa-pb;
1269
+ return (a.label||"").localeCompare(b.label||"","en",{sensitivity:"base"});
1270
+ });
1271
+ function render(){
1272
+ const q=(filterEl&&filterEl.value||"").trim().toLowerCase();
1273
+ grid.innerHTML="";
1274
+ let n=0;
1275
+ for(const s of _scenarioItems){
1276
+ const lab=(s.label||"").toLowerCase();
1277
+ const id=(s.id||"").toLowerCase();
1278
+ const desc=(s.description||"").toLowerCase();
1279
+ if(q&&!(lab.includes(q)||id.includes(q)||desc.includes(q)))continue;
1280
+ n++;
1281
+ const btn=document.createElement("button");
1282
+ btn.type="button";
1283
+ btn.className="sim-btn p-2.5 rounded-lg bg-surface border border-outline/20 hover:border-secondary/40 text-left transition";
1284
+ if(pin.has(s.id))btn.classList.add("border-primary/25","hover:border-primary/55");
1285
+ btn.onclick=()=>runSim(s.id);
1286
+ btn.innerHTML=`<div class="text-xs font-bold text-on-surface leading-tight">${_escapeHtml(s.label)}</div><div class="text-[8px] text-on-surface-dim mt-0.5 line-clamp-2">${_escapeHtml(s.description)}</div>`;
1287
+ grid.appendChild(btn);
1288
+ }
1289
+ if(!n)grid.innerHTML='<div class="col-span-full text-on-surface-dim text-[10px] italic py-4 text-center">No strategies match your search.</div>';
1290
+ }
1291
+ if(filterEl)filterEl.oninput=render;
1292
+ render();
1293
+ }catch(e){
1294
+ console.error(e);
1295
+ grid.innerHTML='<div class="col-span-full text-error text-[10px] py-3">Could not load strategies. Refresh the page.</div>';
1296
+ if(countEl)countEl.textContent="";
1297
+ }
1298
+ }
1299
+
1300
+ loadScenarioButtons();
1301
+ loadHistory();
1302
+ doReset();
1303
+ refreshTaskScoreBlurb();
1304
+ </script>
1305
+ </body>
1306
+ </html>
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
server/simulation_history.json ADDED
@@ -0,0 +1,1802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "2026-04-05T10:50:54.850500+00:00",
4
+ "scenario": "Always Rest",
5
+ "scenario_id": "always_rest",
6
+ "task": "weekly_competitive",
7
+ "score": 0.035,
8
+ "total_steps": 168,
9
+ "total_posts": 0,
10
+ "avg_reward": 0.15,
11
+ "final": {
12
+ "energy": 1.0,
13
+ "hours_since_sleep": 1,
14
+ "sleep_debt": 0.0,
15
+ "followers": 5497,
16
+ "engagement_rate": 0.0,
17
+ "burned_out": false
18
+ }
19
+ },
20
+ {
21
+ "id": "2026-04-05T10:50:54.859097+00:00",
22
+ "scenario": "Anti-Trend",
23
+ "scenario_id": "anti_trend",
24
+ "task": "weekly_competitive",
25
+ "score": 0.2316,
26
+ "total_steps": 168,
27
+ "total_posts": 14,
28
+ "avg_reward": 0.2201,
29
+ "final": {
30
+ "energy": 1.0,
31
+ "hours_since_sleep": 1,
32
+ "sleep_debt": 0.0,
33
+ "followers": 11125,
34
+ "engagement_rate": 0.747,
35
+ "burned_out": false
36
+ }
37
+ },
38
+ {
39
+ "id": "2026-04-05T10:50:54.868624+00:00",
40
+ "scenario": "Bad Timing",
41
+ "scenario_id": "bad_timing",
42
+ "task": "weekly_competitive",
43
+ "score": 0.0937,
44
+ "total_steps": 168,
45
+ "total_posts": 49,
46
+ "avg_reward": 0.1611,
47
+ "final": {
48
+ "energy": 0.59,
49
+ "hours_since_sleep": 5,
50
+ "sleep_debt": 0.0,
51
+ "followers": 10237,
52
+ "engagement_rate": 0.0358,
53
+ "burned_out": false
54
+ }
55
+ },
56
+ {
57
+ "id": "2026-04-05T10:50:54.878099+00:00",
58
+ "scenario": "Balanced Creator",
59
+ "scenario_id": "balanced",
60
+ "task": "weekly_competitive",
61
+ "score": 0.8775,
62
+ "total_steps": 168,
63
+ "total_posts": 28,
64
+ "avg_reward": 0.2187,
65
+ "final": {
66
+ "energy": 1.0,
67
+ "hours_since_sleep": 2,
68
+ "sleep_debt": 0.0,
69
+ "followers": 12534,
70
+ "engagement_rate": 0.8273,
71
+ "burned_out": false
72
+ }
73
+ },
74
+ {
75
+ "id": "2026-04-05T10:50:54.891038+00:00",
76
+ "scenario": "Burst Poster",
77
+ "scenario_id": "burst",
78
+ "task": "weekly_competitive",
79
+ "score": 0.6111,
80
+ "total_steps": 168,
81
+ "total_posts": 57,
82
+ "avg_reward": 0.2318,
83
+ "final": {
84
+ "energy": 0.44,
85
+ "hours_since_sleep": 1,
86
+ "sleep_debt": 0.0,
87
+ "followers": 11701,
88
+ "engagement_rate": 0.2076,
89
+ "burned_out": false
90
+ }
91
+ },
92
+ {
93
+ "id": "2026-04-05T10:50:54.901147+00:00",
94
+ "scenario": "Carousel Only",
95
+ "scenario_id": "carousel_only",
96
+ "task": "weekly_competitive",
97
+ "score": 0.417,
98
+ "total_steps": 168,
99
+ "total_posts": 14,
100
+ "avg_reward": 0.2353,
101
+ "final": {
102
+ "energy": 1.0,
103
+ "hours_since_sleep": 1,
104
+ "sleep_debt": 0.0,
105
+ "followers": 12074,
106
+ "engagement_rate": 1.3175,
107
+ "burned_out": false
108
+ }
109
+ },
110
+ {
111
+ "id": "2026-04-05T10:50:54.911264+00:00",
112
+ "scenario": "Competitor Avoider",
113
+ "scenario_id": "comp_avoider",
114
+ "task": "weekly_competitive",
115
+ "score": 0.446,
116
+ "total_steps": 168,
117
+ "total_posts": 14,
118
+ "avg_reward": 0.2365,
119
+ "final": {
120
+ "energy": 1.0,
121
+ "hours_since_sleep": 1,
122
+ "sleep_debt": 0.0,
123
+ "followers": 12678,
124
+ "engagement_rate": 1.8163,
125
+ "burned_out": false
126
+ }
127
+ },
128
+ {
129
+ "id": "2026-04-05T10:50:54.921231+00:00",
130
+ "scenario": "Conservative Energy",
131
+ "scenario_id": "conservative",
132
+ "task": "weekly_competitive",
133
+ "score": 0.2181,
134
+ "total_steps": 168,
135
+ "total_posts": 7,
136
+ "avg_reward": 0.1967,
137
+ "final": {
138
+ "energy": 1.0,
139
+ "hours_since_sleep": 1,
140
+ "sleep_debt": 0.0,
141
+ "followers": 10239,
142
+ "engagement_rate": 0.3439,
143
+ "burned_out": false
144
+ }
145
+ },
146
+ {
147
+ "id": "2026-04-05T10:50:54.931980+00:00",
148
+ "scenario": "Content Creator",
149
+ "scenario_id": "content_creator",
150
+ "task": "weekly_competitive",
151
+ "score": 0.6434,
152
+ "total_steps": 168,
153
+ "total_posts": 12,
154
+ "avg_reward": 0.2065,
155
+ "final": {
156
+ "energy": 0.309,
157
+ "hours_since_sleep": 28,
158
+ "sleep_debt": 0.017,
159
+ "followers": 10931,
160
+ "engagement_rate": 0.525,
161
+ "burned_out": false
162
+ }
163
+ },
164
+ {
165
+ "id": "2026-04-05T10:50:54.942037+00:00",
166
+ "scenario": "Copycat",
167
+ "scenario_id": "copycat",
168
+ "task": "weekly_competitive",
169
+ "score": 0.6136,
170
+ "total_steps": 168,
171
+ "total_posts": 21,
172
+ "avg_reward": 0.1887,
173
+ "final": {
174
+ "energy": 1.0,
175
+ "hours_since_sleep": 1,
176
+ "sleep_debt": 0.0,
177
+ "followers": 11589,
178
+ "engagement_rate": 0.497,
179
+ "burned_out": false
180
+ }
181
+ },
182
+ {
183
+ "id": "2026-04-05T10:50:54.951850+00:00",
184
+ "scenario": "Creator Economy",
185
+ "scenario_id": "creator_economy",
186
+ "task": "weekly_competitive",
187
+ "score": 0.2515,
188
+ "total_steps": 168,
189
+ "total_posts": 14,
190
+ "avg_reward": 0.2226,
191
+ "final": {
192
+ "energy": 1.0,
193
+ "hours_since_sleep": 1,
194
+ "sleep_debt": 0.0,
195
+ "followers": 11994,
196
+ "engagement_rate": 1.3918,
197
+ "burned_out": false
198
+ }
199
+ },
200
+ {
201
+ "id": "2026-04-05T10:50:54.961166+00:00",
202
+ "scenario": "Crypto/Web3",
203
+ "scenario_id": "crypto_niche",
204
+ "task": "weekly_competitive",
205
+ "score": 0.2879,
206
+ "total_steps": 168,
207
+ "total_posts": 14,
208
+ "avg_reward": 0.2324,
209
+ "final": {
210
+ "energy": 1.0,
211
+ "hours_since_sleep": 1,
212
+ "sleep_debt": 0.0,
213
+ "followers": 12444,
214
+ "engagement_rate": 1.6187,
215
+ "burned_out": false
216
+ }
217
+ },
218
+ {
219
+ "id": "2026-04-05T10:50:54.970461+00:00",
220
+ "scenario": "Double Peak",
221
+ "scenario_id": "double_peak",
222
+ "task": "weekly_competitive",
223
+ "score": 0.4519,
224
+ "total_steps": 168,
225
+ "total_posts": 14,
226
+ "avg_reward": 0.2352,
227
+ "final": {
228
+ "energy": 1.0,
229
+ "hours_since_sleep": 1,
230
+ "sleep_debt": 0.0,
231
+ "followers": 13138,
232
+ "engagement_rate": 2.0814,
233
+ "burned_out": false
234
+ }
235
+ },
236
+ {
237
+ "id": "2026-04-05T10:50:54.980718+00:00",
238
+ "scenario": "Early Bird",
239
+ "scenario_id": "early_bird",
240
+ "task": "weekly_competitive",
241
+ "score": 0.2075,
242
+ "total_steps": 168,
243
+ "total_posts": 16,
244
+ "avg_reward": 0.2284,
245
+ "final": {
246
+ "energy": 0.62,
247
+ "hours_since_sleep": 2,
248
+ "sleep_debt": 0.0,
249
+ "followers": 10818,
250
+ "engagement_rate": 0.4138,
251
+ "burned_out": false
252
+ }
253
+ },
254
+ {
255
+ "id": "2026-04-05T10:50:54.989979+00:00",
256
+ "scenario": "Energy Saver",
257
+ "scenario_id": "energy_saver",
258
+ "task": "weekly_competitive",
259
+ "score": 0.3744,
260
+ "total_steps": 168,
261
+ "total_posts": 7,
262
+ "avg_reward": 0.2111,
263
+ "final": {
264
+ "energy": 1.0,
265
+ "hours_since_sleep": 1,
266
+ "sleep_debt": 0.0,
267
+ "followers": 11080,
268
+ "engagement_rate": 1.5483,
269
+ "burned_out": false
270
+ }
271
+ },
272
+ {
273
+ "id": "2026-04-05T10:50:55.000118+00:00",
274
+ "scenario": "Engagement Chaser",
275
+ "scenario_id": "engagement_chaser",
276
+ "task": "weekly_competitive",
277
+ "score": 0.4194,
278
+ "total_steps": 168,
279
+ "total_posts": 21,
280
+ "avg_reward": 0.2224,
281
+ "final": {
282
+ "energy": 1.0,
283
+ "hours_since_sleep": 1,
284
+ "sleep_debt": 0.0,
285
+ "followers": 15287,
286
+ "engagement_rate": 2.2466,
287
+ "burned_out": false
288
+ }
289
+ },
290
+ {
291
+ "id": "2026-04-05T10:50:55.009873+00:00",
292
+ "scenario": "Events/News",
293
+ "scenario_id": "events",
294
+ "task": "weekly_competitive",
295
+ "score": 0.158,
296
+ "total_steps": 168,
297
+ "total_posts": 4,
298
+ "avg_reward": 0.1732,
299
+ "final": {
300
+ "energy": 1.0,
301
+ "hours_since_sleep": 1,
302
+ "sleep_debt": 0.0,
303
+ "followers": 7491,
304
+ "engagement_rate": 1.4388,
305
+ "burned_out": false
306
+ }
307
+ },
308
+ {
309
+ "id": "2026-04-05T10:50:55.018674+00:00",
310
+ "scenario": "Fashion Content",
311
+ "scenario_id": "fashion",
312
+ "task": "weekly_competitive",
313
+ "score": 0.2181,
314
+ "total_steps": 168,
315
+ "total_posts": 14,
316
+ "avg_reward": 0.2147,
317
+ "final": {
318
+ "energy": 1.0,
319
+ "hours_since_sleep": 1,
320
+ "sleep_debt": 0.0,
321
+ "followers": 11135,
322
+ "engagement_rate": 0.7898,
323
+ "burned_out": false
324
+ }
325
+ },
326
+ {
327
+ "id": "2026-04-05T10:50:55.027894+00:00",
328
+ "scenario": "Food Creator",
329
+ "scenario_id": "food_creator",
330
+ "task": "weekly_competitive",
331
+ "score": 0.2612,
332
+ "total_steps": 168,
333
+ "total_posts": 15,
334
+ "avg_reward": 0.2293,
335
+ "final": {
336
+ "energy": 0.7,
337
+ "hours_since_sleep": 2,
338
+ "sleep_debt": 0.0,
339
+ "followers": 12091,
340
+ "engagement_rate": 1.1978,
341
+ "burned_out": false
342
+ }
343
+ },
344
+ {
345
+ "id": "2026-04-05T10:50:55.037230+00:00",
346
+ "scenario": "Gaming Niche",
347
+ "scenario_id": "gaming_niche",
348
+ "task": "weekly_competitive",
349
+ "score": 0.2188,
350
+ "total_steps": 168,
351
+ "total_posts": 14,
352
+ "avg_reward": 0.2062,
353
+ "final": {
354
+ "energy": 1.0,
355
+ "hours_since_sleep": 1,
356
+ "sleep_debt": 0.0,
357
+ "followers": 11364,
358
+ "engagement_rate": 0.9138,
359
+ "burned_out": false
360
+ }
361
+ },
362
+ {
363
+ "id": "2026-04-05T10:50:55.047589+00:00",
364
+ "scenario": "Growth Focus",
365
+ "scenario_id": "growth_focus",
366
+ "task": "weekly_competitive",
367
+ "score": 0.2764,
368
+ "total_steps": 168,
369
+ "total_posts": 14,
370
+ "avg_reward": 0.2205,
371
+ "final": {
372
+ "energy": 1.0,
373
+ "hours_since_sleep": 1,
374
+ "sleep_debt": 0.0,
375
+ "followers": 12621,
376
+ "engagement_rate": 1.7101,
377
+ "burned_out": false
378
+ }
379
+ },
380
+ {
381
+ "id": "2026-04-05T10:50:55.059854+00:00",
382
+ "scenario": "High Frequency",
383
+ "scenario_id": "high_freq",
384
+ "task": "weekly_competitive",
385
+ "score": 0.8611,
386
+ "total_steps": 168,
387
+ "total_posts": 22,
388
+ "avg_reward": 0.2058,
389
+ "final": {
390
+ "energy": 0.92,
391
+ "hours_since_sleep": 2,
392
+ "sleep_debt": 0.0,
393
+ "followers": 12654,
394
+ "engagement_rate": 1.079,
395
+ "burned_out": false
396
+ }
397
+ },
398
+ {
399
+ "id": "2026-04-05T10:50:55.072522+00:00",
400
+ "scenario": "Lifestyle Niche",
401
+ "scenario_id": "lifestyle_niche",
402
+ "task": "weekly_competitive",
403
+ "score": 0.2612,
404
+ "total_steps": 168,
405
+ "total_posts": 14,
406
+ "avg_reward": 0.2288,
407
+ "final": {
408
+ "energy": 1.0,
409
+ "hours_since_sleep": 1,
410
+ "sleep_debt": 0.0,
411
+ "followers": 12251,
412
+ "engagement_rate": 1.6295,
413
+ "burned_out": false
414
+ }
415
+ },
416
+ {
417
+ "id": "2026-04-05T10:50:55.081957+00:00",
418
+ "scenario": "Low Frequency",
419
+ "scenario_id": "low_freq",
420
+ "task": "weekly_competitive",
421
+ "score": 0.3241,
422
+ "total_steps": 168,
423
+ "total_posts": 4,
424
+ "avg_reward": 0.1768,
425
+ "final": {
426
+ "energy": 1.0,
427
+ "hours_since_sleep": 1,
428
+ "sleep_debt": 0.0,
429
+ "followers": 10461,
430
+ "engagement_rate": 1.1563,
431
+ "burned_out": false
432
+ }
433
+ },
434
+ {
435
+ "id": "2026-04-05T10:50:55.089553+00:00",
436
+ "scenario": "Marathon Runner",
437
+ "scenario_id": "marathon",
438
+ "task": "weekly_competitive",
439
+ "score": 0.0,
440
+ "total_steps": 50,
441
+ "total_posts": 9,
442
+ "avg_reward": 0.1323,
443
+ "final": {
444
+ "energy": 0.0,
445
+ "hours_since_sleep": 22,
446
+ "sleep_debt": 0.028,
447
+ "followers": 10137,
448
+ "engagement_rate": 0.157,
449
+ "burned_out": true
450
+ }
451
+ },
452
+ {
453
+ "id": "2026-04-05T10:50:55.095782+00:00",
454
+ "scenario": "Midday Focus",
455
+ "scenario_id": "midday",
456
+ "task": "weekly_competitive",
457
+ "score": 0.4317,
458
+ "total_steps": 168,
459
+ "total_posts": 14,
460
+ "avg_reward": 0.2306,
461
+ "final": {
462
+ "energy": 1.0,
463
+ "hours_since_sleep": 1,
464
+ "sleep_debt": 0.0,
465
+ "followers": 13537,
466
+ "engagement_rate": 2.3076,
467
+ "burned_out": false
468
+ }
469
+ },
470
+ {
471
+ "id": "2026-04-05T10:50:55.106103+00:00",
472
+ "scenario": "Minimal Poster",
473
+ "scenario_id": "minimal",
474
+ "task": "weekly_competitive",
475
+ "score": 0.3658,
476
+ "total_steps": 168,
477
+ "total_posts": 7,
478
+ "avg_reward": 0.2039,
479
+ "final": {
480
+ "energy": 1.0,
481
+ "hours_since_sleep": 1,
482
+ "sleep_debt": 0.0,
483
+ "followers": 10907,
484
+ "engagement_rate": 1.3002,
485
+ "burned_out": false
486
+ }
487
+ },
488
+ {
489
+ "id": "2026-04-05T10:50:55.116369+00:00",
490
+ "scenario": "ML/AI Deep Dive",
491
+ "scenario_id": "ml_deep",
492
+ "task": "weekly_competitive",
493
+ "score": 0.2266,
494
+ "total_steps": 168,
495
+ "total_posts": 14,
496
+ "avg_reward": 0.2197,
497
+ "final": {
498
+ "energy": 1.0,
499
+ "hours_since_sleep": 1,
500
+ "sleep_debt": 0.0,
501
+ "followers": 11180,
502
+ "engagement_rate": 0.7014,
503
+ "burned_out": false
504
+ }
505
+ },
506
+ {
507
+ "id": "2026-04-05T10:50:55.125451+00:00",
508
+ "scenario": "Monday Motivation",
509
+ "scenario_id": "monday",
510
+ "task": "weekly_competitive",
511
+ "score": 0.2606,
512
+ "total_steps": 168,
513
+ "total_posts": 4,
514
+ "avg_reward": 0.159,
515
+ "final": {
516
+ "energy": 0.75,
517
+ "hours_since_sleep": 2,
518
+ "sleep_debt": 0.0,
519
+ "followers": 5827,
520
+ "engagement_rate": 0.911,
521
+ "burned_out": false
522
+ }
523
+ },
524
+ {
525
+ "id": "2026-04-05T10:50:55.134737+00:00",
526
+ "scenario": "Napper",
527
+ "scenario_id": "napper",
528
+ "task": "weekly_competitive",
529
+ "score": 0.3623,
530
+ "total_steps": 168,
531
+ "total_posts": 14,
532
+ "avg_reward": 0.2264,
533
+ "final": {
534
+ "energy": 1.0,
535
+ "hours_since_sleep": 1,
536
+ "sleep_debt": 0.0,
537
+ "followers": 11322,
538
+ "engagement_rate": 0.8914,
539
+ "burned_out": false
540
+ }
541
+ },
542
+ {
543
+ "id": "2026-04-05T10:50:55.144641+00:00",
544
+ "scenario": "Night Owl",
545
+ "scenario_id": "night_owl",
546
+ "task": "weekly_competitive",
547
+ "score": 0.266,
548
+ "total_steps": 168,
549
+ "total_posts": 14,
550
+ "avg_reward": 0.194,
551
+ "final": {
552
+ "energy": 1.0,
553
+ "hours_since_sleep": 1,
554
+ "sleep_debt": 0.0,
555
+ "followers": 11927,
556
+ "engagement_rate": 1.328,
557
+ "burned_out": false
558
+ }
559
+ },
560
+ {
561
+ "id": "2026-04-05T10:50:55.153554+00:00",
562
+ "scenario": "Night Shift",
563
+ "scenario_id": "night_shift",
564
+ "task": "weekly_competitive",
565
+ "score": 0.2105,
566
+ "total_steps": 168,
567
+ "total_posts": 16,
568
+ "avg_reward": 0.2453,
569
+ "final": {
570
+ "energy": 1.0,
571
+ "hours_since_sleep": 1,
572
+ "sleep_debt": 0.0,
573
+ "followers": 11069,
574
+ "engagement_rate": 0.5602,
575
+ "burned_out": false
576
+ }
577
+ },
578
+ {
579
+ "id": "2026-04-05T10:50:55.159353+00:00",
580
+ "scenario": "No Rest",
581
+ "scenario_id": "no_rest",
582
+ "task": "weekly_competitive",
583
+ "score": 0.0,
584
+ "total_steps": 8,
585
+ "total_posts": 8,
586
+ "avg_reward": 0.2686,
587
+ "final": {
588
+ "energy": 0.0,
589
+ "hours_since_sleep": 10,
590
+ "sleep_debt": 0.0,
591
+ "followers": 10213,
592
+ "engagement_rate": 0.2732,
593
+ "burned_out": true
594
+ }
595
+ },
596
+ {
597
+ "id": "2026-04-05T10:50:55.164846+00:00",
598
+ "scenario": "Optimal Sleep",
599
+ "scenario_id": "optimal_sleep",
600
+ "task": "weekly_competitive",
601
+ "score": 0.3635,
602
+ "total_steps": 168,
603
+ "total_posts": 14,
604
+ "avg_reward": 0.2257,
605
+ "final": {
606
+ "energy": 0.9,
607
+ "hours_since_sleep": 3,
608
+ "sleep_debt": 0.0,
609
+ "followers": 11305,
610
+ "engagement_rate": 0.8729,
611
+ "burned_out": false
612
+ }
613
+ },
614
+ {
615
+ "id": "2026-04-05T10:50:55.174882+00:00",
616
+ "scenario": "Photography Focus",
617
+ "scenario_id": "photography",
618
+ "task": "weekly_competitive",
619
+ "score": 0.1838,
620
+ "total_steps": 168,
621
+ "total_posts": 16,
622
+ "avg_reward": 0.22,
623
+ "final": {
624
+ "energy": 0.5,
625
+ "hours_since_sleep": 3,
626
+ "sleep_debt": 0.0,
627
+ "followers": 10736,
628
+ "engagement_rate": 0.4388,
629
+ "burned_out": false
630
+ }
631
+ },
632
+ {
633
+ "id": "2026-04-05T10:50:55.184216+00:00",
634
+ "scenario": "Productivity Guru",
635
+ "scenario_id": "productivity",
636
+ "task": "weekly_competitive",
637
+ "score": 0.184,
638
+ "total_steps": 168,
639
+ "total_posts": 16,
640
+ "avg_reward": 0.227,
641
+ "final": {
642
+ "energy": 0.62,
643
+ "hours_since_sleep": 2,
644
+ "sleep_debt": 0.0,
645
+ "followers": 10741,
646
+ "engagement_rate": 0.3797,
647
+ "burned_out": false
648
+ }
649
+ },
650
+ {
651
+ "id": "2026-04-05T10:50:55.192896+00:00",
652
+ "scenario": "Queue Heavy",
653
+ "scenario_id": "queue_heavy",
654
+ "task": "weekly_competitive",
655
+ "score": 0.1933,
656
+ "total_steps": 168,
657
+ "total_posts": 8,
658
+ "avg_reward": 0.1923,
659
+ "final": {
660
+ "energy": 1.0,
661
+ "hours_since_sleep": 1,
662
+ "sleep_debt": 0.0,
663
+ "followers": 9453,
664
+ "engagement_rate": 0.781,
665
+ "burned_out": false
666
+ }
667
+ },
668
+ {
669
+ "id": "2026-04-05T10:50:55.202107+00:00",
670
+ "scenario": "Queue Optimizer",
671
+ "scenario_id": "queue_optimizer",
672
+ "task": "weekly_competitive",
673
+ "score": 0.352,
674
+ "total_steps": 168,
675
+ "total_posts": 14,
676
+ "avg_reward": 0.2233,
677
+ "final": {
678
+ "energy": 1.0,
679
+ "hours_since_sleep": 1,
680
+ "sleep_debt": 0.0,
681
+ "followers": 11215,
682
+ "engagement_rate": 0.8701,
683
+ "burned_out": false
684
+ }
685
+ },
686
+ {
687
+ "id": "2026-04-05T10:50:55.209453+00:00",
688
+ "scenario": "Random Actor",
689
+ "scenario_id": "random",
690
+ "task": "weekly_competitive",
691
+ "score": 0.0,
692
+ "total_steps": 22,
693
+ "total_posts": 11,
694
+ "avg_reward": 0.2318,
695
+ "final": {
696
+ "energy": 0.0,
697
+ "hours_since_sleep": 17,
698
+ "sleep_debt": 0.033,
699
+ "followers": 10159,
700
+ "engagement_rate": 0.087,
701
+ "burned_out": true
702
+ }
703
+ },
704
+ {
705
+ "id": "2026-04-05T10:50:55.215343+00:00",
706
+ "scenario": "Reel Maximizer",
707
+ "scenario_id": "reel_max",
708
+ "task": "weekly_competitive",
709
+ "score": 0.4344,
710
+ "total_steps": 168,
711
+ "total_posts": 14,
712
+ "avg_reward": 0.2295,
713
+ "final": {
714
+ "energy": 1.0,
715
+ "hours_since_sleep": 1,
716
+ "sleep_debt": 0.0,
717
+ "followers": 13314,
718
+ "engagement_rate": 2.1201,
719
+ "burned_out": false
720
+ }
721
+ },
722
+ {
723
+ "id": "2026-04-05T10:50:55.225542+00:00",
724
+ "scenario": "SaaS/Business",
725
+ "scenario_id": "saas",
726
+ "task": "weekly_competitive",
727
+ "score": 0.2015,
728
+ "total_steps": 168,
729
+ "total_posts": 14,
730
+ "avg_reward": 0.2182,
731
+ "final": {
732
+ "energy": 1.0,
733
+ "hours_since_sleep": 1,
734
+ "sleep_debt": 0.0,
735
+ "followers": 10958,
736
+ "engagement_rate": 0.6072,
737
+ "burned_out": false
738
+ }
739
+ },
740
+ {
741
+ "id": "2026-04-05T10:50:55.234793+00:00",
742
+ "scenario": "Sleep Conscious",
743
+ "scenario_id": "sleep_conscious",
744
+ "task": "weekly_competitive",
745
+ "score": 0.3635,
746
+ "total_steps": 168,
747
+ "total_posts": 14,
748
+ "avg_reward": 0.2257,
749
+ "final": {
750
+ "energy": 0.9,
751
+ "hours_since_sleep": 3,
752
+ "sleep_debt": 0.0,
753
+ "followers": 11305,
754
+ "engagement_rate": 0.8729,
755
+ "burned_out": false
756
+ }
757
+ },
758
+ {
759
+ "id": "2026-04-05T10:50:55.245249+00:00",
760
+ "scenario": "Sleep Debt Aware",
761
+ "scenario_id": "sleep_debt_aware",
762
+ "task": "weekly_competitive",
763
+ "score": 0.3745,
764
+ "total_steps": 168,
765
+ "total_posts": 14,
766
+ "avg_reward": 0.2293,
767
+ "final": {
768
+ "energy": 1.0,
769
+ "hours_since_sleep": 1,
770
+ "sleep_debt": 0.0,
771
+ "followers": 11412,
772
+ "engagement_rate": 0.9425,
773
+ "burned_out": false
774
+ }
775
+ },
776
+ {
777
+ "id": "2026-04-05T10:50:55.252673+00:00",
778
+ "scenario": "Sleep Deprived",
779
+ "scenario_id": "sleep_deprived",
780
+ "task": "weekly_competitive",
781
+ "score": 0.0,
782
+ "total_steps": 16,
783
+ "total_posts": 2,
784
+ "avg_reward": 0.2248,
785
+ "final": {
786
+ "energy": 0.0,
787
+ "hours_since_sleep": 18,
788
+ "sleep_debt": 0.045,
789
+ "followers": 10215,
790
+ "engagement_rate": 1.0806,
791
+ "burned_out": true
792
+ }
793
+ },
794
+ {
795
+ "id": "2026-04-05T10:50:55.258355+00:00",
796
+ "scenario": "Sleep Respecting",
797
+ "scenario_id": "sleep_respecting",
798
+ "task": "weekly_competitive",
799
+ "score": 0.3623,
800
+ "total_steps": 168,
801
+ "total_posts": 14,
802
+ "avg_reward": 0.2264,
803
+ "final": {
804
+ "energy": 1.0,
805
+ "hours_since_sleep": 1,
806
+ "sleep_debt": 0.0,
807
+ "followers": 11322,
808
+ "engagement_rate": 0.8914,
809
+ "burned_out": false
810
+ }
811
+ },
812
+ {
813
+ "id": "2026-04-05T10:50:55.268389+00:00",
814
+ "scenario": "Smart Agent",
815
+ "scenario_id": "smart",
816
+ "task": "weekly_competitive",
817
+ "score": 0.8745,
818
+ "total_steps": 168,
819
+ "total_posts": 14,
820
+ "avg_reward": 0.2301,
821
+ "final": {
822
+ "energy": 1.0,
823
+ "hours_since_sleep": 1,
824
+ "sleep_debt": 0.0,
825
+ "followers": 12200,
826
+ "engagement_rate": 1.5557,
827
+ "burned_out": false
828
+ }
829
+ },
830
+ {
831
+ "id": "2026-04-05T10:50:55.276258+00:00",
832
+ "scenario": "Spam Post",
833
+ "scenario_id": "spam",
834
+ "task": "weekly_competitive",
835
+ "score": 0.0,
836
+ "total_steps": 4,
837
+ "total_posts": 4,
838
+ "avg_reward": 0.387,
839
+ "final": {
840
+ "energy": 0.0,
841
+ "hours_since_sleep": 6,
842
+ "sleep_debt": 0.0,
843
+ "followers": 10625,
844
+ "engagement_rate": 1.567,
845
+ "burned_out": true
846
+ }
847
+ },
848
+ {
849
+ "id": "2026-04-05T10:50:55.281752+00:00",
850
+ "scenario": "Split Schedule",
851
+ "scenario_id": "split_schedule",
852
+ "task": "weekly_competitive",
853
+ "score": 0.385,
854
+ "total_steps": 168,
855
+ "total_posts": 15,
856
+ "avg_reward": 0.2347,
857
+ "final": {
858
+ "energy": 0.75,
859
+ "hours_since_sleep": 2,
860
+ "sleep_debt": 0.0,
861
+ "followers": 11689,
862
+ "engagement_rate": 0.9724,
863
+ "burned_out": false
864
+ }
865
+ },
866
+ {
867
+ "id": "2026-04-05T10:50:55.291899+00:00",
868
+ "scenario": "Stoic Philosophy",
869
+ "scenario_id": "stoic",
870
+ "task": "weekly_competitive",
871
+ "score": 0.1071,
872
+ "total_steps": 168,
873
+ "total_posts": 7,
874
+ "avg_reward": 0.2069,
875
+ "final": {
876
+ "energy": 1.0,
877
+ "hours_since_sleep": 1,
878
+ "sleep_debt": 0.0,
879
+ "followers": 10108,
880
+ "engagement_rate": 0.1578,
881
+ "burned_out": false
882
+ }
883
+ },
884
+ {
885
+ "id": "2026-04-05T10:50:55.301186+00:00",
886
+ "scenario": "Story Spammer",
887
+ "scenario_id": "story_spammer",
888
+ "task": "weekly_competitive",
889
+ "score": 0.1632,
890
+ "total_steps": 168,
891
+ "total_posts": 29,
892
+ "avg_reward": 0.1592,
893
+ "final": {
894
+ "energy": 0.87,
895
+ "hours_since_sleep": 2,
896
+ "sleep_debt": 0.0,
897
+ "followers": 10504,
898
+ "engagement_rate": 0.1285,
899
+ "burned_out": false
900
+ }
901
+ },
902
+ {
903
+ "id": "2026-04-05T10:50:55.310194+00:00",
904
+ "scenario": "Tag Exploiter",
905
+ "scenario_id": "tag_exploiter",
906
+ "task": "weekly_competitive",
907
+ "score": 0.2922,
908
+ "total_steps": 168,
909
+ "total_posts": 14,
910
+ "avg_reward": 0.2358,
911
+ "final": {
912
+ "energy": 1.0,
913
+ "hours_since_sleep": 1,
914
+ "sleep_debt": 0.0,
915
+ "followers": 13696,
916
+ "engagement_rate": 2.2487,
917
+ "burned_out": false
918
+ }
919
+ },
920
+ {
921
+ "id": "2026-04-05T10:50:55.320255+00:00",
922
+ "scenario": "Tag Explorer",
923
+ "scenario_id": "tag_explorer",
924
+ "task": "weekly_competitive",
925
+ "score": 0.8323,
926
+ "total_steps": 168,
927
+ "total_posts": 15,
928
+ "avg_reward": 0.2253,
929
+ "final": {
930
+ "energy": 0.94,
931
+ "hours_since_sleep": 2,
932
+ "sleep_debt": 0.0,
933
+ "followers": 11351,
934
+ "engagement_rate": 0.7735,
935
+ "burned_out": false
936
+ }
937
+ },
938
+ {
939
+ "id": "2026-04-05T10:50:55.333620+00:00",
940
+ "scenario": "Tech Niche",
941
+ "scenario_id": "tech_niche",
942
+ "task": "weekly_competitive",
943
+ "score": 0.2001,
944
+ "total_steps": 168,
945
+ "total_posts": 14,
946
+ "avg_reward": 0.215,
947
+ "final": {
948
+ "energy": 1.0,
949
+ "hours_since_sleep": 1,
950
+ "sleep_debt": 0.0,
951
+ "followers": 10770,
952
+ "engagement_rate": 0.533,
953
+ "burned_out": false
954
+ }
955
+ },
956
+ {
957
+ "id": "2026-04-05T10:50:55.343185+00:00",
958
+ "scenario": "Text Only",
959
+ "scenario_id": "text_only",
960
+ "task": "weekly_competitive",
961
+ "score": 0.1583,
962
+ "total_steps": 168,
963
+ "total_posts": 21,
964
+ "avg_reward": 0.1857,
965
+ "final": {
966
+ "energy": 1.0,
967
+ "hours_since_sleep": 1,
968
+ "sleep_debt": 0.0,
969
+ "followers": 10485,
970
+ "engagement_rate": 0.234,
971
+ "burned_out": false
972
+ }
973
+ },
974
+ {
975
+ "id": "2026-04-05T10:50:55.352680+00:00",
976
+ "scenario": "Travel Blogger",
977
+ "scenario_id": "travel",
978
+ "task": "weekly_competitive",
979
+ "score": 0.2975,
980
+ "total_steps": 168,
981
+ "total_posts": 14,
982
+ "avg_reward": 0.2307,
983
+ "final": {
984
+ "energy": 1.0,
985
+ "hours_since_sleep": 1,
986
+ "sleep_debt": 0.0,
987
+ "followers": 12749,
988
+ "engagement_rate": 1.9614,
989
+ "burned_out": false
990
+ }
991
+ },
992
+ {
993
+ "id": "2026-04-05T10:50:55.362329+00:00",
994
+ "scenario": "Trend Chaser",
995
+ "scenario_id": "trend_chaser",
996
+ "task": "weekly_competitive",
997
+ "score": 0.4344,
998
+ "total_steps": 168,
999
+ "total_posts": 14,
1000
+ "avg_reward": 0.2413,
1001
+ "final": {
1002
+ "energy": 1.0,
1003
+ "hours_since_sleep": 1,
1004
+ "sleep_debt": 0.0,
1005
+ "followers": 14148,
1006
+ "engagement_rate": 2.6985,
1007
+ "burned_out": false
1008
+ }
1009
+ },
1010
+ {
1011
+ "id": "2026-04-05T10:50:55.373024+00:00",
1012
+ "scenario": "Tuesday Thursday",
1013
+ "scenario_id": "tue_thu",
1014
+ "task": "weekly_competitive",
1015
+ "score": 0.1826,
1016
+ "total_steps": 168,
1017
+ "total_posts": 4,
1018
+ "avg_reward": 0.1731,
1019
+ "final": {
1020
+ "energy": 1.0,
1021
+ "hours_since_sleep": 1,
1022
+ "sleep_debt": 0.0,
1023
+ "followers": 9154,
1024
+ "engagement_rate": 3.4748,
1025
+ "burned_out": false
1026
+ }
1027
+ },
1028
+ {
1029
+ "id": "2026-04-05T10:50:55.382708+00:00",
1030
+ "scenario": "Weekday Only",
1031
+ "scenario_id": "weekday_only",
1032
+ "task": "weekly_competitive",
1033
+ "score": 0.2366,
1034
+ "total_steps": 168,
1035
+ "total_posts": 10,
1036
+ "avg_reward": 0.2046,
1037
+ "final": {
1038
+ "energy": 1.0,
1039
+ "hours_since_sleep": 1,
1040
+ "sleep_debt": 0.0,
1041
+ "followers": 9810,
1042
+ "engagement_rate": 1.0028,
1043
+ "burned_out": false
1044
+ }
1045
+ },
1046
+ {
1047
+ "id": "2026-04-05T10:50:55.392284+00:00",
1048
+ "scenario": "Weekend Warrior",
1049
+ "scenario_id": "weekend",
1050
+ "task": "weekly_competitive",
1051
+ "score": 0.1257,
1052
+ "total_steps": 168,
1053
+ "total_posts": 6,
1054
+ "avg_reward": 0.1648,
1055
+ "final": {
1056
+ "energy": 1.0,
1057
+ "hours_since_sleep": 1,
1058
+ "sleep_debt": 0.0,
1059
+ "followers": 7659,
1060
+ "engagement_rate": 0.635,
1061
+ "burned_out": false
1062
+ }
1063
+ },
1064
+ {
1065
+ "id": "2026-04-05T10:51:44.770556+00:00",
1066
+ "scenario": "Aggressive Energy",
1067
+ "scenario_id": "aggressive",
1068
+ "task": "weekly_competitive",
1069
+ "score": 0.8255,
1070
+ "total_steps": 168,
1071
+ "total_posts": 29,
1072
+ "avg_reward": 0.1875,
1073
+ "final": {
1074
+ "energy": 0.75,
1075
+ "hours_since_sleep": 2,
1076
+ "sleep_debt": 0.0,
1077
+ "followers": 13021,
1078
+ "engagement_rate": 0.8084,
1079
+ "burned_out": false
1080
+ }
1081
+ },
1082
+ {
1083
+ "id": "2026-04-06T14:25:47.636598+00:00",
1084
+ "scenario": "Sleep Respecting",
1085
+ "scenario_id": "sleep_respecting",
1086
+ "task": "weekly_competitive",
1087
+ "score": 0.3623,
1088
+ "total_steps": 168,
1089
+ "total_posts": 14,
1090
+ "avg_reward": 0.2264,
1091
+ "final": {
1092
+ "energy": 1.0,
1093
+ "hours_since_sleep": 1,
1094
+ "sleep_debt": 0.0,
1095
+ "followers": 11322,
1096
+ "engagement_rate": 0.8914,
1097
+ "burned_out": false
1098
+ }
1099
+ },
1100
+ {
1101
+ "id": "2026-04-06T14:26:41.631567+00:00",
1102
+ "scenario": "Creator Economy",
1103
+ "scenario_id": "creator_economy",
1104
+ "task": "weekly_competitive",
1105
+ "score": 0.2515,
1106
+ "total_steps": 168,
1107
+ "total_posts": 14,
1108
+ "avg_reward": 0.2226,
1109
+ "final": {
1110
+ "energy": 1.0,
1111
+ "hours_since_sleep": 1,
1112
+ "sleep_debt": 0.0,
1113
+ "followers": 11994,
1114
+ "engagement_rate": 1.3918,
1115
+ "burned_out": false
1116
+ }
1117
+ },
1118
+ {
1119
+ "id": "2026-04-06T14:27:32.195059+00:00",
1120
+ "scenario": "Weekday Only",
1121
+ "scenario_id": "weekday_only",
1122
+ "task": "weekly_competitive",
1123
+ "score": 0.2366,
1124
+ "total_steps": 168,
1125
+ "total_posts": 10,
1126
+ "avg_reward": 0.2046,
1127
+ "final": {
1128
+ "energy": 1.0,
1129
+ "hours_since_sleep": 1,
1130
+ "sleep_debt": 0.0,
1131
+ "followers": 9810,
1132
+ "engagement_rate": 1.0028,
1133
+ "burned_out": false
1134
+ }
1135
+ },
1136
+ {
1137
+ "id": "2026-04-06T14:28:12.547146+00:00",
1138
+ "scenario": "Weekday Only",
1139
+ "scenario_id": "weekday_only",
1140
+ "task": "weekly_competitive",
1141
+ "score": 0.2366,
1142
+ "total_steps": 168,
1143
+ "total_posts": 10,
1144
+ "avg_reward": 0.2046,
1145
+ "final": {
1146
+ "energy": 1.0,
1147
+ "hours_since_sleep": 1,
1148
+ "sleep_debt": 0.0,
1149
+ "followers": 9810,
1150
+ "engagement_rate": 1.0028,
1151
+ "burned_out": false
1152
+ }
1153
+ },
1154
+ {
1155
+ "id": "2026-04-06T14:29:19.356814+00:00",
1156
+ "scenario": "No Rest",
1157
+ "scenario_id": "no_rest",
1158
+ "task": "weekly_engage",
1159
+ "score": 0.027,
1160
+ "total_steps": 8,
1161
+ "total_posts": 8,
1162
+ "avg_reward": 0.2686,
1163
+ "final": {
1164
+ "energy": 0.0,
1165
+ "hours_since_sleep": 10,
1166
+ "sleep_debt": 0.0,
1167
+ "followers": 10213,
1168
+ "engagement_rate": 0.2732,
1169
+ "burned_out": true
1170
+ }
1171
+ },
1172
+ {
1173
+ "id": "2026-04-06T14:29:21.996045+00:00",
1174
+ "scenario": "No Rest",
1175
+ "scenario_id": "no_rest",
1176
+ "task": "weekly_engage",
1177
+ "score": 0.027,
1178
+ "total_steps": 8,
1179
+ "total_posts": 8,
1180
+ "avg_reward": 0.2686,
1181
+ "final": {
1182
+ "energy": 0.0,
1183
+ "hours_since_sleep": 10,
1184
+ "sleep_debt": 0.0,
1185
+ "followers": 10213,
1186
+ "engagement_rate": 0.2732,
1187
+ "burned_out": true
1188
+ }
1189
+ },
1190
+ {
1191
+ "id": "2026-04-06T14:29:33.742894+00:00",
1192
+ "scenario": "Text Only",
1193
+ "scenario_id": "text_only",
1194
+ "task": "weekly_engage",
1195
+ "score": 0.2049,
1196
+ "total_steps": 168,
1197
+ "total_posts": 21,
1198
+ "avg_reward": 0.1857,
1199
+ "final": {
1200
+ "energy": 1.0,
1201
+ "hours_since_sleep": 1,
1202
+ "sleep_debt": 0.0,
1203
+ "followers": 10485,
1204
+ "engagement_rate": 0.234,
1205
+ "burned_out": false
1206
+ }
1207
+ },
1208
+ {
1209
+ "id": "2026-04-06T14:29:39.176314+00:00",
1210
+ "scenario": "Gaming Niche",
1211
+ "scenario_id": "gaming_niche",
1212
+ "task": "weekly_engage",
1213
+ "score": 0.5658,
1214
+ "total_steps": 168,
1215
+ "total_posts": 14,
1216
+ "avg_reward": 0.2062,
1217
+ "final": {
1218
+ "energy": 1.0,
1219
+ "hours_since_sleep": 1,
1220
+ "sleep_debt": 0.0,
1221
+ "followers": 11364,
1222
+ "engagement_rate": 0.9138,
1223
+ "burned_out": false
1224
+ }
1225
+ },
1226
+ {
1227
+ "id": "2026-04-06T14:29:50.321368+00:00",
1228
+ "scenario": "Midday Focus",
1229
+ "scenario_id": "midday",
1230
+ "task": "weekly_engage",
1231
+ "score": 1.0,
1232
+ "total_steps": 168,
1233
+ "total_posts": 14,
1234
+ "avg_reward": 0.2306,
1235
+ "final": {
1236
+ "energy": 1.0,
1237
+ "hours_since_sleep": 1,
1238
+ "sleep_debt": 0.0,
1239
+ "followers": 13537,
1240
+ "engagement_rate": 2.3076,
1241
+ "burned_out": false
1242
+ }
1243
+ },
1244
+ {
1245
+ "id": "2026-04-06T17:52:48.224991+00:00",
1246
+ "scenario": "Double Peak",
1247
+ "scenario_id": "double_peak",
1248
+ "task": "weekly_competitive",
1249
+ "score": 0.4519,
1250
+ "total_steps": 168,
1251
+ "total_posts": 14,
1252
+ "avg_reward": 0.2352,
1253
+ "final": {
1254
+ "energy": 1.0,
1255
+ "hours_since_sleep": 1,
1256
+ "sleep_debt": 0.0,
1257
+ "followers": 13138,
1258
+ "engagement_rate": 2.0814,
1259
+ "burned_out": false
1260
+ }
1261
+ },
1262
+ {
1263
+ "id": "2026-04-06T17:53:45.401024+00:00",
1264
+ "scenario": "Photography Focus",
1265
+ "scenario_id": "photography",
1266
+ "task": "weekly_competitive",
1267
+ "score": 0.1838,
1268
+ "total_steps": 168,
1269
+ "total_posts": 16,
1270
+ "avg_reward": 0.22,
1271
+ "final": {
1272
+ "energy": 0.5,
1273
+ "hours_since_sleep": 3,
1274
+ "sleep_debt": 0.0,
1275
+ "followers": 10736,
1276
+ "engagement_rate": 0.4388,
1277
+ "burned_out": false
1278
+ }
1279
+ },
1280
+ {
1281
+ "id": "2026-04-06T17:54:16.540951+00:00",
1282
+ "scenario": "Burst Poster",
1283
+ "scenario_id": "burst",
1284
+ "task": "weekly_competitive",
1285
+ "score": 0.6111,
1286
+ "total_steps": 168,
1287
+ "total_posts": 57,
1288
+ "avg_reward": 0.2318,
1289
+ "final": {
1290
+ "energy": 0.44,
1291
+ "hours_since_sleep": 1,
1292
+ "sleep_debt": 0.0,
1293
+ "followers": 11701,
1294
+ "engagement_rate": 0.2076,
1295
+ "burned_out": false
1296
+ }
1297
+ },
1298
+ {
1299
+ "id": "2026-04-06T17:54:39.699482+00:00",
1300
+ "scenario": "Engagement Chaser",
1301
+ "scenario_id": "engagement_chaser",
1302
+ "task": "weekly_competitive",
1303
+ "score": 0.4194,
1304
+ "total_steps": 168,
1305
+ "total_posts": 21,
1306
+ "avg_reward": 0.2224,
1307
+ "final": {
1308
+ "energy": 1.0,
1309
+ "hours_since_sleep": 1,
1310
+ "sleep_debt": 0.0,
1311
+ "followers": 15287,
1312
+ "engagement_rate": 2.2466,
1313
+ "burned_out": false
1314
+ }
1315
+ },
1316
+ {
1317
+ "id": "2026-04-06T18:09:31.470202+00:00",
1318
+ "scenario": "Lifestyle Niche",
1319
+ "scenario_id": "lifestyle_niche",
1320
+ "task": "weekly_competitive",
1321
+ "score": 0.2612,
1322
+ "total_steps": 168,
1323
+ "total_posts": 14,
1324
+ "avg_reward": 0.2288,
1325
+ "final": {
1326
+ "energy": 1.0,
1327
+ "hours_since_sleep": 1,
1328
+ "sleep_debt": 0.0,
1329
+ "followers": 12251,
1330
+ "engagement_rate": 1.6295,
1331
+ "burned_out": false
1332
+ }
1333
+ },
1334
+ {
1335
+ "id": "2026-04-06T18:09:42.791462+00:00",
1336
+ "scenario": "Content Creator",
1337
+ "scenario_id": "content_creator",
1338
+ "task": "weekly_competitive",
1339
+ "score": 0.6434,
1340
+ "total_steps": 168,
1341
+ "total_posts": 12,
1342
+ "avg_reward": 0.2065,
1343
+ "final": {
1344
+ "energy": 0.309,
1345
+ "hours_since_sleep": 28,
1346
+ "sleep_debt": 0.017,
1347
+ "followers": 10931,
1348
+ "engagement_rate": 0.525,
1349
+ "burned_out": false
1350
+ }
1351
+ },
1352
+ {
1353
+ "id": "2026-04-06T18:25:35.360345+00:00",
1354
+ "scenario": "Anti-Trend",
1355
+ "scenario_id": "anti_trend",
1356
+ "task": "weekly_competitive",
1357
+ "score": 0.2316,
1358
+ "total_steps": 168,
1359
+ "total_posts": 14,
1360
+ "avg_reward": 0.2201,
1361
+ "final": {
1362
+ "energy": 1.0,
1363
+ "hours_since_sleep": 1,
1364
+ "sleep_debt": 0.0,
1365
+ "followers": 11125,
1366
+ "engagement_rate": 0.747,
1367
+ "burned_out": false
1368
+ }
1369
+ },
1370
+ {
1371
+ "id": "2026-04-06T18:28:21.455943+00:00",
1372
+ "scenario": "Fashion Content",
1373
+ "scenario_id": "fashion",
1374
+ "task": "weekly_competitive",
1375
+ "score": 0.2181,
1376
+ "total_steps": 168,
1377
+ "total_posts": 14,
1378
+ "avg_reward": 0.2147,
1379
+ "final": {
1380
+ "energy": 1.0,
1381
+ "hours_since_sleep": 1,
1382
+ "sleep_debt": 0.0,
1383
+ "followers": 11135,
1384
+ "engagement_rate": 0.7898,
1385
+ "burned_out": false
1386
+ }
1387
+ },
1388
+ {
1389
+ "id": "2026-04-06T18:28:26.860641+00:00",
1390
+ "scenario": "Low Frequency",
1391
+ "scenario_id": "low_freq",
1392
+ "task": "weekly_competitive",
1393
+ "score": 0.3241,
1394
+ "total_steps": 168,
1395
+ "total_posts": 4,
1396
+ "avg_reward": 0.1768,
1397
+ "final": {
1398
+ "energy": 1.0,
1399
+ "hours_since_sleep": 1,
1400
+ "sleep_debt": 0.0,
1401
+ "followers": 10461,
1402
+ "engagement_rate": 1.1563,
1403
+ "burned_out": false
1404
+ }
1405
+ },
1406
+ {
1407
+ "id": "2026-04-06T18:28:36.279972+00:00",
1408
+ "scenario": "Balanced Creator",
1409
+ "scenario_id": "balanced",
1410
+ "task": "weekly_competitive",
1411
+ "score": 0.8775,
1412
+ "total_steps": 168,
1413
+ "total_posts": 28,
1414
+ "avg_reward": 0.2187,
1415
+ "final": {
1416
+ "energy": 1.0,
1417
+ "hours_since_sleep": 2,
1418
+ "sleep_debt": 0.0,
1419
+ "followers": 12534,
1420
+ "engagement_rate": 0.8273,
1421
+ "burned_out": false
1422
+ }
1423
+ },
1424
+ {
1425
+ "id": "2026-04-06T18:29:19.542258+00:00",
1426
+ "scenario": "Napper",
1427
+ "scenario_id": "napper",
1428
+ "task": "weekly_competitive",
1429
+ "score": 0.3623,
1430
+ "total_steps": 168,
1431
+ "total_posts": 14,
1432
+ "avg_reward": 0.2264,
1433
+ "final": {
1434
+ "energy": 1.0,
1435
+ "hours_since_sleep": 1,
1436
+ "sleep_debt": 0.0,
1437
+ "followers": 11322,
1438
+ "engagement_rate": 0.8914,
1439
+ "burned_out": false
1440
+ }
1441
+ },
1442
+ {
1443
+ "id": "2026-04-06T19:48:37.931282+00:00",
1444
+ "scenario": "Optimal Sleep",
1445
+ "scenario_id": "optimal_sleep",
1446
+ "task": "weekly_competitive",
1447
+ "score": 0.3635,
1448
+ "total_steps": 168,
1449
+ "total_posts": 14,
1450
+ "avg_reward": 0.2257,
1451
+ "final": {
1452
+ "energy": 0.9,
1453
+ "hours_since_sleep": 3,
1454
+ "sleep_debt": 0.0,
1455
+ "followers": 11305,
1456
+ "engagement_rate": 0.8729,
1457
+ "burned_out": false
1458
+ }
1459
+ },
1460
+ {
1461
+ "id": "2026-04-06T19:49:01.327141+00:00",
1462
+ "scenario": "Marathon Runner",
1463
+ "scenario_id": "marathon",
1464
+ "task": "weekly_competitive",
1465
+ "score": 0.0,
1466
+ "total_steps": 50,
1467
+ "total_posts": 9,
1468
+ "avg_reward": 0.1323,
1469
+ "final": {
1470
+ "energy": 0.0,
1471
+ "hours_since_sleep": 22,
1472
+ "sleep_debt": 0.028,
1473
+ "followers": 10137,
1474
+ "engagement_rate": 0.157,
1475
+ "burned_out": true
1476
+ }
1477
+ },
1478
+ {
1479
+ "id": "2026-04-06T19:49:13.972097+00:00",
1480
+ "scenario": "Balanced Creator",
1481
+ "scenario_id": "balanced",
1482
+ "task": "weekly_competitive",
1483
+ "score": 0.8775,
1484
+ "total_steps": 168,
1485
+ "total_posts": 28,
1486
+ "avg_reward": 0.2187,
1487
+ "final": {
1488
+ "energy": 1.0,
1489
+ "hours_since_sleep": 2,
1490
+ "sleep_debt": 0.0,
1491
+ "followers": 12534,
1492
+ "engagement_rate": 0.8273,
1493
+ "burned_out": false
1494
+ }
1495
+ },
1496
+ {
1497
+ "id": "2026-04-06T19:49:37.864235+00:00",
1498
+ "scenario": "Engagement Chaser",
1499
+ "scenario_id": "engagement_chaser",
1500
+ "task": "weekly_competitive",
1501
+ "score": 0.4194,
1502
+ "total_steps": 168,
1503
+ "total_posts": 21,
1504
+ "avg_reward": 0.2224,
1505
+ "final": {
1506
+ "energy": 1.0,
1507
+ "hours_since_sleep": 1,
1508
+ "sleep_debt": 0.0,
1509
+ "followers": 15287,
1510
+ "engagement_rate": 2.2466,
1511
+ "burned_out": false
1512
+ }
1513
+ },
1514
+ {
1515
+ "id": "2026-04-06T19:50:08.348742+00:00",
1516
+ "scenario": "Early Bird",
1517
+ "scenario_id": "early_bird",
1518
+ "task": "weekly_competitive",
1519
+ "score": 0.2075,
1520
+ "total_steps": 168,
1521
+ "total_posts": 16,
1522
+ "avg_reward": 0.2284,
1523
+ "final": {
1524
+ "energy": 0.62,
1525
+ "hours_since_sleep": 2,
1526
+ "sleep_debt": 0.0,
1527
+ "followers": 10818,
1528
+ "engagement_rate": 0.4138,
1529
+ "burned_out": false
1530
+ }
1531
+ },
1532
+ {
1533
+ "id": "2026-04-06T19:50:15.765261+00:00",
1534
+ "scenario": "Queue Heavy",
1535
+ "scenario_id": "queue_heavy",
1536
+ "task": "weekly_competitive",
1537
+ "score": 0.1933,
1538
+ "total_steps": 168,
1539
+ "total_posts": 8,
1540
+ "avg_reward": 0.1923,
1541
+ "final": {
1542
+ "energy": 1.0,
1543
+ "hours_since_sleep": 1,
1544
+ "sleep_debt": 0.0,
1545
+ "followers": 9453,
1546
+ "engagement_rate": 0.781,
1547
+ "burned_out": false
1548
+ }
1549
+ },
1550
+ {
1551
+ "id": "2026-04-06T19:50:26.015235+00:00",
1552
+ "scenario": "Balanced Creator",
1553
+ "scenario_id": "balanced",
1554
+ "task": "weekly_competitive",
1555
+ "score": 0.8775,
1556
+ "total_steps": 168,
1557
+ "total_posts": 28,
1558
+ "avg_reward": 0.2187,
1559
+ "final": {
1560
+ "energy": 1.0,
1561
+ "hours_since_sleep": 2,
1562
+ "sleep_debt": 0.0,
1563
+ "followers": 12534,
1564
+ "engagement_rate": 0.8273,
1565
+ "burned_out": false
1566
+ }
1567
+ },
1568
+ {
1569
+ "id": "2026-04-06T19:50:30.364460+00:00",
1570
+ "scenario": "High Frequency",
1571
+ "scenario_id": "high_freq",
1572
+ "task": "weekly_competitive",
1573
+ "score": 0.8611,
1574
+ "total_steps": 168,
1575
+ "total_posts": 22,
1576
+ "avg_reward": 0.2058,
1577
+ "final": {
1578
+ "energy": 0.92,
1579
+ "hours_since_sleep": 2,
1580
+ "sleep_debt": 0.0,
1581
+ "followers": 12654,
1582
+ "engagement_rate": 1.079,
1583
+ "burned_out": false
1584
+ }
1585
+ },
1586
+ {
1587
+ "id": "2026-04-06T19:50:38.185556+00:00",
1588
+ "scenario": "Sleep Conscious",
1589
+ "scenario_id": "sleep_conscious",
1590
+ "task": "weekly_competitive",
1591
+ "score": 0.3635,
1592
+ "total_steps": 168,
1593
+ "total_posts": 14,
1594
+ "avg_reward": 0.2257,
1595
+ "final": {
1596
+ "energy": 0.9,
1597
+ "hours_since_sleep": 3,
1598
+ "sleep_debt": 0.0,
1599
+ "followers": 11305,
1600
+ "engagement_rate": 0.8729,
1601
+ "burned_out": false
1602
+ }
1603
+ },
1604
+ {
1605
+ "id": "2026-04-06T19:50:44.256241+00:00",
1606
+ "scenario": "Burst Poster",
1607
+ "scenario_id": "burst",
1608
+ "task": "weekly_competitive",
1609
+ "score": 0.6111,
1610
+ "total_steps": 168,
1611
+ "total_posts": 57,
1612
+ "avg_reward": 0.2318,
1613
+ "final": {
1614
+ "energy": 0.44,
1615
+ "hours_since_sleep": 1,
1616
+ "sleep_debt": 0.0,
1617
+ "followers": 11701,
1618
+ "engagement_rate": 0.2076,
1619
+ "burned_out": false
1620
+ }
1621
+ },
1622
+ {
1623
+ "id": "2026-04-06T19:51:00.755964+00:00",
1624
+ "scenario": "Queue Optimizer",
1625
+ "scenario_id": "queue_optimizer",
1626
+ "task": "weekly_competitive",
1627
+ "score": 0.352,
1628
+ "total_steps": 168,
1629
+ "total_posts": 14,
1630
+ "avg_reward": 0.2233,
1631
+ "final": {
1632
+ "energy": 1.0,
1633
+ "hours_since_sleep": 1,
1634
+ "sleep_debt": 0.0,
1635
+ "followers": 11215,
1636
+ "engagement_rate": 0.8701,
1637
+ "burned_out": false
1638
+ }
1639
+ },
1640
+ {
1641
+ "id": "2026-04-07T19:19:06.982475+00:00",
1642
+ "scenario": "Easy: Afternoon story",
1643
+ "scenario_id": "easy_relaxed",
1644
+ "task": "weekly_engage",
1645
+ "score": 0.0776,
1646
+ "total_steps": 168,
1647
+ "total_posts": 7,
1648
+ "avg_reward": 0.1885,
1649
+ "final": {
1650
+ "energy": 1.0,
1651
+ "hours_since_sleep": 1,
1652
+ "sleep_debt": 0.0,
1653
+ "followers": 10185,
1654
+ "engagement_rate": 0.2689,
1655
+ "burned_out": false
1656
+ }
1657
+ },
1658
+ {
1659
+ "id": "2026-04-07T19:25:22.760913+00:00",
1660
+ "scenario": "Medium: Reel + carousel day",
1661
+ "scenario_id": "medium_two_format",
1662
+ "task": "weekly_engage",
1663
+ "score": 1.0,
1664
+ "total_steps": 168,
1665
+ "total_posts": 14,
1666
+ "avg_reward": 0.2305,
1667
+ "final": {
1668
+ "energy": 1.0,
1669
+ "hours_since_sleep": 1,
1670
+ "sleep_debt": 0.0,
1671
+ "followers": 13498,
1672
+ "engagement_rate": 2.3223,
1673
+ "burned_out": false
1674
+ }
1675
+ },
1676
+ {
1677
+ "id": "2026-04-07T19:37:07.163654+00:00",
1678
+ "scenario": "Easy: Morning story",
1679
+ "scenario_id": "easy_morning_story",
1680
+ "task": "weekly_engage",
1681
+ "score": 0.1126,
1682
+ "total_steps": 168,
1683
+ "total_posts": 7,
1684
+ "avg_reward": 0.2064,
1685
+ "final": {
1686
+ "energy": 1.0,
1687
+ "hours_since_sleep": 1,
1688
+ "sleep_debt": 0.0,
1689
+ "followers": 10269,
1690
+ "engagement_rate": 0.3903,
1691
+ "burned_out": false
1692
+ }
1693
+ },
1694
+ {
1695
+ "id": "2026-04-07T19:37:08.936466+00:00",
1696
+ "scenario": "Easy: One text at 1pm",
1697
+ "scenario_id": "easy_one_a_day",
1698
+ "task": "weekly_engage",
1699
+ "score": 0.0992,
1700
+ "total_steps": 168,
1701
+ "total_posts": 7,
1702
+ "avg_reward": 0.1933,
1703
+ "final": {
1704
+ "energy": 1.0,
1705
+ "hours_since_sleep": 1,
1706
+ "sleep_debt": 0.0,
1707
+ "followers": 10239,
1708
+ "engagement_rate": 0.3439,
1709
+ "burned_out": false
1710
+ }
1711
+ },
1712
+ {
1713
+ "id": "2026-04-07T19:37:10.555676+00:00",
1714
+ "scenario": "Easy: Afternoon story",
1715
+ "scenario_id": "easy_relaxed",
1716
+ "task": "weekly_engage",
1717
+ "score": 0.0776,
1718
+ "total_steps": 168,
1719
+ "total_posts": 7,
1720
+ "avg_reward": 0.1885,
1721
+ "final": {
1722
+ "energy": 1.0,
1723
+ "hours_since_sleep": 1,
1724
+ "sleep_debt": 0.0,
1725
+ "followers": 10185,
1726
+ "engagement_rate": 0.2689,
1727
+ "burned_out": false
1728
+ }
1729
+ },
1730
+ {
1731
+ "id": "2026-04-07T19:37:12.240540+00:00",
1732
+ "scenario": "Medium: Create then post",
1733
+ "scenario_id": "medium_queue_cycle",
1734
+ "task": "weekly_engage",
1735
+ "score": 0.8459,
1736
+ "total_steps": 168,
1737
+ "total_posts": 14,
1738
+ "avg_reward": 0.2318,
1739
+ "final": {
1740
+ "energy": 1.0,
1741
+ "hours_since_sleep": 1,
1742
+ "sleep_debt": 0.0,
1743
+ "followers": 12045,
1744
+ "engagement_rate": 1.3511,
1745
+ "burned_out": false
1746
+ }
1747
+ },
1748
+ {
1749
+ "id": "2026-04-07T19:37:14.032300+00:00",
1750
+ "scenario": "Medium: Trend + format rotation",
1751
+ "scenario_id": "medium_trend_rotate",
1752
+ "task": "weekly_engage",
1753
+ "score": 0.5524,
1754
+ "total_steps": 168,
1755
+ "total_posts": 14,
1756
+ "avg_reward": 0.2265,
1757
+ "final": {
1758
+ "energy": 1.0,
1759
+ "hours_since_sleep": 1,
1760
+ "sleep_debt": 0.0,
1761
+ "followers": 11332,
1762
+ "engagement_rate": 0.9003,
1763
+ "burned_out": false
1764
+ }
1765
+ },
1766
+ {
1767
+ "id": "2026-04-07T19:37:15.697454+00:00",
1768
+ "scenario": "Medium: Reel + carousel day",
1769
+ "scenario_id": "medium_two_format",
1770
+ "task": "weekly_engage",
1771
+ "score": 1.0,
1772
+ "total_steps": 168,
1773
+ "total_posts": 14,
1774
+ "avg_reward": 0.2305,
1775
+ "final": {
1776
+ "energy": 1.0,
1777
+ "hours_since_sleep": 1,
1778
+ "sleep_debt": 0.0,
1779
+ "followers": 13498,
1780
+ "engagement_rate": 2.3223,
1781
+ "burned_out": false
1782
+ }
1783
+ },
1784
+ {
1785
+ "id": "2026-04-07T19:38:24.165792+00:00",
1786
+ "scenario": "Easy: One text at 1pm",
1787
+ "scenario_id": "easy_one_a_day",
1788
+ "task": "weekly_engage",
1789
+ "score": 0.0992,
1790
+ "total_steps": 168,
1791
+ "total_posts": 7,
1792
+ "avg_reward": 0.1933,
1793
+ "final": {
1794
+ "energy": 1.0,
1795
+ "hours_since_sleep": 1,
1796
+ "sleep_debt": 0.0,
1797
+ "followers": 10239,
1798
+ "engagement_rate": 0.3439,
1799
+ "burned_out": false
1800
+ }
1801
+ }
1802
+ ]
server/viraltest_environment.py ADDED
@@ -0,0 +1,844 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Viraltest Environment — RL-Based Creator Optimization Simulation.
3
+
4
+ Simulates a social media creator's weekly posting lifecycle.
5
+ The agent decides when to post, what format, which tags, and how
6
+ to differentiate from competitors, while managing burnout.
7
+ """
8
+
9
+ import random
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+ from uuid import uuid4
14
+
15
+ from openenv.core.env_server.interfaces import Environment
16
+ from openenv.core.env_server.types import State
17
+
18
+ try:
19
+ from ..models import ScheduledAction, ViraltestAction, ViraltestObservation
20
+ except ImportError:
21
+ from models import ScheduledAction, ViraltestAction, ViraltestObservation
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Constants (research-backed)
25
+ # ---------------------------------------------------------------------------
26
+
27
+ TASK_HORIZON = 7 # 7 daily steps (each step simulates 24 hours internally)
28
+
29
+ CONTENT_ENERGY_COST = {
30
+ "reel": 0.25,
31
+ "carousel": 0.20,
32
+ "story": 0.08,
33
+ "text_post": 0.06,
34
+ }
35
+
36
+ BASE_ENGAGEMENT = {
37
+ "reel": 0.52,
38
+ "carousel": 0.55,
39
+ "story": 0.30,
40
+ "text_post": 0.37,
41
+ }
42
+
43
+ REACH_MULT = {
44
+ "reel": 2.25,
45
+ "carousel": 1.0,
46
+ "story": 0.5,
47
+ "text_post": 0.44,
48
+ }
49
+
50
+ TAG_POOL = [
51
+ # Tech
52
+ "ai", "ml", "coding", "startup", "saas", "devtools",
53
+ # Lifestyle
54
+ "fitness", "travel", "food", "wellness", "fashion", "photography",
55
+ # Trending (base set — rotated daily)
56
+ "summer", "worldcup", "election", "newyear", "oscars", "climate",
57
+ # Niche
58
+ "productivity", "minimalism", "stoic", "web3", "gaming", "crypto",
59
+ # Broad
60
+ "motivation", "tips", "howto", "viral", "trending", "growth",
61
+ ]
62
+
63
+ TOPIC_CATEGORIES = {
64
+ "tech": ["AI tools", "coding tips", "startup life", "tech news", "SaaS growth", "dev workflow"],
65
+ "lifestyle": ["fitness routine", "travel guide", "food recipe", "wellness tips", "fashion haul", "photo editing"],
66
+ "business": ["growth hacks", "marketing strategy", "creator economy", "monetization", "brand deals", "analytics"],
67
+ }
68
+
69
+ VALID_TASKS = ("weekly_engage", "weekly_strategic", "weekly_competitive")
70
+
71
+ # Hour multipliers (Buffer 9.6M post study)
72
+ PEAK_HOURS = {
73
+ "weekday_morning": (9, 12, 1.3),
74
+ "weekday_peak": (12, 15, 1.4),
75
+ "evening": (18, 20, 1.25),
76
+ "late_evening": (20, 23, 1.1),
77
+ "night": (23, 6, 0.5),
78
+ "off_hours": (6, 9, 0.8),
79
+ }
80
+
81
+ WEEKEND_PENALTY = 0.7
82
+ PEAK_DAYS = (1, 2, 3) # Tue, Wed, Thu (0=Mon)
83
+
84
+
85
+ @dataclass
86
+ class CompetitorState:
87
+ name: str
88
+ niche_topics: List[str]
89
+ preferred_types: List[str]
90
+ posting_frequency: float
91
+ base_engagement: float
92
+ tag_preferences: List[str]
93
+ recent_posts: List[Dict[str, Any]] = field(default_factory=list)
94
+
95
+
96
+ COMPETITOR_PROFILES = [
97
+ {
98
+ "name": "creator_alpha",
99
+ "niche_topics": ["AI tools", "coding tips", "tech news"],
100
+ "preferred_types": ["reel", "carousel"],
101
+ "posting_frequency": 2.5,
102
+ "base_engagement": 0.45,
103
+ "tag_preferences": ["ai", "coding", "tech news"],
104
+ },
105
+ {
106
+ "name": "creator_beta",
107
+ "niche_topics": ["growth hacks", "marketing strategy", "creator economy"],
108
+ "preferred_types": ["carousel", "text_post"],
109
+ "posting_frequency": 1.8,
110
+ "base_engagement": 0.40,
111
+ "tag_preferences": ["growth", "tips", "viral"],
112
+ },
113
+ {
114
+ "name": "creator_gamma",
115
+ "niche_topics": ["fitness routine", "wellness tips", "motivation"],
116
+ "preferred_types": ["reel", "story"],
117
+ "posting_frequency": 3.0,
118
+ "base_engagement": 0.38,
119
+ "tag_preferences": ["fitness", "wellness", "motivation"],
120
+ },
121
+ ]
122
+
123
+ INITIAL_FOLLOWERS = 10000
124
+ REST_RECOVERY = 0.12
125
+ CREATE_CONTENT_COST = 0.05
126
+ REPETITION_ENERGY_PENALTY = 0.05
127
+ AUDIENCE_FATIGUE_THRESHOLD_1 = 3
128
+ AUDIENCE_FATIGUE_THRESHOLD_2 = 5
129
+ FOLLOWER_DECAY_HOURS = 48
130
+ ALGORITHM_PENALTY_MULT = 0.6
131
+ ALGORITHM_PENALTY_DURATION = 2
132
+
133
+ # Sleep mechanics (research-backed: Frontiers Neuroscience 2025, Frontiers Human Neuroscience 2014)
134
+ # - Cognitive performance follows a continuous decay curve, not step functions
135
+ # - Full night deprivation (~24hrs) impairs performance by ~50%
136
+ # - Uses exponential decay: quality = 1.0 * (0.5 ^ ((hours - optimal) / halflife))
137
+ SLEEP_OPTIMAL_AWAKE = 14 # Hours awake with no performance impact
138
+ SLEEP_HALFLIFE_HOURS = 10 # Hours beyond optimal for quality to halve
139
+ SLEEP_MIN_QUALITY = 0.30 # Floor for sleep-based quality (can't go below 30%)
140
+ SLEEP_ENERGY_DRAIN_START = 16 # Hours awake before extra energy drain kicks in
141
+ SLEEP_ENERGY_DRAIN_RATE = 0.015 # Energy drain per hour when sleep deprived
142
+ SLEEP_RECOVERY_PER_REST = 2 # Hours of "sleep credit" per rest action (rest = nap)
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Environment
147
+ # ---------------------------------------------------------------------------
148
+
149
+ class ViraltestEnvironment(Environment):
150
+ """
151
+ Weekly creator optimization simulation.
152
+
153
+ The agent manages a social media creator's posting strategy over 7 days
154
+ (168 hourly steps), balancing engagement, energy, tags, and competition.
155
+ """
156
+
157
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
158
+
159
+ def __init__(self) -> None:
160
+ self._state = State(episode_id=str(uuid4()), step_count=0)
161
+ self._task = "weekly_engage"
162
+ self._rng = random.Random(42)
163
+ self._init_state()
164
+
165
+ def _init_state(self) -> None:
166
+ self._energy = 1.0
167
+ self._followers = INITIAL_FOLLOWERS
168
+ self._initial_followers = INITIAL_FOLLOWERS
169
+ self._hour = 9
170
+ self._day = 0 # 0=Mon
171
+ self._posts_today = 0
172
+ self._last_post_types: List[str] = []
173
+ self._time_since_last_post = 0
174
+ self._engagement_history: List[float] = []
175
+ self._tag_history: Dict[str, List[float]] = defaultdict(list)
176
+ self._content_queue = 0
177
+ self._unique_tags_used: set = set()
178
+ self._unique_content_types: set = set()
179
+ self._energy_history: List[float] = [1.0]
180
+ self._posting_steps = 0
181
+ self._episode_done = False
182
+ self._last_topic: Optional[str] = None
183
+ self._final_observation: Optional[ViraltestObservation] = None
184
+ self._unique_topic_steps = 0
185
+ self._days_with_good_posts: set = set()
186
+ self._total_engagement = 0.0
187
+ self._posts_per_day: Dict[int, int] = defaultdict(int)
188
+ self._algorithm_penalty_remaining = 0
189
+
190
+ self._trending_topics = self._pick_trending_topics()
191
+ self._trending_tags = self._pick_trending_tags()
192
+ self._competitors = [CompetitorState(**p) for p in COMPETITOR_PROFILES]
193
+
194
+ # Sleep state: creator starts well-rested at 9am (awake since ~7am)
195
+ self._hours_since_sleep = 2 # Woke up 2 hours ago at start (9am)
196
+ self._sleep_debt = 0.0 # 0 = fully rested, 1 = severe deprivation
197
+
198
+ # ----- trend rotation -----
199
+
200
+ def _pick_trending_topics(self) -> List[str]:
201
+ all_topics = []
202
+ for cat_topics in TOPIC_CATEGORIES.values():
203
+ all_topics.extend(cat_topics)
204
+ return self._rng.sample(all_topics, min(3, len(all_topics)))
205
+
206
+ def _pick_trending_tags(self) -> List[str]:
207
+ return self._rng.sample(TAG_POOL, min(5, len(TAG_POOL)))
208
+
209
+ def _rotate_trends(self) -> None:
210
+ self._trending_topics = self._pick_trending_topics()
211
+ self._trending_tags = self._pick_trending_tags()
212
+
213
+ # ----- hour multiplier -----
214
+
215
+ def _get_hour_multiplier(self) -> float:
216
+ h = self._hour
217
+ d = self._day
218
+
219
+ is_weekend = d >= 5
220
+ base = WEEKEND_PENALTY if is_weekend else 1.0
221
+
222
+ if 12 <= h < 15 and d in PEAK_DAYS:
223
+ return base * 1.4
224
+ if 9 <= h < 12:
225
+ return base * 1.3
226
+ if 18 <= h < 20:
227
+ return base * 1.25
228
+ if 20 <= h < 23:
229
+ return base * 1.1
230
+ if h >= 23 or h < 6:
231
+ return base * 0.5
232
+ return base * 0.8
233
+
234
+ # ----- quality -----
235
+
236
+ def _get_quality_modifier(self) -> float:
237
+ """
238
+ Quality affected by both energy and sleep debt.
239
+
240
+ Sleep uses exponential decay curve (not step function):
241
+ - No impact until SLEEP_OPTIMAL_AWAKE hours (14hrs)
242
+ - Then: quality = 0.5 ^ ((hours - optimal) / halflife)
243
+ - At 24hrs awake: ~50% quality (matches research)
244
+ - Floor at SLEEP_MIN_QUALITY (30%)
245
+ """
246
+ # Energy component (existing logic)
247
+ if self._energy > 0.5:
248
+ energy_factor = 1.0
249
+ else:
250
+ energy_factor = max(0.48, self._energy * 1.5)
251
+
252
+ # Sleep component - exponential decay curve
253
+ if self._hours_since_sleep <= SLEEP_OPTIMAL_AWAKE:
254
+ sleep_factor = 1.0
255
+ else:
256
+ hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
257
+ # Exponential decay: halves every SLEEP_HALFLIFE_HOURS
258
+ sleep_factor = 0.5 ** (hours_over / SLEEP_HALFLIFE_HOURS)
259
+ sleep_factor = max(SLEEP_MIN_QUALITY, sleep_factor)
260
+
261
+ return energy_factor * sleep_factor
262
+
263
+ # ----- tags -----
264
+
265
+ def _calc_tag_boost(self, tags: Optional[List[str]]) -> float:
266
+ if not tags:
267
+ return 1.0
268
+ trending_count = sum(1 for t in tags if t in self._trending_tags)
269
+ perf_values = [
270
+ self._tag_performance_avg(t) for t in tags if self._tag_performance_avg(t) > 0
271
+ ]
272
+ perf_avg = sum(perf_values) / len(perf_values) if perf_values else 0.0
273
+ return 1.0 + 0.1 * trending_count + 0.05 * perf_avg
274
+
275
+ def _tag_performance_avg(self, tag: str) -> float:
276
+ history = self._tag_history.get(tag, [])
277
+ if not history:
278
+ return 0.0
279
+ window = history[-5:]
280
+ return sum(window) / len(window)
281
+
282
+ def _get_tag_performance_dict(self) -> Dict[str, float]:
283
+ return {tag: self._tag_performance_avg(tag) for tag in self._unique_tags_used}
284
+
285
+ # ----- competitors -----
286
+
287
+ def _advance_competitors(self) -> None:
288
+ for comp in self._competitors:
289
+ for p in comp.recent_posts:
290
+ p["hours_ago"] += 1
291
+ comp.recent_posts = [p for p in comp.recent_posts if p["hours_ago"] < 48]
292
+
293
+ post_prob = comp.posting_frequency / 24.0
294
+ if self._rng.random() < post_prob:
295
+ ct = self._rng.choice(comp.preferred_types)
296
+ topic = self._rng.choice(comp.niche_topics)
297
+ tags = self._rng.sample(
298
+ comp.tag_preferences, min(3, len(comp.tag_preferences))
299
+ )
300
+ eng = comp.base_engagement + self._rng.uniform(-0.1, 0.1)
301
+ eng = max(0.0, min(1.0, eng))
302
+ comp.recent_posts.append({
303
+ "content_type": ct,
304
+ "topic": topic,
305
+ "tags": tags,
306
+ "engagement": round(eng, 3),
307
+ "hours_ago": 0,
308
+ })
309
+
310
+ def _get_competitor_recent_posts(self, limit: int = 5) -> List[Dict[str, Any]]:
311
+ all_posts: List[Dict[str, Any]] = []
312
+ for comp in self._competitors:
313
+ for p in comp.recent_posts:
314
+ all_posts.append(p)
315
+ all_posts.sort(key=lambda x: x["hours_ago"])
316
+ return all_posts[:limit]
317
+
318
+ def _get_competitor_avg_engagement(self) -> float:
319
+ engagements = []
320
+ for comp in self._competitors:
321
+ for p in comp.recent_posts:
322
+ engagements.append(p["engagement"])
323
+ return sum(engagements) / len(engagements) if engagements else 0.0
324
+
325
+ def _calc_niche_saturation(self, topic: Optional[str]) -> float:
326
+ if not topic:
327
+ return 0.0
328
+ recent_topics = []
329
+ for comp in self._competitors:
330
+ for p in comp.recent_posts:
331
+ if p["hours_ago"] < 12:
332
+ recent_topics.append(p["topic"].lower())
333
+ if not recent_topics:
334
+ return 0.0
335
+ topic_lower = topic.lower()
336
+ overlap = sum(1 for t in recent_topics if _topic_overlap(topic_lower, t))
337
+ return min(1.0, overlap / max(1, len(recent_topics)))
338
+
339
+ def _calc_competitor_diff(self, topic: Optional[str]) -> float:
340
+ if not topic:
341
+ return 1.0
342
+ saturation = self._calc_niche_saturation(topic)
343
+ recent_topics = []
344
+ for comp in self._competitors:
345
+ for p in comp.recent_posts:
346
+ if p["hours_ago"] < 12:
347
+ recent_topics.append(p["topic"].lower())
348
+ topic_lower = topic.lower()
349
+ has_overlap = any(_topic_overlap(topic_lower, t) for t in recent_topics)
350
+ if not has_overlap:
351
+ return 1.3
352
+ if saturation > 0.7:
353
+ return 0.6
354
+ return 1.0
355
+
356
+ # ----- core API -----
357
+
358
+ def reset(
359
+ self,
360
+ seed: Optional[int] = None,
361
+ episode_id: Optional[str] = None,
362
+ **kwargs: Any,
363
+ ) -> ViraltestObservation:
364
+ self._task = kwargs.get("task", "weekly_engage")
365
+ if self._task not in VALID_TASKS:
366
+ self._task = "weekly_engage"
367
+
368
+ self._rng = random.Random(seed if seed is not None else 42)
369
+ self._state = State(
370
+ episode_id=episode_id or str(uuid4()), step_count=0
371
+ )
372
+ self._init_state()
373
+
374
+ return self._build_observation(reward=0.0, error=None)
375
+
376
+ def step(self, action: ViraltestAction, **kwargs: Any) -> ViraltestObservation: # type: ignore[override]
377
+ """Process a daily step: run 24 hourly sub-steps using the sparse schedule."""
378
+ if self._episode_done and self._final_observation is not None:
379
+ return self._final_observation
380
+
381
+ self._state.step_count += 1
382
+
383
+ schedule: Dict[int, ScheduledAction] = {}
384
+ errors: List[str] = []
385
+ for sa in action.scheduled_actions:
386
+ if sa.hour < 0 or sa.hour > 23:
387
+ errors.append(f"Invalid hour: {sa.hour}")
388
+ continue
389
+ err = self._validate_scheduled_action(sa)
390
+ if err:
391
+ errors.append(f"hour {sa.hour}: {err}")
392
+ continue
393
+ schedule[sa.hour] = sa
394
+
395
+ daily_engagement = 0.0
396
+ daily_reward = 0.0
397
+ daily_posts = 0
398
+ energy_min = self._energy
399
+ burned_out = False
400
+
401
+ for hour in range(24):
402
+ if burned_out:
403
+ break
404
+
405
+ if hour in schedule:
406
+ sa = schedule[hour]
407
+ hourly_eng, hourly_reward = self._process_hour_action(sa)
408
+ else:
409
+ hourly_eng, hourly_reward = self._process_hour_rest()
410
+
411
+ daily_engagement += hourly_eng
412
+ daily_reward += hourly_reward
413
+ if hourly_eng > 0:
414
+ daily_posts += 1
415
+ energy_min = min(energy_min, self._energy)
416
+
417
+ self._advance_competitors()
418
+ self._advance_time()
419
+ self._energy_history.append(self._energy)
420
+
421
+ if self._energy <= 0.0:
422
+ burned_out = True
423
+
424
+ day_posts = self._posts_per_day.get(self._day - 1, 0) if self._day > 0 else self._posts_per_day.get(0, 0)
425
+ prev_day = max(0, self._day - 1)
426
+ if 1 <= self._posts_per_day.get(prev_day, 0) <= 2:
427
+ self._days_with_good_posts.add(prev_day)
428
+
429
+ avg_reward = daily_reward / 24.0
430
+
431
+ error_str = "; ".join(errors) if errors else None
432
+
433
+ done = self._state.step_count >= TASK_HORIZON or self._energy <= 0.0
434
+ if done:
435
+ self._episode_done = True
436
+ grader_score = self._run_grader()
437
+ self._final_observation = self._build_observation(
438
+ reward=round(avg_reward, 4),
439
+ error=error_str,
440
+ done=True,
441
+ grader_score=grader_score,
442
+ daily_total_engagement=daily_engagement,
443
+ daily_posts_made=daily_posts,
444
+ daily_energy_min=energy_min,
445
+ )
446
+ return self._final_observation
447
+
448
+ return self._build_observation(
449
+ reward=round(avg_reward, 4),
450
+ error=error_str,
451
+ daily_total_engagement=daily_engagement,
452
+ daily_posts_made=daily_posts,
453
+ daily_energy_min=energy_min,
454
+ )
455
+
456
+ def _process_hour_action(self, sa: ScheduledAction) -> tuple:
457
+ """Process a single scheduled (non-rest) hourly action. Returns (engagement, reward)."""
458
+ engagement = 0.0
459
+
460
+ if sa.action_type == "post":
461
+ cost = CONTENT_ENERGY_COST.get(sa.content_type, 0.1) # type: ignore[arg-type]
462
+ if self._content_queue > 0:
463
+ cost *= 0.5
464
+ self._content_queue -= 1
465
+ if len(self._last_post_types) >= 3 and all(
466
+ t == sa.content_type for t in self._last_post_types[-3:]
467
+ ):
468
+ cost += REPETITION_ENERGY_PENALTY
469
+ self._energy = max(0.0, self._energy - cost)
470
+ self._unique_content_types.add(sa.content_type) # type: ignore[arg-type]
471
+
472
+ if self._energy <= 0.0:
473
+ engagement = 0.0
474
+ else:
475
+ base = BASE_ENGAGEMENT.get(sa.content_type, 0.3) # type: ignore[arg-type]
476
+ reach = REACH_MULT.get(sa.content_type, 1.0) # type: ignore[arg-type]
477
+ hour_mult = self._get_hour_multiplier()
478
+ quality = self._get_quality_modifier()
479
+ tag_boost = self._calc_tag_boost(sa.tags)
480
+ trending_bonus = 1.5 if self._is_topic_trending(sa.topic) else 1.0
481
+ comp_diff = self._calc_competitor_diff(sa.topic)
482
+
483
+ fatigue = 1.0
484
+ if self._posts_today >= AUDIENCE_FATIGUE_THRESHOLD_2:
485
+ fatigue = 0.1
486
+ elif self._posts_today >= AUDIENCE_FATIGUE_THRESHOLD_1:
487
+ fatigue = 0.5
488
+
489
+ algo_mult = 1.0
490
+ if self._algorithm_penalty_remaining > 0:
491
+ algo_mult = ALGORITHM_PENALTY_MULT
492
+ self._algorithm_penalty_remaining -= 1
493
+
494
+ engagement = (
495
+ base * reach * hour_mult * quality * tag_boost
496
+ * trending_bonus * comp_diff * fatigue * algo_mult
497
+ )
498
+ engagement = min(engagement, 5.0)
499
+
500
+ self._last_topic = sa.topic
501
+
502
+ if sa.tags and engagement > 0:
503
+ for tag in sa.tags:
504
+ tag_lower = tag.lower()
505
+ self._tag_history[tag_lower].append(engagement)
506
+ self._unique_tags_used.add(tag_lower)
507
+
508
+ self._engagement_history.append(engagement)
509
+ self._total_engagement += engagement
510
+ self._posting_steps += 1
511
+
512
+ if self._calc_competitor_diff(sa.topic) >= 1.3:
513
+ self._unique_topic_steps += 1
514
+
515
+ self._last_post_types.append(sa.content_type) # type: ignore[arg-type]
516
+ if len(self._last_post_types) > 3:
517
+ self._last_post_types = self._last_post_types[-3:]
518
+ self._posts_today += 1
519
+ self._posts_per_day[self._day] += 1
520
+ self._time_since_last_post = 0
521
+
522
+ if engagement > 0:
523
+ self._followers += int(engagement * 100)
524
+
525
+ elif sa.action_type == "create_content":
526
+ self._energy = max(0.0, self._energy - CREATE_CONTENT_COST)
527
+ self._content_queue += 1
528
+ self._time_since_last_post += 1
529
+
530
+ if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
531
+ self._followers = max(0, self._followers - int(self._followers * 0.005))
532
+ if self._algorithm_penalty_remaining == 0:
533
+ self._algorithm_penalty_remaining = ALGORITHM_PENALTY_DURATION
534
+
535
+ reward = 0.0 if self._energy <= 0.0 else self._compute_hourly_reward(sa, engagement)
536
+ return engagement, reward
537
+
538
+ def _process_hour_rest(self) -> tuple:
539
+ """Process a rest hour. Returns (0.0, reward)."""
540
+ self._energy = min(1.0, self._energy + REST_RECOVERY)
541
+ self._hours_since_sleep = max(0, self._hours_since_sleep - SLEEP_RECOVERY_PER_REST)
542
+ self._sleep_debt = max(0.0, self._sleep_debt - 0.1)
543
+ self._time_since_last_post += 1
544
+
545
+ if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
546
+ self._followers = max(0, self._followers - int(self._followers * 0.005))
547
+ if self._algorithm_penalty_remaining == 0:
548
+ self._algorithm_penalty_remaining = ALGORITHM_PENALTY_DURATION
549
+
550
+ reward = 0.0 if self._energy <= 0.0 else self._compute_rest_reward()
551
+ return 0.0, reward
552
+
553
+ @property
554
+ def state(self) -> State:
555
+ return self._state
556
+
557
+ # ----- validation -----
558
+
559
+ def _validate_scheduled_action(self, sa: ScheduledAction) -> Optional[str]:
560
+ if sa.action_type not in ("post", "create_content"):
561
+ return f"Invalid action_type: {sa.action_type}"
562
+ if sa.action_type == "post":
563
+ if not sa.content_type:
564
+ return "content_type is required when posting"
565
+ if sa.content_type not in CONTENT_ENERGY_COST:
566
+ return f"Invalid content_type: {sa.content_type}"
567
+ if not sa.topic or not sa.topic.strip():
568
+ return "topic is required when posting"
569
+ if len(sa.topic) > 200:
570
+ return "topic must be ≤200 characters"
571
+ if sa.tags:
572
+ valid = [t for t in sa.tags if t.lower() in TAG_POOL]
573
+ sa.tags = valid if valid else None
574
+ return None
575
+
576
+ # ----- trending -----
577
+
578
+ def _is_topic_trending(self, topic: Optional[str]) -> bool:
579
+ if not topic:
580
+ return False
581
+ topic_lower = topic.lower()
582
+ return any(t.lower() in topic_lower for t in self._trending_topics)
583
+
584
+ # ----- reward -----
585
+
586
+ def _compute_hourly_reward(self, sa: ScheduledAction, engagement: float) -> float:
587
+ eng_component = min(1.0, engagement / 2.0) * 0.3
588
+
589
+ prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
590
+ energy_delta = self._energy - prev_energy
591
+ energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
592
+
593
+ day_posts = self._posts_per_day.get(self._day, 0)
594
+ if 1 <= day_posts <= 2:
595
+ consistency = 1.0
596
+ elif day_posts == 0 or day_posts == 3:
597
+ consistency = 0.5
598
+ else:
599
+ consistency = 0.0
600
+ consistency_component = consistency * 0.15
601
+
602
+ tag_component = 0.0
603
+ if sa.action_type == "post" and sa.tags:
604
+ trending_match = sum(1 for t in sa.tags if t.lower() in self._trending_tags) / 5.0
605
+ tag_component = min(1.0, trending_match + 0.3) * 0.15
606
+
607
+ comp_component = 0.0
608
+ if sa.action_type == "post":
609
+ diff = self._calc_competitor_diff(sa.topic)
610
+ comp_component = min(1.0, diff / 1.3) * 0.15
611
+
612
+ burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
613
+
614
+ raw = eng_component + energy_component + consistency_component + tag_component + comp_component - burnout_penalty
615
+ return max(0.0, min(1.0, raw))
616
+
617
+ def _compute_rest_reward(self) -> float:
618
+ prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
619
+ energy_delta = self._energy - prev_energy
620
+ energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
621
+
622
+ day_posts = self._posts_per_day.get(self._day, 0)
623
+ if 1 <= day_posts <= 2:
624
+ consistency = 1.0
625
+ elif day_posts == 0 or day_posts == 3:
626
+ consistency = 0.5
627
+ else:
628
+ consistency = 0.0
629
+ consistency_component = consistency * 0.15
630
+
631
+ burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
632
+ raw = energy_component + consistency_component - burnout_penalty
633
+ return max(0.0, min(1.0, raw))
634
+
635
+ # ----- time -----
636
+
637
+ def _advance_time(self) -> None:
638
+ self._hour += 1
639
+
640
+ # Track hours since sleep (always increases unless resting)
641
+ self._hours_since_sleep += 1
642
+
643
+ # Sleep deprivation drains extra energy (smooth ramp after threshold)
644
+ if self._hours_since_sleep > SLEEP_ENERGY_DRAIN_START:
645
+ hours_over = self._hours_since_sleep - SLEEP_ENERGY_DRAIN_START
646
+ # Drain increases smoothly the longer you're awake
647
+ drain = SLEEP_ENERGY_DRAIN_RATE * (1 + hours_over * 0.1)
648
+ self._energy = max(0.0, self._energy - drain)
649
+
650
+ # Update sleep debt (smooth accumulation based on hours awake)
651
+ if self._hours_since_sleep > SLEEP_OPTIMAL_AWAKE:
652
+ hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
653
+ # Debt accumulates faster the longer awake (quadratic-ish curve)
654
+ debt_rate = 0.01 * (1 + hours_over * 0.05)
655
+ self._sleep_debt = min(1.0, self._sleep_debt + debt_rate)
656
+
657
+ if self._hour >= 24:
658
+ self._hour = 0
659
+ self._day += 1
660
+ self._posts_today = 0
661
+ self._rotate_trends()
662
+
663
+ # ----- observation builder -----
664
+
665
+ def _build_observation(
666
+ self,
667
+ reward: float,
668
+ error: Optional[str],
669
+ done: bool = False,
670
+ grader_score: Optional[float] = None,
671
+ daily_total_engagement: float = 0.0,
672
+ daily_posts_made: int = 0,
673
+ daily_energy_min: float = 1.0,
674
+ ) -> ViraltestObservation:
675
+ recent_eng = self._engagement_history[-10:] if self._engagement_history else []
676
+ eng_rate = sum(recent_eng) / len(recent_eng) if recent_eng else 0.0
677
+
678
+ meta: Dict[str, Any] = {"step": self._state.step_count, "task": self._task}
679
+ if grader_score is not None:
680
+ meta["grader_score"] = round(grader_score, 4)
681
+
682
+ return ViraltestObservation(
683
+ current_hour=self._hour,
684
+ day_of_week=self._day % 7,
685
+ days_elapsed=self._day,
686
+ creator_energy=round(self._energy, 3),
687
+ hours_since_sleep=self._hours_since_sleep,
688
+ sleep_debt=round(self._sleep_debt, 3),
689
+ follower_count=self._followers,
690
+ engagement_rate=round(eng_rate, 4),
691
+ posts_today=self._posts_today,
692
+ time_since_last_post=self._time_since_last_post,
693
+ trending_topics=list(self._trending_topics),
694
+ content_queue_size=self._content_queue,
695
+ last_post_type=self._last_post_types[-1] if self._last_post_types else "none",
696
+ tag_performance=self._get_tag_performance_dict(),
697
+ trending_tags=list(self._trending_tags),
698
+ competitor_recent_posts=self._get_competitor_recent_posts(),
699
+ competitor_avg_engagement=round(self._get_competitor_avg_engagement(), 4),
700
+ niche_saturation=round(self._calc_niche_saturation(self._last_topic), 3),
701
+ daily_total_engagement=round(daily_total_engagement, 4),
702
+ daily_posts_made=daily_posts_made,
703
+ daily_energy_min=round(daily_energy_min, 3),
704
+ grader_score=round(grader_score, 4) if grader_score is not None else None,
705
+ error=error,
706
+ done=done,
707
+ reward=round(reward, 4),
708
+ metadata=meta,
709
+ )
710
+
711
+ # ----- graders -----
712
+
713
+ def _run_grader(self) -> float:
714
+ if self._task == "weekly_engage":
715
+ return self._grade_weekly_engage()
716
+ elif self._task == "weekly_strategic":
717
+ return self._grade_weekly_strategic()
718
+ elif self._task == "weekly_competitive":
719
+ return self._grade_weekly_competitive()
720
+ return 0.0
721
+
722
+ def _theoretical_max_engagement(self) -> float:
723
+ best_base = max(BASE_ENGAGEMENT.values())
724
+ best_reach = max(REACH_MULT.values())
725
+ peak_mult = 1.4
726
+ quality = 1.0
727
+ posts_per_day = 2
728
+ days = 7
729
+ return best_base * best_reach * peak_mult * quality * posts_per_day * days
730
+
731
+ def _grade_weekly_engage(self) -> float:
732
+ theoretical_max = self._theoretical_max_engagement()
733
+ if theoretical_max <= 0:
734
+ return 0.0
735
+ raw = min(1.0, self._total_engagement / theoretical_max)
736
+ if self._energy <= 0.0:
737
+ raw *= 0.3 # burnout penalty even on easy task
738
+ return raw
739
+
740
+ def _grade_weekly_strategic(self) -> float:
741
+ # Burnout = severe penalty (not total fail like competitive, but close)
742
+ if self._energy <= 0.0:
743
+ return max(0.0, min(0.15, self._total_engagement * 0.01))
744
+
745
+ # Engagement: 35%
746
+ theoretical_max = self._theoretical_max_engagement()
747
+ norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
748
+
749
+ # Tag score: 25% (40% discovery + 60% exploitation)
750
+ positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
751
+ tag_discovery = min(1.0, positive_tags / 30.0)
752
+ top_perfs = sorted(
753
+ [self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True
754
+ )[:3]
755
+ tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
756
+ tag_exploitation = min(1.0, tag_exploitation / 2.0)
757
+ tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
758
+
759
+ # Avg energy: 25%
760
+ avg_energy = sum(self._energy_history) / len(self._energy_history) if self._energy_history else 0.0
761
+
762
+ # Consistency: 15%
763
+ consistency = len(self._days_with_good_posts) / 7.0
764
+
765
+ raw = 0.35 * norm_eng + 0.25 * tag_score + 0.25 * avg_energy + 0.15 * consistency
766
+
767
+ # Constraints
768
+ min_energy = min(self._energy_history) if self._energy_history else 0.0
769
+ if min_energy < 0.2:
770
+ raw *= 0.4 # crashed hard
771
+ elif min_energy < 0.3:
772
+ raw = min(raw, 0.45)
773
+ if len(self._unique_tags_used) < 5:
774
+ raw *= 0.7
775
+
776
+ return max(0.0, min(1.0, raw))
777
+
778
+ def _grade_weekly_competitive(self) -> float:
779
+ # Burnout = total fail
780
+ if self._energy <= 0.0:
781
+ return 0.0
782
+
783
+ # Engagement: 25%
784
+ theoretical_max = self._theoretical_max_engagement()
785
+ norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
786
+
787
+ # Tag score: 20%
788
+ positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
789
+ tag_discovery = min(1.0, positive_tags / 30.0)
790
+ top_perfs = sorted(
791
+ [self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True
792
+ )[:3]
793
+ tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
794
+ tag_exploitation = min(1.0, tag_exploitation / 2.0)
795
+ tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
796
+
797
+ # Follower growth: 20%
798
+ growth = (self._followers - self._initial_followers) / self._initial_followers if self._initial_followers > 0 else 0.0
799
+ target_growth = 0.05
800
+ norm_growth = min(1.0, max(0.0, growth / target_growth))
801
+
802
+ # Competitor outperformance: 15%
803
+ comp_avg = self._get_competitor_avg_engagement()
804
+ my_avg = self._total_engagement / self._posting_steps if self._posting_steps > 0 else 0.0
805
+ outperformance = my_avg / comp_avg if comp_avg > 0 else 1.0
806
+ norm_outperformance = min(1.0, outperformance / 1.5)
807
+
808
+ # Differentiation: 10%
809
+ differentiation = self._unique_topic_steps / self._posting_steps if self._posting_steps > 0 else 0.0
810
+
811
+ # Energy floor: 10%
812
+ min_energy = min(self._energy_history) if self._energy_history else 0.0
813
+ energy_floor = min(1.0, max(0.0, min_energy))
814
+
815
+ raw = (
816
+ 0.25 * norm_eng
817
+ + 0.20 * tag_score
818
+ + 0.20 * norm_growth
819
+ + 0.15 * norm_outperformance
820
+ + 0.10 * differentiation
821
+ + 0.10 * energy_floor
822
+ )
823
+
824
+ # Constraints
825
+ if len(self._unique_content_types) < 3:
826
+ raw *= 0.5
827
+ if len(self._unique_tags_used) < 8:
828
+ raw *= 0.7
829
+
830
+ return max(0.0, min(1.0, raw))
831
+
832
+
833
+ # ---------------------------------------------------------------------------
834
+ # Helpers
835
+ # ---------------------------------------------------------------------------
836
+
837
+ def _topic_overlap(topic_a: str, topic_b: str) -> bool:
838
+ """Check if two topics have significant word overlap."""
839
+ words_a = set(topic_a.split())
840
+ words_b = set(topic_b.split())
841
+ if not words_a or not words_b:
842
+ return False
843
+ common = words_a & words_b
844
+ return len(common) / min(len(words_a), len(words_b)) >= 0.5
test_scenarios.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Viraltest — Edge Case & Scenario Tests (Daily Plan Format)
3
+ Runs scenarios for all 3 tasks using the new daily step format.
4
+ Each step = one full day. Agent submits a sparse daily plan.
5
+ """
6
+
7
+ import random as stdlib_random
8
+ from typing import Callable, Dict, List, Tuple
9
+
10
+ from models import ScheduledAction, ViraltestAction
11
+ from server.viraltest_environment import (
12
+ TAG_POOL,
13
+ ViraltestEnvironment,
14
+ ViraltestObservation,
15
+ )
16
+
17
+ TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
18
+ SEED = 42
19
+
20
+ _CONTENT_TYPES = ["reel", "carousel", "story", "text_post"]
21
+ _TOPICS = ["AI tools", "fitness routine", "growth hacks", "travel guide", "food recipe", "wellness tips"]
22
+ _rng = stdlib_random.Random(99)
23
+
24
+
25
+ def _plan(actions: list) -> ViraltestAction:
26
+ return ViraltestAction(scheduled_actions=[ScheduledAction(**a) for a in actions])
27
+
28
+
29
+ def run_episode(
30
+ task: str,
31
+ plan_fn: Callable[[Dict, int], ViraltestAction],
32
+ label: str,
33
+ ) -> float:
34
+ env = ViraltestEnvironment()
35
+ obs = env.reset(task=task, seed=SEED)
36
+ obs_dict = obs.model_dump()
37
+ rewards: List[float] = []
38
+ min_energy = 1.0
39
+ burned_out = False
40
+
41
+ for day in range(1, 8):
42
+ action = plan_fn(obs_dict, day)
43
+ obs = env.step(action)
44
+ obs_dict = obs.model_dump()
45
+ r = obs.reward if obs.reward is not None else 0.0
46
+ rewards.append(r)
47
+ min_energy = min(min_energy, obs.creator_energy)
48
+ if obs.done and obs.creator_energy <= 0:
49
+ burned_out = True
50
+ if obs.done:
51
+ break
52
+
53
+ score = (obs.metadata or {}).get("grader_score", 0.0)
54
+ total_steps = len(rewards)
55
+
56
+ print(f" Task: {task}")
57
+ print(f" Days: {total_steps} | Done: {obs.done} | Burned out: {burned_out}")
58
+ print(f" Score: {score:.4f} | Total reward: {sum(rewards):.2f} | Avg reward: {sum(rewards)/len(rewards):.3f}")
59
+ print(f" Energy: {obs.creator_energy:.2f} | Min energy: {min_energy:.2f}")
60
+ print(f" Followers: {obs.follower_count} (started 10000, delta {obs.follower_count - 10000:+d})")
61
+ print(f" Engagement rate: {obs.engagement_rate:.4f}")
62
+ print(f" Unique tags: {len(obs.tag_performance)}")
63
+ print(f" Niche saturation: {obs.niche_saturation:.3f}")
64
+ print()
65
+ return score
66
+
67
+
68
+ def plan_always_rest(obs: dict, day: int) -> ViraltestAction:
69
+ return _plan([])
70
+
71
+
72
+ def plan_spam(obs: dict, day: int) -> ViraltestAction:
73
+ return _plan([{"hour": h, "action_type": "post", "content_type": "reel",
74
+ "topic": "AI tools", "tags": ["ai"]} for h in range(24)])
75
+
76
+
77
+ def plan_smart(obs: dict, day: int) -> ViraltestAction:
78
+ trending = (obs.get("trending_topics") or ["AI tools"])[0]
79
+ t_tags = list((obs.get("trending_tags") or [])[:2])
80
+ pool_tag = TAG_POOL[(day * 2) % len(TAG_POOL)]
81
+ pool_tag2 = TAG_POOL[(day * 2 + 1) % len(TAG_POOL)]
82
+ ct1 = _CONTENT_TYPES[(day * 2) % 4]
83
+ ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
84
+ return _plan([
85
+ {"hour": 8, "action_type": "create_content"},
86
+ {"hour": 12, "action_type": "post", "content_type": ct1, "topic": trending, "tags": t_tags + [pool_tag]},
87
+ {"hour": 19, "action_type": "post", "content_type": ct2, "topic": trending, "tags": t_tags + [pool_tag2]},
88
+ ])
89
+
90
+
91
+ def plan_no_rest(obs: dict, day: int) -> ViraltestAction:
92
+ actions = []
93
+ for h in range(24):
94
+ ct = _CONTENT_TYPES[h % 4]
95
+ topic = _rng.choice(_TOPICS)
96
+ tags = _rng.sample(TAG_POOL, 3)
97
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
98
+ return _plan(actions)
99
+
100
+
101
+ def plan_minimal(obs: dict, day: int) -> ViraltestAction:
102
+ trending = (obs.get("trending_topics") or ["minimalism"])[0]
103
+ tags = list((obs.get("trending_tags") or [])[:3])
104
+ return _plan([
105
+ {"hour": 12, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
106
+ ])
107
+
108
+
109
+ def plan_tag_explorer(obs: dict, day: int) -> ViraltestAction:
110
+ trending = (obs.get("trending_topics") or ["devtools"])[0]
111
+ start = (day * 6) % len(TAG_POOL)
112
+ tags1 = [TAG_POOL[(start + i) % len(TAG_POOL)] for i in range(3)]
113
+ tags2 = [TAG_POOL[(start + 3 + i) % len(TAG_POOL)] for i in range(3)]
114
+ ct1 = _CONTENT_TYPES[(day * 2) % 4]
115
+ ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
116
+ return _plan([
117
+ {"hour": 10, "action_type": "post", "content_type": ct1, "topic": trending, "tags": tags1},
118
+ {"hour": 18, "action_type": "post", "content_type": ct2, "topic": trending, "tags": tags2},
119
+ ])
120
+
121
+
122
+ def plan_queue_optimizer(obs: dict, day: int) -> ViraltestAction:
123
+ trending = (obs.get("trending_topics") or ["productivity"])[0]
124
+ tags = list((obs.get("trending_tags") or [])[:2]) + ["growth"]
125
+ queue = obs.get("content_queue_size", 0)
126
+ if day < 3 or queue < 2:
127
+ return _plan([
128
+ {"hour": 8, "action_type": "create_content"},
129
+ {"hour": 10, "action_type": "create_content"},
130
+ {"hour": 14, "action_type": "create_content"},
131
+ ])
132
+ ct = _CONTENT_TYPES[day % 4]
133
+ return _plan([
134
+ {"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
135
+ {"hour": 19, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": trending, "tags": tags},
136
+ ])
137
+
138
+
139
+ def plan_double_peak(obs: dict, day: int) -> ViraltestAction:
140
+ trending = (obs.get("trending_topics") or ["peak time content"])[0]
141
+ tags = list((obs.get("trending_tags") or [])[:3])
142
+ return _plan([
143
+ {"hour": 9, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
144
+ {"hour": 15, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
145
+ ])
146
+
147
+
148
+ def plan_random(obs: dict, day: int) -> ViraltestAction:
149
+ actions = []
150
+ for h in range(24):
151
+ r = _rng.random()
152
+ if r < 0.1:
153
+ ct = _rng.choice(_CONTENT_TYPES)
154
+ topic = _rng.choice(["random topic", "AI tools", "fitness", "travel"])
155
+ tags = _rng.sample(TAG_POOL, 2)
156
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
157
+ elif r < 0.15:
158
+ actions.append({"hour": h, "action_type": "create_content"})
159
+ return _plan(actions)
160
+
161
+
162
+ SCENARIOS: List[Tuple[str, Callable, str]] = [
163
+ ("Always Rest", plan_always_rest, "Zero engagement, no growth, energy stays max"),
164
+ ("Spam Post", plan_spam, "Post every hour, burns out instantly"),
165
+ ("Smart Agent", plan_smart, "Peak hours, trending, varied types, energy management"),
166
+ ("No Rest", plan_no_rest, "Post every hour, never rests, burns out"),
167
+ ("Minimal Poster", plan_minimal, "1 carousel at noon per day"),
168
+ ("Tag Explorer", plan_tag_explorer, "Rotates through tag pool for max discovery"),
169
+ ("Queue Optimizer", plan_queue_optimizer, "Creates content first, posts from queue"),
170
+ ("Double Peak", plan_double_peak, "Posts at 9am and 3pm"),
171
+ ("Random Actor", plan_random, "Random sparse actions each day"),
172
+ ]
173
+
174
+
175
+ if __name__ == "__main__":
176
+ print("=" * 70)
177
+ print("VIRALTEST — DAILY PLAN SCENARIO TESTS")
178
+ print("=" * 70)
179
+ print()
180
+
181
+ for scenario_name, plan_fn, description in SCENARIOS:
182
+ print("=" * 70)
183
+ print(f"{scenario_name}")
184
+ print(f" {description}")
185
+ print("=" * 70)
186
+ print()
187
+
188
+ for task in TASKS:
189
+ _rng = stdlib_random.Random(99)
190
+ run_episode(task, plan_fn, scenario_name)
191
+
192
+ print()
193
+
194
+ print("=" * 70)
195
+ print("SUMMARY TABLE")
196
+ print("=" * 70)
197
+ print()
198
+ print(f"{'Scenario':<30} {'Engage':>8} {'Strategic':>10} {'Competitive':>12}")
199
+ print("-" * 62)
200
+
201
+ for scenario_name, plan_fn, _ in SCENARIOS:
202
+ scores = []
203
+ for task in TASKS:
204
+ _rng = stdlib_random.Random(99)
205
+ env = ViraltestEnvironment()
206
+ obs = env.reset(task=task, seed=SEED)
207
+ obs_dict = obs.model_dump()
208
+ for day in range(1, 8):
209
+ action = plan_fn(obs_dict, day)
210
+ obs = env.step(action)
211
+ obs_dict = obs.model_dump()
212
+ if obs.done:
213
+ break
214
+ scores.append((obs.metadata or {}).get("grader_score", 0.0))
215
+ print(f"{scenario_name:<30} {scores[0]:>8.4f} {scores[1]:>10.4f} {scores[2]:>12.4f}")
216
+
217
+ print()
218
+ print("EXPECTED: Smart/Queue/Tag Explorer should score highest.")
219
+ print("Burnout agents (spam, no_rest) should score near 0 on strategic/competitive.")
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
validate-submission.sh ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # validate-submission.sh — OpenEnv Submission Validator for Viraltest
4
+ #
5
+ # Checks that your HF Space is live, Docker image builds, and openenv validate passes.
6
+ #
7
+ # Prerequisites:
8
+ # - Docker: https://docs.docker.com/get-docker/
9
+ # - openenv validate: uv sync (uses .venv/bin/openenv), or pip install openenv-core, or uv on PATH
10
+ # - curl (usually pre-installed)
11
+ #
12
+ # Run:
13
+ # chmod +x validate-submission.sh
14
+ # ./validate-submission.sh <ping_url> [repo_dir]
15
+ #
16
+ # Optional: create repo-local .env (gitignored) with HF_TOKEN=... — sourced automatically.
17
+ # cp .env.example .env # then edit .env
18
+ #
19
+ # Skip Docker build (Step 2) — faster local checks; run full build before submit:
20
+ # SKIP_DOCKER=1 ./validate-submission.sh https://your-space.hf.space
21
+ #
22
+ # Step 5 — Hugging Face Inference Router LLM smoke test (runs by default if HF_TOKEN is set):
23
+ # export HF_TOKEN=hf_... # required for Step 5; never commit; use Space Secrets for deploys
24
+ # # Optional overrides (defaults match inference.py / HF router):
25
+ # export MODEL_NAME=gemma-4-E4B-it-IQ4_XS
26
+ # export API_BASE_URL=https://router.huggingface.co/v1
27
+ # SKIP_LLM_SMOKE=1 # only if you must skip Step 5 (e.g. CI without secrets)
28
+ #
29
+ # HF token permissions (403 = insufficient permissions):
30
+ # - Create or edit at https://huggingface.co/settings/tokens
31
+ # - For https://router.huggingface.co/v1 the token must be allowed to call
32
+ # Inference Providers / serverless inference for your account (UI labels vary).
33
+ # - If 403 persists, confirm billing/access for Inference Providers in HF account settings.
34
+ # - LLM_SMOKE_OPTIONAL=1 — still pass Steps 1,3–5 when Step 5 auth fails (not for production).
35
+ #
36
+ # Arguments:
37
+ # ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)
38
+ # repo_dir Path to your repo (default: current directory)
39
+ #
40
+ # Examples:
41
+ # ./validate-submission.sh https://my-team.hf.space
42
+ # ./validate-submission.sh https://my-team.hf.space ./viraltest
43
+
44
+ set -uo pipefail
45
+
46
+ DOCKER_BUILD_TIMEOUT=600
47
+ if [ -t 1 ]; then
48
+ RED='\033[0;31m'
49
+ GREEN='\033[0;32m'
50
+ YELLOW='\033[1;33m'
51
+ BOLD='\033[1m'
52
+ NC='\033[0m'
53
+ else
54
+ RED='' GREEN='' YELLOW='' BOLD='' NC=''
55
+ fi
56
+
57
+ run_with_timeout() {
58
+ local secs="$1"; shift
59
+ if command -v timeout &>/dev/null; then
60
+ timeout "$secs" "$@"
61
+ elif command -v gtimeout &>/dev/null; then
62
+ gtimeout "$secs" "$@"
63
+ else
64
+ "$@" &
65
+ local pid=$!
66
+ ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
67
+ local watcher=$!
68
+ wait "$pid" 2>/dev/null
69
+ local rc=$?
70
+ kill "$watcher" 2>/dev/null
71
+ wait "$watcher" 2>/dev/null
72
+ return $rc
73
+ fi
74
+ }
75
+
76
+ portable_mktemp() {
77
+ local prefix="${1:-validate}"
78
+ mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
79
+ }
80
+
81
+ CLEANUP_FILES=()
82
+ cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
83
+ trap cleanup EXIT
84
+
85
+ PING_URL="${1:-}"
86
+ REPO_DIR="${2:-.}"
87
+
88
+ if [ -z "$PING_URL" ]; then
89
+ printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
90
+ printf "\n"
91
+ printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
92
+ printf " repo_dir Path to your repo (default: current directory)\n"
93
+ exit 1
94
+ fi
95
+
96
+ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
97
+ printf "Error: directory '%s' not found\n" "${2:-.}"
98
+ exit 1
99
+ fi
100
+ PING_URL="${PING_URL%/}"
101
+ export PING_URL
102
+ PASS=0
103
+
104
+ log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
105
+ pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
106
+ fail() { log "${RED}FAILED${NC} -- $1"; }
107
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
108
+ stop_at() {
109
+ printf "\n"
110
+ printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
111
+ exit 1
112
+ }
113
+
114
+ if [ -f "$REPO_DIR/.env" ]; then
115
+ set -a
116
+ # shellcheck disable=SC1091
117
+ . "$REPO_DIR/.env"
118
+ set +a
119
+ fi
120
+
121
+ printf "\n"
122
+ printf "${BOLD}========================================${NC}\n"
123
+ printf "${BOLD} Viraltest Submission Validator${NC}\n"
124
+ printf "${BOLD}========================================${NC}\n"
125
+ log "Repo: $REPO_DIR"
126
+ log "Ping URL: $PING_URL"
127
+ if [ "${SKIP_DOCKER:-}" = "1" ]; then
128
+ log "${YELLOW}SKIP_DOCKER=1 — Docker build will be skipped${NC}"
129
+ fi
130
+ printf "\n"
131
+
132
+ # ──────────────────────────────────────
133
+ # Step 1: Ping HF Space
134
+ # ──────────────────────────────────────
135
+ log "${BOLD}Step 1/5: Pinging HF Space${NC} ($PING_URL/reset) ..."
136
+
137
+ CURL_OUTPUT=$(portable_mktemp "validate-curl")
138
+ CLEANUP_FILES+=("$CURL_OUTPUT")
139
+ HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
140
+ -H "Content-Type: application/json" -d '{}' \
141
+ "$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
142
+
143
+ if [ "$HTTP_CODE" = "200" ]; then
144
+ pass "HF Space is live and responds to /reset"
145
+ elif [ "$HTTP_CODE" = "000" ]; then
146
+ fail "HF Space not reachable (connection failed or timed out)"
147
+ hint "Check your network and that the Space is running."
148
+ stop_at "Step 1"
149
+ else
150
+ fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
151
+ hint "Make sure your Space is running. Try: curl -X POST $PING_URL/reset"
152
+ stop_at "Step 1"
153
+ fi
154
+
155
+ # ──────────────────────────────────────
156
+ # Step 2: Docker build
157
+ # ──────────────────────────────────────
158
+ if [ "${SKIP_DOCKER:-}" = "1" ]; then
159
+ log "${BOLD}Step 2/5: Docker build${NC} ${YELLOW}SKIPPED${NC} (SKIP_DOCKER=1)"
160
+ hint "Run without SKIP_DOCKER=1 before submission to confirm docker build still succeeds."
161
+ else
162
+ log "${BOLD}Step 2/5: Running docker build${NC} ..."
163
+
164
+ if ! command -v docker &>/dev/null; then
165
+ fail "docker command not found"
166
+ hint "Install Docker: https://docs.docker.com/get-docker/"
167
+ stop_at "Step 2"
168
+ fi
169
+
170
+ if [ -f "$REPO_DIR/Dockerfile" ]; then
171
+ DOCKER_CONTEXT="$REPO_DIR"
172
+ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
173
+ DOCKER_CONTEXT="$REPO_DIR/server"
174
+ else
175
+ fail "No Dockerfile found in repo root or server/ directory"
176
+ stop_at "Step 2"
177
+ fi
178
+
179
+ log " Found Dockerfile in $DOCKER_CONTEXT"
180
+
181
+ BUILD_OK=false
182
+ BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
183
+
184
+ if [ "$BUILD_OK" = true ]; then
185
+ pass "Docker build succeeded"
186
+ else
187
+ fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
188
+ printf "%s\n" "$BUILD_OUTPUT" | tail -20
189
+ stop_at "Step 2"
190
+ fi
191
+ fi
192
+
193
+ # ──────────────────────────────────────
194
+ # Step 3: openenv validate
195
+ # ──────────────────────────────────────
196
+ log "${BOLD}Step 3/5: Running openenv validate${NC} ..."
197
+
198
+ VALIDATE_OK=false
199
+ VALIDATE_OUTPUT=""
200
+ VENV_OPENENV="$REPO_DIR/.venv/bin/openenv"
201
+ if command -v uv &>/dev/null && [ -f "$REPO_DIR/pyproject.toml" ]; then
202
+ log " Using: uv run openenv validate (avoids global CLI / Python mismatch)"
203
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && uv run openenv validate 2>&1) && VALIDATE_OK=true
204
+ elif command -v openenv &>/dev/null; then
205
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
206
+ elif [ -x "$VENV_OPENENV" ]; then
207
+ log " Using: .venv/bin/openenv (repo virtualenv; run: uv sync)"
208
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && "$VENV_OPENENV" validate 2>&1) && VALIDATE_OK=true
209
+ else
210
+ fail "openenv not found (no uv, no openenv on PATH, no .venv/bin/openenv)"
211
+ hint "From the repo: uv sync # then re-run; or: pip install openenv-core"
212
+ stop_at "Step 3"
213
+ fi
214
+
215
+ if [ "$VALIDATE_OK" = true ]; then
216
+ pass "openenv validate passed"
217
+ [ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
218
+ else
219
+ fail "openenv validate failed"
220
+ printf "%s\n" "$VALIDATE_OUTPUT"
221
+ stop_at "Step 3"
222
+ fi
223
+
224
+ # ──────────────────────────────────────
225
+ # Step 4: Viraltest-specific checks
226
+ # ──────────────────────────────────────
227
+ log "${BOLD}Step 4/5: Viraltest environment checks${NC} ..."
228
+
229
+ STEP_OUTPUT=$(portable_mktemp "validate-step")
230
+ CLEANUP_FILES+=("$STEP_OUTPUT")
231
+
232
+ # Test all 3 tasks respond to reset
233
+ for TASK in weekly_engage weekly_strategic weekly_competitive; do
234
+ TASK_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
235
+ -H "Content-Type: application/json" \
236
+ -d "{\"task\": \"$TASK\"}" \
237
+ "$PING_URL/reset" --max-time 15 2>/dev/null || printf "000")
238
+
239
+ if [ "$TASK_CODE" = "200" ]; then
240
+ log " ${GREEN}OK${NC} task=$TASK reset responds"
241
+ else
242
+ fail "Task $TASK reset returned HTTP $TASK_CODE"
243
+ stop_at "Step 4"
244
+ fi
245
+ done
246
+
247
+ # Test step endpoint with a daily plan action (sparse: one post at hour 12)
248
+ STEP_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
249
+ -H "Content-Type: application/json" \
250
+ -d '{"action":{"scheduled_actions":[{"hour":12,"action_type":"post","content_type":"reel","topic":"AI trends","tags":["ai","ml"]}]}}' \
251
+ "$PING_URL/step" --max-time 15 2>/dev/null || printf "000")
252
+
253
+ if [ "$STEP_CODE" = "200" ]; then
254
+ pass "Step endpoint responds correctly"
255
+ else
256
+ fail "Step endpoint returned HTTP $STEP_CODE"
257
+ stop_at "Step 4"
258
+ fi
259
+
260
+ # Check inference.py exists
261
+ if [ -f "$REPO_DIR/inference.py" ]; then
262
+ pass "inference.py found in project root"
263
+ else
264
+ fail "inference.py not found in $REPO_DIR"
265
+ stop_at "Step 4"
266
+ fi
267
+
268
+ # ──────────────────────────────────────
269
+ # Step 5: HF Inference Router — one chat completion
270
+ # ──────────────────────────────────────
271
+ DEFAULT_SMOKE_MODEL="gemma-4-E4B-it-IQ4_XS"
272
+ DEFAULT_SMOKE_API="https://router.huggingface.co/v1"
273
+ SMOKE_MODEL="${MODEL_NAME:-$DEFAULT_SMOKE_MODEL}"
274
+ SMOKE_API="${API_BASE_URL:-$DEFAULT_SMOKE_API}"
275
+
276
+ if [ "${SKIP_LLM_SMOKE:-}" = "1" ]; then
277
+ log "${BOLD}Step 5/5: LLM router smoke test${NC} ${YELLOW}SKIPPED${NC} (SKIP_LLM_SMOKE=1)"
278
+ elif [ -z "${HF_TOKEN:-}" ]; then
279
+ fail "Step 5 requires HF_TOKEN (Inference router). Export it from https://huggingface.co/settings/tokens"
280
+ hint "Override model/URL: MODEL_NAME and API_BASE_URL (defaults: $DEFAULT_SMOKE_MODEL, $DEFAULT_SMOKE_API). To skip Step 5: SKIP_LLM_SMOKE=1"
281
+ stop_at "Step 5"
282
+ else
283
+ log "${BOLD}Step 5/5: LLM router smoke test${NC} (model=$SMOKE_MODEL) ..."
284
+ LLM_OK=false
285
+ LLM_OUT=""
286
+ if [ ! -f "$REPO_DIR/pyproject.toml" ]; then
287
+ fail "No pyproject.toml in repo — cannot run LLM smoke test"
288
+ stop_at "Step 5"
289
+ fi
290
+ RUN_PYTHON=()
291
+ if command -v uv &>/dev/null; then
292
+ RUN_PYTHON=(uv run python)
293
+ elif [ -x "$REPO_DIR/.venv/bin/python" ]; then
294
+ RUN_PYTHON=("$REPO_DIR/.venv/bin/python")
295
+ else
296
+ fail "Need uv on PATH or .venv/bin/python (run: uv sync)"
297
+ stop_at "Step 5"
298
+ fi
299
+ if [ "${#RUN_PYTHON[@]}" -gt 0 ]; then
300
+ LLM_OUT=$(cd "$REPO_DIR" && \
301
+ MODEL_NAME="$SMOKE_MODEL" API_BASE_URL="$SMOKE_API" HF_TOKEN="$HF_TOKEN" \
302
+ "${RUN_PYTHON[@]}" - <<'PY' 2>&1
303
+ import os, sys
304
+ from openai import OpenAI
305
+
306
+ def main() -> None:
307
+ client = OpenAI(
308
+ base_url=os.environ["API_BASE_URL"].rstrip("/"),
309
+ api_key=os.environ["HF_TOKEN"],
310
+ )
311
+ r = client.chat.completions.create(
312
+ model=os.environ["MODEL_NAME"],
313
+ messages=[{"role": "user", "content": "Reply with exactly: OK"}],
314
+ max_tokens=32,
315
+ temperature=0.0,
316
+ )
317
+ text = (r.choices[0].message.content or "").strip()
318
+ if not text:
319
+ print("empty completion", file=sys.stderr)
320
+ sys.exit(1)
321
+ print(text[:500])
322
+
323
+ if __name__ == "__main__":
324
+ main()
325
+ PY
326
+ ) && LLM_OK=true
327
+ fi
328
+
329
+ if [ "$LLM_OK" = true ]; then
330
+ pass "LLM router responded"
331
+ if [ -n "$LLM_OUT" ]; then
332
+ preview="${LLM_OUT:0:120}"
333
+ [ "${#LLM_OUT}" -gt 120 ] && preview="${preview}..."
334
+ log " completion: $preview"
335
+ fi
336
+ else
337
+ fail "LLM router smoke test failed"
338
+ printf "%s\n" "$LLM_OUT"
339
+ if [ "${LLM_SMOKE_OPTIONAL:-}" = "1" ]; then
340
+ hint "LLM_SMOKE_OPTIONAL=1 set — continuing (fix HF token / Inference Providers access for real inference runs)."
341
+ else
342
+ hint "403 often means the token cannot use Inference Providers for this account. See HF token settings or set LLM_SMOKE_OPTIONAL=1 to still pass Steps 1–4."
343
+ stop_at "Step 5"
344
+ fi
345
+ fi
346
+ fi
347
+
348
+ printf "\n"
349
+ printf "${BOLD}========================================${NC}\n"
350
+ printf "${GREEN}${BOLD} All checks passed!${NC}\n"
351
+ printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
352
+ printf "${BOLD}========================================${NC}\n"
353
+ printf "\n"
354
+
355
+ exit 0
visualize_optimal.py ADDED
@@ -0,0 +1,732 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Visualization of optimal posting strategies for the Viraltest environment.
3
+ Shows engagement multipliers, sleep effects, recommended posting windows,
4
+ and simulation results for all 61 test scenarios.
5
+ """
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ from matplotlib.patches import Rectangle
10
+ from matplotlib.colors import LinearSegmentedColormap
11
+ from collections import Counter
12
+ from typing import Callable, List, Tuple, Dict, Any
13
+
14
+ # Environment constants (matching viraltest_environment.py)
15
+ CONTENT_ENERGY_COST = {"reel": 0.25, "carousel": 0.20, "story": 0.08, "text_post": 0.06}
16
+ BASE_ENGAGEMENT = {"reel": 0.52, "carousel": 0.55, "story": 0.30, "text_post": 0.37}
17
+ REACH_MULT = {"reel": 2.25, "carousel": 1.0, "story": 0.5, "text_post": 0.44}
18
+ WEEKEND_PENALTY = 0.7
19
+ PEAK_DAYS = (1, 2, 3) # Tue, Wed, Thu
20
+
21
+ # Sleep constants
22
+ SLEEP_OPTIMAL_AWAKE = 14
23
+ SLEEP_HALFLIFE_HOURS = 10
24
+ SLEEP_MIN_QUALITY = 0.30
25
+
26
+
27
+ def get_hour_multiplier(hour: int, day: int) -> float:
28
+ """Calculate engagement multiplier for given hour and day."""
29
+ is_weekend = day >= 5
30
+ base = WEEKEND_PENALTY if is_weekend else 1.0
31
+
32
+ if 12 <= hour < 15 and day in PEAK_DAYS:
33
+ return base * 1.4
34
+ if 9 <= hour < 12:
35
+ return base * 1.3
36
+ if 18 <= hour < 20:
37
+ return base * 1.25
38
+ if 20 <= hour < 23:
39
+ return base * 1.1
40
+ if hour >= 23 or hour < 6:
41
+ return base * 0.5
42
+ return base * 0.8
43
+
44
+
45
+ def get_sleep_factor(hours_since_sleep: int) -> float:
46
+ """Calculate sleep quality factor (exponential decay)."""
47
+ if hours_since_sleep <= SLEEP_OPTIMAL_AWAKE:
48
+ return 1.0
49
+ hours_over = hours_since_sleep - SLEEP_OPTIMAL_AWAKE
50
+ factor = 0.5 ** (hours_over / SLEEP_HALFLIFE_HOURS)
51
+ return max(SLEEP_MIN_QUALITY, factor)
52
+
53
+
54
+ def create_visualizations():
55
+ """Generate all visualization plots."""
56
+ fig = plt.figure(figsize=(16, 14))
57
+ fig.suptitle('Viraltest Environment - Optimal Posting Strategy Guide',
58
+ fontsize=16, fontweight='bold', y=0.98)
59
+
60
+ # 1. Hour x Day Engagement Heatmap
61
+ ax1 = fig.add_subplot(2, 2, 1)
62
+ days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
63
+ hours = list(range(24))
64
+
65
+ heatmap_data = np.zeros((24, 7))
66
+ for d in range(7):
67
+ for h in range(24):
68
+ heatmap_data[h, d] = get_hour_multiplier(h, d)
69
+
70
+ im = ax1.imshow(heatmap_data, aspect='auto', cmap='RdYlGn', vmin=0.3, vmax=1.5)
71
+ ax1.set_xticks(range(7))
72
+ ax1.set_xticklabels(days)
73
+ ax1.set_yticks(range(0, 24, 2))
74
+ ax1.set_yticklabels([f'{h:02d}:00' for h in range(0, 24, 2)])
75
+ ax1.set_xlabel('Day of Week')
76
+ ax1.set_ylabel('Hour of Day')
77
+ ax1.set_title('Engagement Multiplier by Hour & Day', fontweight='bold')
78
+
79
+ # Add colorbar
80
+ cbar = plt.colorbar(im, ax=ax1, shrink=0.8)
81
+ cbar.set_label('Multiplier')
82
+
83
+ # Highlight peak zones
84
+ for d in PEAK_DAYS:
85
+ rect = Rectangle((d-0.5, 11.5), 1, 3, linewidth=2,
86
+ edgecolor='blue', facecolor='none', linestyle='--')
87
+ ax1.add_patch(rect)
88
+ ax1.text(2, 10.5, 'PEAK\nZONE', fontsize=8, color='blue', ha='center', fontweight='bold')
89
+
90
+ # 2. Content Type Comparison
91
+ ax2 = fig.add_subplot(2, 2, 2)
92
+ content_types = list(BASE_ENGAGEMENT.keys())
93
+ x = np.arange(len(content_types))
94
+ width = 0.25
95
+
96
+ base_vals = [BASE_ENGAGEMENT[ct] for ct in content_types]
97
+ reach_vals = [REACH_MULT[ct] for ct in content_types]
98
+ energy_vals = [CONTENT_ENERGY_COST[ct] for ct in content_types]
99
+
100
+ # Calculate effective engagement (base * reach)
101
+ effective = [BASE_ENGAGEMENT[ct] * REACH_MULT[ct] for ct in content_types]
102
+
103
+ bars1 = ax2.bar(x - width, base_vals, width, label='Base Engagement', color='steelblue')
104
+ bars2 = ax2.bar(x, reach_vals, width, label='Reach Multiplier', color='seagreen')
105
+ bars3 = ax2.bar(x + width, energy_vals, width, label='Energy Cost', color='coral')
106
+
107
+ ax2.set_xlabel('Content Type')
108
+ ax2.set_ylabel('Value')
109
+ ax2.set_title('Content Type Comparison', fontweight='bold')
110
+ ax2.set_xticks(x)
111
+ ax2.set_xticklabels(['Reel', 'Carousel', 'Story', 'Text Post'])
112
+ ax2.legend(loc='upper right')
113
+ ax2.grid(axis='y', alpha=0.3)
114
+
115
+ # Add efficiency annotation
116
+ efficiency = [(BASE_ENGAGEMENT[ct] * REACH_MULT[ct]) / CONTENT_ENERGY_COST[ct]
117
+ for ct in content_types]
118
+ for i, (ct, eff) in enumerate(zip(content_types, efficiency)):
119
+ ax2.annotate(f'Eff: {eff:.1f}', (i, max(base_vals[i], reach_vals[i], energy_vals[i]) + 0.1),
120
+ ha='center', fontsize=8, color='purple')
121
+
122
+ # 3. Sleep Quality Decay Curve
123
+ ax3 = fig.add_subplot(2, 2, 3)
124
+ hours_awake = np.linspace(0, 40, 200)
125
+ sleep_quality = [get_sleep_factor(int(h)) for h in hours_awake]
126
+
127
+ ax3.plot(hours_awake, sleep_quality, 'b-', linewidth=2, label='Quality Factor')
128
+ ax3.axvline(x=SLEEP_OPTIMAL_AWAKE, color='green', linestyle='--',
129
+ label=f'Optimal threshold ({SLEEP_OPTIMAL_AWAKE}h)')
130
+ ax3.axhline(y=0.5, color='orange', linestyle=':', alpha=0.7,
131
+ label='50% quality (24h awake)')
132
+ ax3.axhline(y=SLEEP_MIN_QUALITY, color='red', linestyle=':', alpha=0.7,
133
+ label=f'Floor ({SLEEP_MIN_QUALITY*100:.0f}%)')
134
+
135
+ # Fill regions
136
+ ax3.fill_between(hours_awake, sleep_quality, alpha=0.3)
137
+ ax3.axvspan(0, SLEEP_OPTIMAL_AWAKE, alpha=0.1, color='green', label='_No fatigue')
138
+ ax3.axvspan(SLEEP_OPTIMAL_AWAKE, 24, alpha=0.1, color='yellow')
139
+ ax3.axvspan(24, 40, alpha=0.1, color='red')
140
+
141
+ ax3.set_xlabel('Hours Since Sleep')
142
+ ax3.set_ylabel('Quality Multiplier')
143
+ ax3.set_title('Sleep Deprivation Effect (Exponential Decay)', fontweight='bold')
144
+ ax3.set_xlim(0, 40)
145
+ ax3.set_ylim(0, 1.1)
146
+ ax3.legend(loc='upper right', fontsize=8)
147
+ ax3.grid(alpha=0.3)
148
+
149
+ # Add annotations
150
+ ax3.annotate('No impact', xy=(7, 1.02), fontsize=9, color='green')
151
+ ax3.annotate('Mild fatigue', xy=(18, 0.85), fontsize=9, color='orange')
152
+ ax3.annotate('Severe', xy=(30, 0.4), fontsize=9, color='red')
153
+
154
+ # 4. Optimal Daily Schedule
155
+ ax4 = fig.add_subplot(2, 2, 4)
156
+
157
+ # Create a 24-hour timeline
158
+ hours_day = np.arange(24)
159
+
160
+ # Define activity zones
161
+ sleep_zone = [(0, 7)] # Sleep 0-7
162
+ low_zone = [(7, 9), (21, 24)] # Low engagement
163
+ medium_zone = [(9, 12), (15, 18), (20, 21)] # Medium
164
+ peak_zone = [(12, 15), (18, 20)] # Peak
165
+
166
+ # Plot colored bands
167
+ for start, end in sleep_zone:
168
+ ax4.axvspan(start, end, alpha=0.3, color='navy', label='Sleep (rest)' if start == 0 else '')
169
+ for start, end in low_zone:
170
+ ax4.axvspan(start, end, alpha=0.3, color='gray', label='Low engagement' if start == 7 else '')
171
+ for start, end in medium_zone:
172
+ ax4.axvspan(start, end, alpha=0.3, color='yellow', label='Medium' if start == 9 else '')
173
+ for start, end in peak_zone:
174
+ ax4.axvspan(start, end, alpha=0.4, color='green', label='Peak hours' if start == 12 else '')
175
+
176
+ # Plot engagement curve for peak weekday
177
+ engagement_curve = [get_hour_multiplier(h, 2) for h in hours_day] # Wednesday
178
+ ax4.plot(hours_day, engagement_curve, 'k-', linewidth=2, marker='o', markersize=4)
179
+
180
+ # Add recommended actions
181
+ actions = [
182
+ (3, 0.3, 'SLEEP', 'white'),
183
+ (10, 1.35, 'POST #1', 'darkgreen'),
184
+ (13, 1.45, 'PEAK POST', 'darkgreen'),
185
+ (19, 1.3, 'POST #2', 'darkgreen'),
186
+ (16, 0.85, 'Rest/Create', 'gray'),
187
+ ]
188
+ for x, y, text, color in actions:
189
+ ax4.annotate(text, (x, y), fontsize=9, fontweight='bold',
190
+ color=color, ha='center',
191
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
192
+
193
+ ax4.set_xlabel('Hour of Day')
194
+ ax4.set_ylabel('Engagement Multiplier')
195
+ ax4.set_title('Optimal Daily Schedule (Peak Weekday: Tue-Thu)', fontweight='bold')
196
+ ax4.set_xlim(0, 24)
197
+ ax4.set_ylim(0, 1.6)
198
+ ax4.set_xticks(range(0, 25, 3))
199
+ ax4.set_xticklabels([f'{h:02d}:00' for h in range(0, 25, 3)])
200
+ ax4.legend(loc='lower right', fontsize=8)
201
+ ax4.grid(alpha=0.3)
202
+
203
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
204
+
205
+ # Save figure
206
+ plt.savefig('optimal_posting_guide.png', dpi=150, bbox_inches='tight',
207
+ facecolor='white', edgecolor='none')
208
+ print("Saved: optimal_posting_guide.png")
209
+
210
+ # Create second figure with strategy summary
211
+ fig2, axes = plt.subplots(1, 2, figsize=(14, 6))
212
+ fig2.suptitle('Strategy Recommendations', fontsize=14, fontweight='bold')
213
+
214
+ # Left: Energy vs Posts tradeoff
215
+ ax5 = axes[0]
216
+ posts_per_day = np.arange(0, 6)
217
+
218
+ # Calculate energy remaining after N posts of each type
219
+ for ct in content_types:
220
+ cost = CONTENT_ENERGY_COST[ct]
221
+ energy_remaining = [max(0, 1.0 - n * cost) for n in posts_per_day]
222
+ ax5.plot(posts_per_day, energy_remaining, '-o', label=ct.replace('_', ' ').title(), linewidth=2)
223
+
224
+ ax5.axhline(y=0.4, color='orange', linestyle='--', label='Safe threshold')
225
+ ax5.axhline(y=0.2, color='red', linestyle='--', label='Burnout risk')
226
+
227
+ ax5.set_xlabel('Posts Per Day')
228
+ ax5.set_ylabel('Energy Remaining')
229
+ ax5.set_title('Energy Drain by Content Type', fontweight='bold')
230
+ ax5.legend(loc='upper right')
231
+ ax5.grid(alpha=0.3)
232
+ ax5.set_xlim(0, 5)
233
+ ax5.set_ylim(0, 1.1)
234
+
235
+ # Right: Effective Engagement Score
236
+ ax6 = axes[1]
237
+
238
+ # Calculate total effective engagement for different strategies
239
+ strategies = [
240
+ ('2 Reels/day', 2 * BASE_ENGAGEMENT['reel'] * REACH_MULT['reel'], 2 * CONTENT_ENERGY_COST['reel']),
241
+ ('2 Carousels/day', 2 * BASE_ENGAGEMENT['carousel'] * REACH_MULT['carousel'], 2 * CONTENT_ENERGY_COST['carousel']),
242
+ ('1 Reel + 1 Carousel', BASE_ENGAGEMENT['reel'] * REACH_MULT['reel'] + BASE_ENGAGEMENT['carousel'] * REACH_MULT['carousel'],
243
+ CONTENT_ENERGY_COST['reel'] + CONTENT_ENERGY_COST['carousel']),
244
+ ('3 Stories/day', 3 * BASE_ENGAGEMENT['story'] * REACH_MULT['story'], 3 * CONTENT_ENERGY_COST['story']),
245
+ ('4 Text Posts/day', 4 * BASE_ENGAGEMENT['text_post'] * REACH_MULT['text_post'], 4 * CONTENT_ENERGY_COST['text_post']),
246
+ ]
247
+
248
+ names = [s[0] for s in strategies]
249
+ engagement = [s[1] for s in strategies]
250
+ energy_cost = [s[2] for s in strategies]
251
+ efficiency = [e/c for e, c in zip(engagement, energy_cost)]
252
+
253
+ x = np.arange(len(names))
254
+ width = 0.35
255
+
256
+ bars1 = ax6.bar(x - width/2, engagement, width, label='Total Engagement', color='steelblue')
257
+ bars2 = ax6.bar(x + width/2, energy_cost, width, label='Energy Cost', color='coral')
258
+
259
+ ax6.set_ylabel('Value')
260
+ ax6.set_title('Daily Strategy Comparison', fontweight='bold')
261
+ ax6.set_xticks(x)
262
+ ax6.set_xticklabels(names, rotation=15, ha='right')
263
+ ax6.legend()
264
+ ax6.grid(axis='y', alpha=0.3)
265
+
266
+ # Add efficiency labels
267
+ for i, eff in enumerate(efficiency):
268
+ ax6.annotate(f'Eff: {eff:.1f}', (i, max(engagement[i], energy_cost[i]) + 0.1),
269
+ ha='center', fontsize=9, color='green', fontweight='bold')
270
+
271
+ plt.tight_layout(rect=[0, 0, 1, 0.95])
272
+ plt.savefig('strategy_comparison.png', dpi=150, bbox_inches='tight',
273
+ facecolor='white', edgecolor='none')
274
+ print("Saved: strategy_comparison.png")
275
+
276
+ plt.show()
277
+
278
+
279
+ def print_summary():
280
+ """Print text summary of optimal strategies."""
281
+ print("\n" + "="*70)
282
+ print("OPTIMAL POSTING STRATEGY SUMMARY")
283
+ print("="*70)
284
+
285
+ print("\n📅 BEST DAYS:")
286
+ print(" • Tuesday, Wednesday, Thursday (peak engagement)")
287
+ print(" • Weekend has 30% penalty")
288
+
289
+ print("\n⏰ BEST HOURS:")
290
+ print(" • 12:00-15:00 on Tue/Wed/Thu (+40% engagement)")
291
+ print(" • 09:00-12:00 any weekday (+30%)")
292
+ print(" • 18:00-20:00 evening (+25%)")
293
+ print(" • AVOID: 23:00-06:00 (-50%)")
294
+
295
+ print("\n📱 CONTENT TYPES (by reach efficiency):")
296
+ for ct in ['reel', 'carousel', 'text_post', 'story']:
297
+ eff = (BASE_ENGAGEMENT[ct] * REACH_MULT[ct]) / CONTENT_ENERGY_COST[ct]
298
+ print(f" • {ct.replace('_', ' ').title():12} - "
299
+ f"Reach: {REACH_MULT[ct]:.2f}x, Energy: {CONTENT_ENERGY_COST[ct]:.0%}, "
300
+ f"Efficiency: {eff:.1f}")
301
+
302
+ print("\n😴 SLEEP SCHEDULE:")
303
+ print(f" • No quality impact for first {SLEEP_OPTIMAL_AWAKE} hours awake")
304
+ print(" • Quality halves every 10 hours beyond that")
305
+ print(" • At 24h awake: 50% quality")
306
+ print(" • Rest during 23:00-07:00 to maintain quality")
307
+
308
+ print("\n🎯 RECOMMENDED DAILY ROUTINE:")
309
+ print(" 07:00 - Wake up (2h buffer before posting)")
310
+ print(" 09:00-12:00 - Post #1 (morning peak)")
311
+ print(" 12:00-15:00 - Post #2 (midday peak on Tue-Thu)")
312
+ print(" 15:00-18:00 - Rest or create content")
313
+ print(" 18:00-20:00 - Optional Post #3 (evening)")
314
+ print(" 23:00 - Sleep (rest actions)")
315
+
316
+ print("\n⚡ ENERGY MANAGEMENT:")
317
+ print(" • Stay above 0.4 energy (quality drops below 0.5)")
318
+ print(" • 2 reels/day = 50% energy (sustainable)")
319
+ print(" • Use content queue for 50% energy discount")
320
+ print(" • Rest recovers 12% energy + 2h sleep credit")
321
+
322
+ print("\n" + "="*70)
323
+
324
+
325
+ def run_all_scenarios() -> List[Dict[str, Any]]:
326
+ """Run all 61 scenarios and collect results."""
327
+ from server.viraltest_environment import ViraltestEnvironment
328
+ from models import ViraltestAction
329
+ from test_scenarios import SCENARIOS, TASKS
330
+
331
+ # Import reset functions
332
+ from test_scenarios import (
333
+ _reset_smart_state, _reset_queue_state, _reset_burst_state,
334
+ _reset_tag_explorer_state, _reset_balanced_state, _reset_queue_heavy_state,
335
+ _reset_alternating_state, _reset_content_creator_state, _reset_nap_state
336
+ )
337
+
338
+ def _reset_all():
339
+ _reset_smart_state()
340
+ _reset_queue_state()
341
+ _reset_burst_state()
342
+ _reset_tag_explorer_state()
343
+ _reset_balanced_state()
344
+ _reset_queue_heavy_state()
345
+ _reset_alternating_state()
346
+ _reset_content_creator_state()
347
+ _reset_nap_state()
348
+
349
+ SEED = 42
350
+ results = []
351
+
352
+ for scenario_name, agent_fn, description in SCENARIOS:
353
+ scenario_results = {
354
+ 'name': scenario_name,
355
+ 'description': description,
356
+ 'scores': {},
357
+ 'details': {}
358
+ }
359
+
360
+ for task in TASKS:
361
+ _reset_all()
362
+ env = ViraltestEnvironment()
363
+ obs = env.reset(task=task, seed=SEED)
364
+
365
+ rewards = []
366
+ actions = []
367
+ min_energy = 1.0
368
+ max_sleep_debt = 0.0
369
+ burned_out = False
370
+
371
+ for step in range(1, 169):
372
+ action = agent_fn(obs, step)
373
+ obs = env.step(action)
374
+ r = obs.reward if obs.reward is not None else 0.0
375
+ rewards.append(r)
376
+ actions.append(action.action_type)
377
+ min_energy = min(min_energy, obs.creator_energy)
378
+ max_sleep_debt = max(max_sleep_debt, obs.sleep_debt)
379
+ if obs.done and obs.creator_energy <= 0:
380
+ burned_out = True
381
+ if obs.done:
382
+ break
383
+
384
+ score = (obs.metadata or {}).get("grader_score", 0.0)
385
+ action_counts = Counter(actions)
386
+
387
+ scenario_results['scores'][task] = score
388
+ scenario_results['details'][task] = {
389
+ 'steps': len(rewards),
390
+ 'burned_out': burned_out,
391
+ 'min_energy': min_energy,
392
+ 'max_sleep_debt': max_sleep_debt,
393
+ 'final_energy': obs.creator_energy,
394
+ 'followers': obs.follower_count,
395
+ 'follower_delta': obs.follower_count - 10000,
396
+ 'engagement_rate': obs.engagement_rate,
397
+ 'posts': action_counts.get('post', 0),
398
+ 'rests': action_counts.get('rest', 0),
399
+ 'creates': action_counts.get('create_content', 0),
400
+ 'total_reward': sum(rewards),
401
+ }
402
+
403
+ results.append(scenario_results)
404
+
405
+ return results
406
+
407
+
408
+ def create_scenario_visualizations(results: List[Dict[str, Any]]):
409
+ """Create visualizations for all scenario results."""
410
+
411
+ # Extract data
412
+ names = [r['name'].replace('SCENARIO ', 'S') for r in results]
413
+ short_names = [n.split(':')[0] for n in names] # Just "S1", "S2", etc.
414
+
415
+ engage_scores = [r['scores']['weekly_engage'] for r in results]
416
+ strategic_scores = [r['scores']['weekly_strategic'] for r in results]
417
+ competitive_scores = [r['scores']['weekly_competitive'] for r in results]
418
+
419
+ # Figure 1: Score comparison bar chart
420
+ fig1, axes = plt.subplots(3, 1, figsize=(18, 12))
421
+ fig1.suptitle('All 61 Scenarios - Performance Scores by Task', fontsize=14, fontweight='bold')
422
+
423
+ x = np.arange(len(results))
424
+
425
+ # Color based on score
426
+ def get_colors(scores):
427
+ colors = []
428
+ for s in scores:
429
+ if s >= 0.7:
430
+ colors.append('green')
431
+ elif s >= 0.4:
432
+ colors.append('orange')
433
+ elif s > 0:
434
+ colors.append('coral')
435
+ else:
436
+ colors.append('red')
437
+ return colors
438
+
439
+ # Weekly Engage
440
+ ax1 = axes[0]
441
+ ax1.bar(x, engage_scores, color=get_colors(engage_scores), edgecolor='black', linewidth=0.5)
442
+ ax1.set_ylabel('Score')
443
+ ax1.set_title('Weekly Engage Task', fontweight='bold')
444
+ ax1.set_xticks(x)
445
+ ax1.set_xticklabels(short_names, rotation=90, fontsize=7)
446
+ ax1.axhline(y=0.7, color='green', linestyle='--', alpha=0.5, label='Good (0.7+)')
447
+ ax1.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5, label='Medium (0.4+)')
448
+ ax1.set_ylim(0, 1.1)
449
+ ax1.legend(loc='upper right')
450
+ ax1.grid(axis='y', alpha=0.3)
451
+
452
+ # Weekly Strategic
453
+ ax2 = axes[1]
454
+ ax2.bar(x, strategic_scores, color=get_colors(strategic_scores), edgecolor='black', linewidth=0.5)
455
+ ax2.set_ylabel('Score')
456
+ ax2.set_title('Weekly Strategic Task', fontweight='bold')
457
+ ax2.set_xticks(x)
458
+ ax2.set_xticklabels(short_names, rotation=90, fontsize=7)
459
+ ax2.axhline(y=0.7, color='green', linestyle='--', alpha=0.5)
460
+ ax2.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5)
461
+ ax2.set_ylim(0, 1.1)
462
+ ax2.grid(axis='y', alpha=0.3)
463
+
464
+ # Weekly Competitive
465
+ ax3 = axes[2]
466
+ ax3.bar(x, competitive_scores, color=get_colors(competitive_scores), edgecolor='black', linewidth=0.5)
467
+ ax3.set_ylabel('Score')
468
+ ax3.set_title('Weekly Competitive Task', fontweight='bold')
469
+ ax3.set_xticks(x)
470
+ ax3.set_xticklabels(short_names, rotation=90, fontsize=7)
471
+ ax3.axhline(y=0.7, color='green', linestyle='--', alpha=0.5)
472
+ ax3.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5)
473
+ ax3.set_ylim(0, 1.1)
474
+ ax3.grid(axis='y', alpha=0.3)
475
+
476
+ plt.tight_layout(rect=[0, 0, 1, 0.97])
477
+ plt.savefig('scenario_scores.png', dpi=150, bbox_inches='tight', facecolor='white')
478
+ print("Saved: scenario_scores.png")
479
+
480
+ # Figure 2: Top 15 scenarios
481
+ fig2, ax = plt.subplots(figsize=(14, 8))
482
+ fig2.suptitle('Top 15 Scenarios by Average Score', fontsize=14, fontweight='bold')
483
+
484
+ # Calculate average scores
485
+ avg_scores = [(r['name'],
486
+ (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3,
487
+ r['scores']['weekly_engage'],
488
+ r['scores']['weekly_strategic'],
489
+ r['scores']['weekly_competitive'])
490
+ for r in results]
491
+ avg_scores.sort(key=lambda x: x[1], reverse=True)
492
+ top15 = avg_scores[:15]
493
+
494
+ y = np.arange(len(top15))
495
+ width = 0.25
496
+
497
+ names_top = [t[0].replace('SCENARIO ', '').split(':')[1].strip()[:25] for t in top15]
498
+ engage_top = [t[2] for t in top15]
499
+ strategic_top = [t[3] for t in top15]
500
+ competitive_top = [t[4] for t in top15]
501
+
502
+ bars1 = ax.barh(y + width, engage_top, width, label='Engage', color='steelblue')
503
+ bars2 = ax.barh(y, strategic_top, width, label='Strategic', color='seagreen')
504
+ bars3 = ax.barh(y - width, competitive_top, width, label='Competitive', color='coral')
505
+
506
+ ax.set_xlabel('Score')
507
+ ax.set_ylabel('Scenario')
508
+ ax.set_yticks(y)
509
+ ax.set_yticklabels(names_top)
510
+ ax.legend(loc='lower right')
511
+ ax.set_xlim(0, 1.1)
512
+ ax.grid(axis='x', alpha=0.3)
513
+ ax.invert_yaxis()
514
+
515
+ # Add average score labels
516
+ for i, (name, avg, e, s, c) in enumerate(top15):
517
+ ax.text(1.02, i, f'Avg: {avg:.2f}', va='center', fontsize=9, fontweight='bold')
518
+
519
+ plt.tight_layout(rect=[0, 0, 0.95, 0.97])
520
+ plt.savefig('top_scenarios.png', dpi=150, bbox_inches='tight', facecolor='white')
521
+ print("Saved: top_scenarios.png")
522
+
523
+ # Figure 3: Sleep-related scenarios comparison
524
+ fig3, axes = plt.subplots(1, 2, figsize=(14, 6))
525
+ fig3.suptitle('Sleep-Related Scenarios Analysis', fontsize=14, fontweight='bold')
526
+
527
+ sleep_scenarios = [r for r in results if any(kw in r['name'].lower() or kw in r['description'].lower()
528
+ for kw in ['sleep', 'night', 'rest', 'marathon', 'nap'])]
529
+
530
+ # Left: Scores comparison
531
+ ax_left = axes[0]
532
+ sleep_names = [r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:20] for r in sleep_scenarios]
533
+ sleep_engage = [r['scores']['weekly_engage'] for r in sleep_scenarios]
534
+ sleep_strategic = [r['scores']['weekly_strategic'] for r in sleep_scenarios]
535
+ sleep_competitive = [r['scores']['weekly_competitive'] for r in sleep_scenarios]
536
+
537
+ y = np.arange(len(sleep_scenarios))
538
+ width = 0.25
539
+
540
+ ax_left.barh(y + width, sleep_engage, width, label='Engage', color='steelblue')
541
+ ax_left.barh(y, sleep_strategic, width, label='Strategic', color='seagreen')
542
+ ax_left.barh(y - width, sleep_competitive, width, label='Competitive', color='coral')
543
+
544
+ ax_left.set_xlabel('Score')
545
+ ax_left.set_ylabel('Scenario')
546
+ ax_left.set_yticks(y)
547
+ ax_left.set_yticklabels(sleep_names)
548
+ ax_left.legend(loc='lower right')
549
+ ax_left.set_xlim(0, 1.1)
550
+ ax_left.grid(axis='x', alpha=0.3)
551
+ ax_left.set_title('Sleep Scenario Scores')
552
+ ax_left.invert_yaxis()
553
+
554
+ # Right: Sleep debt and energy analysis
555
+ ax_right = axes[1]
556
+
557
+ # Get sleep debt and min energy for strategic task
558
+ sleep_debt_vals = [r['details']['weekly_strategic']['max_sleep_debt'] for r in sleep_scenarios]
559
+ min_energy_vals = [r['details']['weekly_strategic']['min_energy'] for r in sleep_scenarios]
560
+ burned_out = [r['details']['weekly_strategic']['burned_out'] for r in sleep_scenarios]
561
+
562
+ x = np.arange(len(sleep_scenarios))
563
+ width = 0.35
564
+
565
+ bars1 = ax_right.bar(x - width/2, sleep_debt_vals, width, label='Max Sleep Debt', color='purple', alpha=0.7)
566
+ bars2 = ax_right.bar(x + width/2, min_energy_vals, width, label='Min Energy', color='orange', alpha=0.7)
567
+
568
+ # Mark burned out scenarios
569
+ for i, bo in enumerate(burned_out):
570
+ if bo:
571
+ ax_right.annotate('💀', (i, max(sleep_debt_vals[i], min_energy_vals[i]) + 0.05),
572
+ ha='center', fontsize=12)
573
+
574
+ ax_right.set_xlabel('Scenario')
575
+ ax_right.set_ylabel('Value')
576
+ ax_right.set_xticks(x)
577
+ ax_right.set_xticklabels(sleep_names, rotation=45, ha='right', fontsize=8)
578
+ ax_right.legend(loc='upper right')
579
+ ax_right.set_ylim(0, 1.2)
580
+ ax_right.grid(axis='y', alpha=0.3)
581
+ ax_right.set_title('Sleep Debt vs Min Energy (💀 = burned out)')
582
+
583
+ plt.tight_layout(rect=[0, 0, 1, 0.95])
584
+ plt.savefig('sleep_scenarios.png', dpi=150, bbox_inches='tight', facecolor='white')
585
+ print("Saved: sleep_scenarios.png")
586
+
587
+ # Figure 4: Scenario categories heatmap
588
+ fig4, ax = plt.subplots(figsize=(16, 10))
589
+ fig4.suptitle('All Scenarios - Score Heatmap', fontsize=14, fontweight='bold')
590
+
591
+ # Create matrix for heatmap
592
+ all_scores = np.array([[r['scores']['weekly_engage'],
593
+ r['scores']['weekly_strategic'],
594
+ r['scores']['weekly_competitive']] for r in results])
595
+
596
+ im = ax.imshow(all_scores, aspect='auto', cmap='RdYlGn', vmin=0, vmax=1)
597
+
598
+ ax.set_yticks(range(len(results)))
599
+ ax.set_yticklabels([r['name'].replace('SCENARIO ', '') for r in results], fontsize=7)
600
+ ax.set_xticks([0, 1, 2])
601
+ ax.set_xticklabels(['Engage', 'Strategic', 'Competitive'])
602
+
603
+ # Add score text
604
+ for i in range(len(results)):
605
+ for j in range(3):
606
+ score = all_scores[i, j]
607
+ color = 'white' if score < 0.5 else 'black'
608
+ ax.text(j, i, f'{score:.2f}', ha='center', va='center', fontsize=6, color=color)
609
+
610
+ cbar = plt.colorbar(im, ax=ax, shrink=0.8)
611
+ cbar.set_label('Score')
612
+
613
+ plt.tight_layout(rect=[0, 0, 1, 0.97])
614
+ plt.savefig('scenario_heatmap.png', dpi=150, bbox_inches='tight', facecolor='white')
615
+ print("Saved: scenario_heatmap.png")
616
+
617
+ # Figure 5: Action distribution for top performers
618
+ fig5, axes = plt.subplots(2, 3, figsize=(15, 10))
619
+ fig5.suptitle('Action Distribution - Top 6 Strategies', fontsize=14, fontweight='bold')
620
+
621
+ top6 = avg_scores[:6]
622
+ top6_results = [r for r in results if r['name'] in [t[0] for t in top6]]
623
+ top6_results.sort(key=lambda r: next(t[1] for t in top6 if t[0] == r['name']), reverse=True)
624
+
625
+ for idx, r in enumerate(top6_results):
626
+ ax = axes[idx // 3, idx % 3]
627
+ details = r['details']['weekly_strategic']
628
+
629
+ actions = ['Posts', 'Rests', 'Creates']
630
+ counts = [details['posts'], details['rests'], details['creates']]
631
+ colors = ['steelblue', 'seagreen', 'coral']
632
+
633
+ wedges, texts, autotexts = ax.pie(counts, labels=actions, autopct='%1.0f%%',
634
+ colors=colors, startangle=90)
635
+ ax.set_title(r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:25], fontsize=10)
636
+
637
+ # Add stats
638
+ avg = (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3
639
+ ax.text(0, -1.3, f"Avg Score: {avg:.2f} | Energy: {details['final_energy']:.2f}",
640
+ ha='center', fontsize=8)
641
+
642
+ plt.tight_layout(rect=[0, 0.02, 1, 0.95])
643
+ plt.savefig('top_actions.png', dpi=150, bbox_inches='tight', facecolor='white')
644
+ print("Saved: top_actions.png")
645
+
646
+ return results
647
+
648
+
649
+ def print_scenario_summary(results: List[Dict[str, Any]]):
650
+ """Print summary table of all scenarios."""
651
+ print("\n" + "="*100)
652
+ print("ALL 61 SCENARIOS - SIMULATION RESULTS")
653
+ print("="*100)
654
+
655
+ # Calculate averages and sort
656
+ scored_results = []
657
+ for r in results:
658
+ avg = (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3
659
+ scored_results.append((r, avg))
660
+ scored_results.sort(key=lambda x: x[1], reverse=True)
661
+
662
+ print(f"\n{'Rank':<5} {'Scenario':<45} {'Engage':>8} {'Strategic':>10} {'Competitive':>12} {'Avg':>8}")
663
+ print("-" * 100)
664
+
665
+ for rank, (r, avg) in enumerate(scored_results, 1):
666
+ name = r['name'].replace('SCENARIO ', '')[:43]
667
+ e = r['scores']['weekly_engage']
668
+ s = r['scores']['weekly_strategic']
669
+ c = r['scores']['weekly_competitive']
670
+
671
+ # Add indicator for top performers
672
+ indicator = "🏆" if rank <= 3 else "⭐" if rank <= 10 else " "
673
+ print(f"{indicator}{rank:<3} {name:<45} {e:>8.4f} {s:>10.4f} {c:>12.4f} {avg:>8.4f}")
674
+
675
+ print("\n" + "="*100)
676
+ print("TOP 10 DETAILED ANALYSIS")
677
+ print("="*100)
678
+
679
+ for rank, (r, avg) in enumerate(scored_results[:10], 1):
680
+ print(f"\n#{rank} {r['name']}")
681
+ print(f" Description: {r['description']}")
682
+
683
+ for task in ['weekly_engage', 'weekly_strategic', 'weekly_competitive']:
684
+ d = r['details'][task]
685
+ print(f" {task}: Score={r['scores'][task]:.4f} | "
686
+ f"Posts={d['posts']} Rests={d['rests']} Creates={d['creates']} | "
687
+ f"Energy={d['final_energy']:.2f} | Followers={d['follower_delta']:+d}")
688
+
689
+ # Sleep scenario analysis
690
+ print("\n" + "="*100)
691
+ print("SLEEP MECHANICS ANALYSIS")
692
+ print("="*100)
693
+
694
+ sleep_keywords = ['sleep', 'night', 'rest', 'marathon', 'nap', 'awake']
695
+ sleep_results = [(r, (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3)
696
+ for r in results
697
+ if any(kw in r['name'].lower() or kw in r['description'].lower() for kw in sleep_keywords)]
698
+ sleep_results.sort(key=lambda x: x[1], reverse=True)
699
+
700
+ print(f"\n{'Scenario':<40} {'Avg Score':>10} {'Max Sleep Debt':>15} {'Burned Out':>12}")
701
+ print("-" * 80)
702
+
703
+ for r, avg in sleep_results:
704
+ name = r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:38]
705
+ debt = r['details']['weekly_strategic']['max_sleep_debt']
706
+ bo = "YES 💀" if r['details']['weekly_strategic']['burned_out'] else "No"
707
+ print(f"{name:<40} {avg:>10.4f} {debt:>15.3f} {bo:>12}")
708
+
709
+ print("\n" + "="*100)
710
+
711
+
712
+ if __name__ == "__main__":
713
+ print("Generating optimal posting visualizations...")
714
+ print_summary()
715
+ create_visualizations()
716
+
717
+ print("\n" + "="*70)
718
+ print("Running all 61 scenarios...")
719
+ print("="*70)
720
+
721
+ results = run_all_scenarios()
722
+ print_scenario_summary(results)
723
+ create_scenario_visualizations(results)
724
+
725
+ print("\n✅ All visualizations generated!")
726
+ print(" - optimal_posting_guide.png")
727
+ print(" - strategy_comparison.png")
728
+ print(" - scenario_scores.png")
729
+ print(" - top_scenarios.png")
730
+ print(" - sleep_scenarios.png")
731
+ print(" - scenario_heatmap.png")
732
+ print(" - top_actions.png")