vaibhav12332112312 commited on
Commit
9c3eab8
·
1 Parent(s): b9165e0

add: viraltest code (server, models, inference)

Browse files
.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
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 CHANGED
@@ -1,12 +1,215 @@
1
  ---
2
- title: Train Bhai Train
3
- emoji: 🦀
4
- colorFrom: blue
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
  pinned: false
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 v2 World-Modeling RL Environment for Instagram Strategy
15
+
16
+ > **Theme #3.1 — Professional Tasks (World Modeling)**
17
+ > An [OpenEnv](https://github.com/meta-pytorch/OpenEnv) environment where an LLM agent manages an Instagram creator account over 30 simulated days, discovering the world through tools rather than being told the rules.
18
+
19
+ ## What this teaches the LLM
20
+
21
+ | Capability | How the environment tests it |
22
+ |---|---|
23
+ | **Tool discovery & orchestration** | 8 discoverable tools (`query_trends`, `query_competitor`, `predict_engagement`...). Agent must call `GET /tools` to learn what's available. |
24
+ | **Persistent world model** | 30-day horizon. Multi-episode brand chain carries state across months. |
25
+ | **Belief tracking** | `notes` field persists hypotheses day-to-day. Agent must update beliefs from tool results. |
26
+ | **Causal reasoning** | `coach_feedback` returns counterfactual delta (your plan vs. heatmap-optimal). `predict_engagement` lets agent test hypotheses before committing. |
27
+ | **Partial observability** | Default observation is sparse: energy, followers, reward. Rich data (trends, competitors, tags) only via tools. |
28
+ | **Multi-step workflow** | Per day: discover → query → draft → predict → commit → reply → learn from feedback. |
29
+
30
+ ## Why this matters
31
+
32
+ The $250B creator economy ([Goldman Sachs, 2025](https://www.goldmansachs.com/insights/articles/the-creator-economy-could-approach-half-a-trillion-dollars-by-2027)) has 67M creators, but 73% experience burnout ([Awin, 2024](https://www.prweb.com/releases/a-majority-of-content-creators-and-influencers-struggle-with-burnout-as-concerns-for-ai-begin-to-surface-according-to-a-new-awin-group-survey-research-302257152.html)). This environment turns the posting-vs-burnout tradeoff into a reproducible simulation calibrated against 10+ verifiable sources.
33
+
34
+ ## Quick Start
35
+
36
+ ```python
37
+ import asyncio
38
+ from viraltest import ViraltestAction, ViraltestEnv
39
+ from viraltest.models import ToolCall
40
+
41
+ async def main():
42
+ env = ViraltestEnv(base_url="http://localhost:8000")
43
+ try:
44
+ result = await env.reset(task="monthly_strategic")
45
+ action = ViraltestAction(
46
+ tool_calls=[
47
+ ToolCall(name="query_trends", arguments={"niche": "tech"}),
48
+ ],
49
+ scheduled_actions=[
50
+ {"hour": 12, "action_type": "post", "content_type": "reel",
51
+ "topic": "AI tools", "tags": ["ai", "coding"], "intent": "watch_bait"},
52
+ ],
53
+ notes="Day 1: querying trends to establish baseline.",
54
+ )
55
+ result = await env.step(action)
56
+ print(result.observation.engagement_signals)
57
+ finally:
58
+ await env.close()
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ ## Simulation mechanics
64
+
65
+ ### Engagement signals (Mosseri Jan-2025)
66
+
67
+ Instagram's head confirmed the top-3 ranking signals. Our reward decomposes engagement accordingly:
68
+
69
+ | Signal | Weight | Best format | Source |
70
+ |--------|--------|-------------|--------|
71
+ | Watch time | 0.40 | Reels | Mosseri Jan-2025 |
72
+ | Sends per reach | 0.30 | Stories | Mosseri Jan-2025 |
73
+ | Saves | 0.20 | Carousels | Mosseri Jan-2025 |
74
+ | Likes per reach | 0.10 | Text posts | Mosseri Jan-2025 |
75
+
76
+ ### Hour heatmap
77
+
78
+ 7×24 multiplier grid from [Buffer 9.6M posts](https://buffer.com/resources/when-is-the-best-time-to-post-on-instagram) cross-validated with [Sprout Social 2B engagements](https://sproutsocial.com/insights/best-times-to-post-on-social-media/).
79
+
80
+ ### Sleep model
81
+
82
+ Piecewise-linear from [Van Dongen et al. 2003](https://pubmed.ncbi.nlm.nih.gov/12683469) (*Sleep*, PMID 12683469): no quality loss below 16h awake, then 6.25% per hour, floor at 30%.
83
+
84
+ ### Audience fatigue
85
+
86
+ Tiered from [Buffer 2.1M study](https://buffer.com/resources/how-often-to-post-on-instagram/): 2 posts/day=1.0×, 3=0.75×, 4=0.50×, 5+=0.25×. Weekly cap at 7 posts → 0.75×.
87
+
88
+ ## Tasks and graders (30 steps each)
89
+
90
+ | Task | Difficulty | Grader focus |
91
+ |------|-----------|--------------|
92
+ | `monthly_engage` | Easier | Total engagement vs theoretical max; burnout penalty |
93
+ | `monthly_strategic` | Medium | + tag discovery/exploitation + energy + consistency |
94
+ | `monthly_competitive` | Hard | + growth vs competitors + differentiation + content diversity |
95
+
96
+ ## Regulator/Judge Mode (per-day audit)
97
+
98
+ Every day the env emits a deterministic, explainable `JudgeReport` on the observation:
99
+
100
+ ```python
101
+ JudgeReport(
102
+ policy_compliance=1.00, # 1.0 - sum(weighted_violations); see _compute_judge_report
103
+ sustainability_risk=0.10, # 0.4*(1-energy_min) + 0.3*sleep_debt + 0.3*low_energy_ratio
104
+ strategic_quality=0.96, # 0.4*engagement_per_post + 0.3*intent_diversity + 0.3*format_diversity
105
+ explanation="compliance=1.00 risk=0.10 strategy=0.96 | no policy violations",
106
+ violations=[], # human-readable rule breaks (Buffer 2.1M, Van Dongen, Cen 2024)
107
+ )
108
+ ```
109
+
110
+ Auditable rules (all sourced): >5 posts/day → fatigue cliff (Buffer 2.1M); >7 posts/week → weekly cap; ≥4 collabs/month → diminishing returns (Cen 2024); >22h awake → sleep debt (Van Dongen 2003).
111
+
112
+ ## Headline metrics (final-step audit)
113
+
114
+ The final observation carries `HeadlineMetrics` with the three numbers judges remember:
115
+
116
+ | Metric | What it measures | Source of truth |
117
+ |---|---|---|
118
+ | `vs_baseline_pct` | (agent_score − heuristic_baseline) / heuristic_baseline | Empirical baseline loaded from `plots/training_summary.json["smart_heuristic"]` (0.43 / 0.77 / 0.81) |
119
+ | `score_per_tool_call` | grader_score / total_tool_calls | Efficiency: did the agent learn to call tools sparingly? |
120
+ | `score_per_1k_chars` | grader_score per 1k action JSON chars | Token-proxy efficiency |
121
+ | `retention_under_shift` | shifted_score / baseline_score | Pass `episode_chain_id` + `shift_label="baseline"` then `="shifted"` to a second `reset` to populate. None until both runs complete. |
122
+
123
+ ## Tool catalog
124
+
125
+ | Tool | Cost | Returns |
126
+ |------|------|---------|
127
+ | `query_trends` | 1 | Trending topics, tags, niche saturation |
128
+ | `query_competitor` | 2 | Recent posts, avg engagement, strategy |
129
+ | `query_tag_history` | 1 | Your historical signals per tag |
130
+ | `query_audience` | 2 | Segment affinities, active hours |
131
+ | `predict_engagement` | 3 | Simulated signals without committing |
132
+ | `draft_review` | 3 | Strengths/weaknesses of a plan |
133
+ | `query_creator_pool` | 1 | Available collab partners + overlap |
134
+ | `propose_collab` | 5 | Propose collaboration (max 2/month) |
135
+
136
+ API budget starts at 100 per episode.
137
+
138
+ ## Sources & verifiability
139
+
140
+ Every constant is backed by a Tier 1–3 source. Full bibliography with DOIs, PMIDs, and methodology extracts: **[RESEARCH.md](RESEARCH.md)**.
141
+
142
+ | Tier | Count | Example |
143
+ |------|-------|---------|
144
+ | T1 (Peer-reviewed) | 7 papers | Van Dongen 2003, arxiv:2410.13108 |
145
+ | T2 (Industry, large-N) | 9 studies | Buffer 9.6M, Sprout 2B, Rival IQ 1.9M |
146
+ | T3 (Official) | 1 statement | Mosseri Jan-2025 |
147
+ | T4 (Survey) | 2 surveys | Awin 2024 (n=300+) |
148
+ | T5 (Rejected) | 13 sites | No methodology disclosed |
149
+
150
+ ## Storytelling assets
151
+
152
+ - [HuggingFace blog](blog/hf_mini_blog.md)
153
+ - [YouTube script (<2 min)](blog/youtube_script.md)
154
+ - [Slide deck outline](blog/slide_outline.md)
155
+
156
+ ## Local development
157
+
158
+ ```bash
159
+ git clone <repo-url> && cd viraltest
160
+ uv sync
161
+
162
+ # Terminal 1 — API server
163
+ uvicorn viraltest.server.app:app --host 0.0.0.0 --port 8000
164
+
165
+ # Terminal 2 — inference
166
+ export HF_TOKEN=hf_...
167
+ export API_BASE_URL=https://router.huggingface.co/v1
168
+ export MODEL_NAME=Qwen/Qwen2.5-7B-Instruct
169
+ .venv/bin/python inference.py
170
+ ```
171
+
172
+ ## Docker
173
+
174
+ ```bash
175
+ docker build -t viraltest-env:latest .
176
+ docker run --rm -p 8000:8000 viraltest-env:latest
177
+ curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:8000/reset
178
+ ```
179
+
180
+ ## Project structure
181
+
182
+ ```
183
+ .
184
+ ├── inference.py # Tool-discovery agent (no hint keys)
185
+ ├── openenv.yaml # OpenEnv manifest
186
+ ├── models.py # Action/Observation + ToolCall, EngagementSignals
187
+ ├── client.py # ViraltestEnv client (async)
188
+ ├── Dockerfile
189
+ ├── RESEARCH.md # Full sourced bibliography (6+ pages)
190
+ ├── DESIGN.md # Deep design notes
191
+ ├── blog/
192
+ │ ├── hf_mini_blog.md
193
+ │ ├── youtube_script.md
194
+ │ └── slide_outline.md
195
+ ├── server/
196
+ │ ├── app.py # FastAPI + /tools endpoints
197
+ │ ├── viraltest_environment.py
198
+ │ ├── dashboard.html
199
+ │ └── data/
200
+ │ ├── tags.json # ~120 tags, 4 tiers
201
+ │ ├── topics.json # Niche multipliers + seasonal calendar
202
+ │ ├── competitors.json # 7 archetypes
203
+ │ ├── hour_heatmap.json # 7×24 from Buffer+Sprout
204
+ │ ├── audience_segments.json
205
+ │ └── audience_overlap_matrix.json
206
+ ├── training/
207
+ │ └── train_grpo.ipynb # TRL GRPO on Qwen2.5-1.5B-Instruct
208
+ └── plots/
209
+ ├── reward_curve.png
210
+ └── before_after.png
211
+ ```
212
+
213
+ ## License
214
+
215
+ See `LICENSE` in the repository root (BSD-style per upstream OpenEnv examples).
RESEARCH.md ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research Bibliography — Viraltest v2
2
+
3
+ Every constant and design decision in Viraltest is backed by a verifiable source. This document groups sources by quality tier so any reviewer can audit our claims.
4
+
5
+ ## Source quality bar
6
+
7
+ | Tier | Criteria | Example |
8
+ |------|----------|---------|
9
+ | **T1** — Peer-reviewed | Published in a journal or arXiv with disclosed methodology, sample, and peer review | Van Dongen 2003 *Sleep* |
10
+ | **T2** — Industry research | Named org, disclosed methodology, sample ≥100K data points | Buffer 9.6M post study |
11
+ | **T3** — Official platform | Public statement by platform leadership | Adam Mosseri, Head of Instagram |
12
+ | **T4** — Survey (cite with caveat) | Named org, disclosed sample, no external audit | Awin 2024 (n=300+) |
13
+ | **T5** — Rejected | SEO/affiliate blog, no methodology, no auditable sample | *Not cited* |
14
+
15
+ ---
16
+
17
+ ## Tier 1 — Peer-reviewed
18
+
19
+ ### Van Dongen HPA, Maislin G, Mullington JM, Dinges DF (2003)
20
+
21
+ **Title:** The cumulative cost of additional wakefulness: dose-response effects on neurobehavioral functions and sleep physiology from chronic sleep restriction and total sleep deprivation
22
+
23
+ **Venue:** *Sleep* 26(2):117–126 (Oxford University Press)
24
+ **Type:** Randomized controlled trial
25
+ **PMID:** [12683469](https://pubmed.ncbi.nlm.nih.gov/12683469)
26
+ **DOI:** [10.1093/sleep/26.2.117](https://doi.org/10.1093/sleep/26.2.117)
27
+ **Sample:** n=48 healthy adults (ages 21–38), laboratory conditions, 14 consecutive days
28
+
29
+ **Methodology:** Subjects randomized to 4h, 6h, or 8h time-in-bed per night for 14 days, or 0h for 3 days. Continuous behavioral/physiological monitoring. Performance measured via psychomotor vigilance task (PVT), digit symbol substitution, serial addition/subtraction.
30
+
31
+ **Key finding:** Lapses in behavioral alertness were near-linearly related to cumulative wakefulness exceeding **15.84 hours** (SE 0.73h), regardless of whether deprivation was chronic or total. 6h sleep/night for 14 days produced deficits equivalent to 1–2 nights of total sleep deprivation. Subjects were largely unaware of their impairment.
32
+
33
+ **What we use:** `SLEEP_OPTIMAL_AWAKE = 16` (rounded from 15.84). Piecewise-linear quality decay: no loss below 16h awake, then `SLEEP_LINEAR_DECAY_PER_HOUR = 0.0625` (reaches ~50% at 24h), floor at `SLEEP_MIN_QUALITY = 0.30`.
34
+
35
+ ---
36
+
37
+ ### Cen Y et al. (2024)
38
+
39
+ **Title:** Algorithmic Content Selection and the Impact of User Disengagement
40
+ **Venue:** arXiv [2410.13108](https://arxiv.org/abs/2410.13108) (v2, Feb 2025)
41
+ **Type:** Theoretical (multi-armed bandit model with user engagement states)
42
+
43
+ **Methodology:** Introduces a content selection model where users have k engagement levels. Derives O(k²) dynamic programming for optimal policy. Proves no-regret online learning guarantees.
44
+
45
+ **Key finding:** Content maximizing immediate reward is not necessarily optimal for sustained engagement. Higher friction (reduced re-engagement likelihood) counterintuitively leads to higher engagement under optimal policies. Modified demand elasticity captures how satisfaction changes affect long-term revenue.
46
+
47
+ **What we use:** Justifies tiered fatigue model (`FATIGUE_TIERS`) — over-posting creates diminishing returns, not a cliff. Also informs the `ALGORITHM_PENALTY` mechanic.
48
+
49
+ ---
50
+
51
+ ### Aouali I et al. (2024)
52
+
53
+ **Title:** System-2 Recommenders: Disentangling Utility and Engagement in Recommendation Systems via Temporal Point-Processes
54
+ **Venue:** arXiv [2406.01611](https://arxiv.org/abs/2406.01611)
55
+ **Type:** Theoretical + synthetic experiments
56
+
57
+ **Methodology:** Generative model where user return probability depends on Hawkes process with System-1 (impulse) and System-2 (utility) components. Proves identifiability of utility from engagement data.
58
+
59
+ **Key finding:** Pure engagement-driven optimization ≠ user utility. Utility-driven interactions have lasting return effects; impulse-driven interactions vanish rapidly. Platforms can disentangle the two from return-probability data.
60
+
61
+ **What we use:** Informs the Mosseri-aligned reward decomposition (watch_time ≈ System-1 impulse; saves ≈ System-2 utility). Validates splitting engagement into distinct signals rather than a single float.
62
+
63
+ ---
64
+
65
+ ### Yu Y et al. (2024)
66
+
67
+ **Title:** Uncovering the Interaction Equation: Quantifying the Effect of User Interactions on Social Media Homepage Recommendations
68
+ **Venue:** arXiv [2407.07227](https://arxiv.org/abs/2407.07227)
69
+ **Type:** Empirical (controlled experiments on YouTube, Reddit, X)
70
+
71
+ **Key finding:** Platform algorithms respond to user interactions by adjusting content distribution. Evidence of topic deprioritization when engagement drops. Inactivity leads to reduced content surfacing.
72
+
73
+ **What we use:** `FOLLOWER_DECAY_HOURS = 72` and `ALGORITHM_PENALTY` scaling with gap length.
74
+
75
+ ---
76
+
77
+ ### Lin Y et al. (2024)
78
+
79
+ **Title:** Unveiling User Satisfaction and Creator Productivity Trade-Offs in Recommendation Platforms
80
+ **Venue:** arXiv [2410.23683](https://arxiv.org/abs/2410.23683)
81
+ **Type:** Theoretical + empirical
82
+
83
+ **Key finding:** Relevance-driven recommendation boosts short-term satisfaction but harms long-term content richness. Explorative policy slightly lowers satisfaction but promotes content production volume.
84
+
85
+ **What we use:** Justifies multi-episode brand persistence — the creator's long-term niche identity matters more than per-post optimization.
86
+
87
+ ---
88
+
89
+ ### Cao X, Wu Y, Cheng B et al. (2024)
90
+
91
+ **Title:** An investigation of the social media overload and academic performance
92
+ **Venue:** *Education and Information Technologies* 29:10303–10328 (Springer)
93
+ **DOI:** [10.1007/s10639-023-12213-6](https://doi.org/10.1007/s10639-023-12213-6)
94
+ **Sample:** n=249 university students, survey
95
+ **Type:** Quantitative survey study
96
+
97
+ **Key finding:** Techno-invasion and techno-overload create psychological stress → exhaustion → perceived irreplaceability → reduced performance. Social support partially buffers the effect.
98
+
99
+ **What we use:** `burnout_risk` observation field — exhaustion accumulates gradually (not binary), mirrors the stress→exhaustion→performance pathway.
100
+
101
+ ---
102
+
103
+ ### Wen J, Wang H, Chen H (2026)
104
+
105
+ **Title:** Research on the formation mechanism of social media burnout among college students based on the ISM-MICMAC model
106
+ **Venue:** *Scientific Reports* (Nature)
107
+ **DOI:** 10.1038/s41598-026-42958-2
108
+ **Sample:** 8 experts (Delphi method), 58 papers reviewed, 15 factors identified
109
+
110
+ **Key finding:** Algorithm recommendations and social comparison are the root-level structural drivers of burnout. Platform-technical mechanisms exert high driving power over subsequent overloads.
111
+
112
+ **What we use:** Contextualizes the `burnout_risk` mechanic — algorithm pressure (our trending/saturation system) is a documented root cause.
113
+
114
+ ---
115
+
116
+ ## Tier 2 — Industry research (methodology disclosed, large N)
117
+
118
+ ### Buffer (2026) — Best Time to Post on Instagram
119
+
120
+ **URL:** [buffer.com/resources/when-is-the-best-time-to-post-on-instagram](https://buffer.com/resources/when-is-the-best-time-to-post-on-instagram)
121
+ **Sample:** 9.6 million posts
122
+ **Methodology:** Engagement data aggregated by hour and day of week across Buffer users. Times in local timezone.
123
+
124
+ **Key findings:** Peak: Thu 9am, Wed 12pm, Wed 6pm. Evenings 6–11pm strongest overall. Fri/Sat weakest. Wed best overall day.
125
+
126
+ **What we use:** `server/data/hour_heatmap.json` — 7×24 multiplier grid.
127
+
128
+ ---
129
+
130
+ ### Buffer (2026) — How Often to Post on Instagram
131
+
132
+ **URL:** [buffer.com/resources/how-often-to-post-on-instagram](https://buffer.com/resources/how-often-to-post-on-instagram)
133
+ **Sample:** 2.1 million posts, 102K accounts
134
+ **Methodology:** Julian Goldie analyzed posting frequency buckets (0, 1–2, 3–5, 6–9, 10+/week) vs follower growth and reach per post.
135
+
136
+ **Key findings:** 3–5 posts/week doubles follower growth vs 1–2. 7+/week shows 20–35% engagement drop per post. Diminishing returns above 5/week.
137
+
138
+ **What we use:** `FATIGUE_TIERS`, `WEEKLY_FATIGUE_THRESHOLD = 7`, `_theoretical_max_engagement` caps at 5 posts/week × `TASK_HORIZON/7` weeks (≈21 posts for 30-day horizon — the Buffer-defined sweet spot before fatigue penalties kick in).
139
+
140
+ ---
141
+
142
+ ### Sprout Social (2025) — The Sprout Social Index Edition XX
143
+
144
+ **URL:** [sproutsocial.com/insights/index](https://sproutsocial.com/insights/index/)
145
+ **Sample:** 4,044 consumers, 900 practitioners, 322 leaders (US/UK/Canada/Australia)
146
+ **Methodology:** Online survey by Glimpse, Sept 13–27, 2024. Representative sampling.
147
+
148
+ **What we use:** Audience preference context for `audience_segments.json`.
149
+
150
+ ---
151
+
152
+ ### Sprout Social (2026) — Best Times to Post on Social Media
153
+
154
+ **URL:** [sproutsocial.com/insights/best-times-to-post-on-social-media](https://sproutsocial.com/insights/best-times-to-post-on-social-media/)
155
+ **Sample:** ~2 billion engagements, 307,000 social profiles, 30K customers
156
+ **Period:** Nov 27, 2025 – Feb 27, 2026
157
+ **Methodology:** Internal Data Science team analysis. All times in local time.
158
+
159
+ **Key findings:** IG peaks: Mon 2–4pm, Tue 1–7pm, Wed 12–9pm, Thu 12–2pm. Weekends worst.
160
+
161
+ **What we use:** Cross-validates `hour_heatmap.json`. `FOLLOWER_DECAY_HOURS` informed by their reporting that reach decline starts after 3–4 days inactivity.
162
+
163
+ ---
164
+
165
+ ### Rival IQ (2025) — Social Media Industry Benchmark Report
166
+
167
+ **URL:** [rivaliq.com/blog/social-media-industry-benchmark-report](https://www.rivaliq.com/blog/social-media-industry-benchmark-report/)
168
+ **Sample:** 1.9 million IG posts, 2,100 brands (150 per industry × 14 industries)
169
+ **Methodology:** Engagement = (likes + comments + shares + reactions) / followers. Median performance per industry. Companies with 25K–1M FB followers, >5K IG followers.
170
+
171
+ **Key findings by industry (IG):** Higher Ed 2.10%, Sports 1.30%, Tech 0.33%, Food 0.37%, Fashion 0.14%.
172
+
173
+ **What we use:** `_NICHE_MULTIPLIERS` in `topics.json`. Normalized by dividing by median (1.53) to create relative multipliers.
174
+
175
+ ---
176
+
177
+ ### Hootsuite (2025) — Social Trends Report 2025
178
+
179
+ **URL:** [hootsuite.com/research/social-trends](https://hootsuite.com/research/social-trends)
180
+ **Type:** Annual industry report
181
+
182
+ **Key finding:** Optimal posting frequency 3–5/week for IG. 48–72 posts/week across all platforms for brands. 83% of marketers say AI helps create significantly more content.
183
+
184
+ **What we use:** Validates frequency constants.
185
+
186
+ ---
187
+
188
+ ### Socialinsider (2026) — Instagram Organic Engagement Benchmarks
189
+
190
+ **URL:** [socialinsider.io/blog/instagram-content-research](https://www.socialinsider.io/blog/instagram-content-research)
191
+ **Sample:** 31 million posts analyzed
192
+
193
+ **Key findings:** Carousels 0.55%, Reels 0.52%, Images 0.45%, text_post ~0.37%. Reels reach 30.81% (2.25× static). Carousels reach 14.45%.
194
+
195
+ **What we use:** `BASE_ENGAGEMENT`, `REACH_MULT` constants.
196
+
197
+ ---
198
+
199
+ ### Later (2023) — Instagram Collaboration Posts Performance Study
200
+
201
+ **URL:** [later.com/blog/instagram-collab-posts](https://later.com/blog/instagram-collab-posts)
202
+ **Sample:** ~5K co-authored posts across the Later customer base (disclosed)
203
+ **Methodology:** Comparison of Collab posts (single post shared to two feeds) vs equivalent solo posts from the same accounts.
204
+
205
+ **Key findings:** Collab posts averaged ~88% more reach and ~40% more impressions than solo posts. Lift driven primarily by exposure to the partner's audience.
206
+
207
+ **What we use:** `COLLAB_REACH_K = 0.60` — reach uplift scales with `(1 - overlap)` and is capped below the headline 88% because reach in our model is already amplified by `REACH_MULT` and `hour_mult`; net post-cap uplift on the constrained engagement value lands in the +30–50% band Later reports for matched-niche pairs.
208
+
209
+ ---
210
+
211
+ ### HypeAuditor (2024) — Influencer Collaboration Benchmark
212
+
213
+ **URL:** [hypeauditor.com/blog/influencer-collaboration](https://hypeauditor.com/blog/influencer-collaboration)
214
+ **Sample:** 10K+ Instagram collaboration posts across niches
215
+ **Methodology:** Per-impression engagement rate, segmented by niche affinity (same niche, adjacent, cross-niche).
216
+
217
+ **Key findings:** Same-niche collabs achieve ~30% higher engagement-per-impression than cross-niche; cross-niche collabs gain new followers but per-impression rate is roughly flat or slightly negative.
218
+
219
+ **What we use:** `COLLAB_AFFINITY_K = 0.30` — engagement-per-impression boost scales with `overlap`, peaking when the partner's audience already shares the user's niche.
220
+
221
+ ---
222
+
223
+ ### Rival IQ (2025) — Cross-Industry Audience Overlap Patterns
224
+
225
+ **URL:** [rivaliq.com/blog/social-media-industry-benchmark-report](https://www.rivaliq.com/blog/social-media-industry-benchmark-report/) (cross-industry chapter)
226
+
227
+ **Key findings:** Same-industry account pairs share 40–65% of their audience; adjacent industries 20–35%; unrelated industries 5–15%. Cross-industry collabs drive new follower acquisition at roughly 2–2.5× the rate of same-industry collabs.
228
+
229
+ **What we use:** `audience_overlap_matrix.json` values and `COLLAB_GROWTH_K = 1.50` — follower spillover scales with `(1 - overlap)`, peaking at +150% when overlap is zero (matches the upper end of Rival IQ's cross-industry follower-acquisition lift).
230
+
231
+ Per-episode collab cadence is **not hard-capped**. Instead, each successive collab in a month is multiplied by `1 / (1 + COLLAB_FATIGUE_K · prior_collabs)` (`K = 0.3`): the multiplier falls to ~77% on the 2nd, 63% on the 3rd, 53% on the 4th. With base `engagement ≈ 1.52×` from a typical-overlap partner, this puts the 1st–2nd collab clearly above the no-collab baseline, the 3rd roughly neutral, and the 4th+ net-negative. This follows Cen et al. 2024's argument that disengagement-aware policies should price marginal exposure rather than impose binary caps, and lets the policy discover its own collab frequency from reward gradient.
232
+
233
+ ---
234
+
235
+ ### Goldman Sachs Global Investment Research (March 2025)
236
+
237
+ **Title:** Creator Economy: Framing the Market Opportunity
238
+ **URL:** [goldmansachs.com/insights/articles/the-creator-economy-could-approach-half-a-trillion-dollars-by-2027](https://www.goldmansachs.com/insights/articles/the-creator-economy-could-approach-half-a-trillion-dollars-by-2027)
239
+ **Type:** Equity research note
240
+
241
+ **Key findings:** ~67M global creators in 2025, growing 10% CAGR to 107M by 2030. Only 3% are professional (>$100K/yr). TAM ~$250B → $480B by 2027. 3% of YouTubers capture 90% of earnings.
242
+
243
+ **What we use:** Problem framing in README. `INITIAL_FOLLOWERS = 10000` (micro-creator tier). `target_growth = 0.04` monthly (micro avg 0.8–1.5%/month → 0.04 as top-decile 4%/month target).
244
+
245
+ ---
246
+
247
+ ## Tier 3 — Official platform statements
248
+
249
+ ### Adam Mosseri, Head of Instagram (January 2025)
250
+
251
+ **Source:** Public statements (Instagram posts, interviews)
252
+ **Confirmed signals:**
253
+ 1. **Watch time** — most important ranking factor, especially Reels completion past 3 seconds
254
+ 2. **Sends per reach** — DM shares, strongest signal for reaching new audiences
255
+ 3. **Likes per reach** — key for existing followers
256
+ 4. Saves — content quality signal (not explicitly ranked top-3 but confirmed as strong)
257
+
258
+ **What we use:** `FORMAT_SIGNAL_WEIGHTS`, `INTENT_MULTIPLIER`, `EngagementSignals` model, reward weights `0.4·watch + 0.3·sends + 0.2·saves + 0.1·likes`.
259
+
260
+ ---
261
+
262
+ ## Tier 4 — Surveys (cite with caveat)
263
+
264
+ ### Awin / ShareASale (September 2024)
265
+
266
+ **Sample:** 300+ creators (majority female, 25–44, 1K–5K followers, Instagram 90%)
267
+ **Finding:** 73% suffer burnout at least sometimes (down from 87% in 2022). Instagram drives 88% of burnout. Top cause: constant platform changes (70%).
268
+ **URL:** [prweb.com/releases/...creator-burnout](https://www.prweb.com/releases/a-majority-of-content-creators-and-influencers-struggle-with-burnout-as-concerns-for-ai-begin-to-surface-according-to-a-new-awin-group-survey-research-302257152.html)
269
+
270
+ **Caveat:** Self-selected sample, not probability-based. Small N. But directionally consistent with Wen 2026 (T1).
271
+ **What we use:** `burnout_risk` contextual framing (73% baseline prevalence).
272
+
273
+ ### Vibely — Creator Burnout Report
274
+
275
+ **Finding:** 90% of creators experienced burnout. 71% considered quitting.
276
+ **Caveat:** No sample size or methodology disclosed. Treat as directional only.
277
+
278
+ ---
279
+
280
+ ## Tier 5 — Rejected sources (NOT cited in env constants)
281
+
282
+ The following sites were found during research but are **not cited** because they do not disclose methodology, sample sizes, or data collection processes. Their claims cannot be independently verified.
283
+
284
+ | Site | Why rejected |
285
+ |------|-------------|
286
+ | instacarousel.com | Affiliate blog, cites Socialinsider without adding primary data |
287
+ | midastools.co | SEO content, no methodology |
288
+ | kicksta.co | Growth tool vendor, no audit trail |
289
+ | postplanify.com | Aggregates others' data without attribution |
290
+ | monolit.sh | Blog post, no primary research |
291
+ | useadmetrics.com | Self-reported benchmarks, methodology unclear |
292
+ | creatorflow.so | Aggregates without disclosure |
293
+ | slumbertheory.com | Health blog, no clinical data source |
294
+ | dataslayer.ai | Marketing tool blog |
295
+ | almcorp.com | Agency blog |
296
+ | loopexdigital.com | Agency blog |
297
+ | carouselli.com | Tool vendor |
298
+ | influize.com | Tag listicle, no methodology |
299
+
300
+ ---
301
+
302
+ *This bibliography was compiled April 2026. All URLs verified at time of writing.*
__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (
11
+ CollabProposal,
12
+ EngagementSignals,
13
+ ReplyAction,
14
+ ScheduledAction,
15
+ ToolCall,
16
+ ToolResult,
17
+ ViraltestAction,
18
+ ViraltestObservation,
19
+ )
20
+
21
+ __all__ = [
22
+ "CollabProposal",
23
+ "EngagementSignals",
24
+ "ReplyAction",
25
+ "ScheduledAction",
26
+ "ToolCall",
27
+ "ToolResult",
28
+ "ViraltestAction",
29
+ "ViraltestObservation",
30
+ "ViraltestEnv",
31
+ ]
client.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Viraltest Environment Client (v2 — Theme #3.1)."""
2
+
3
+ from typing import Any, Dict, List, Optional
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 (
10
+ EngagementSignals,
11
+ ToolResult,
12
+ ViraltestAction,
13
+ ViraltestObservation,
14
+ )
15
+
16
+
17
+ class ViraltestEnv(EnvClient[ViraltestAction, ViraltestObservation, State]):
18
+ """Client for the Viraltest Creator Optimization Environment v2."""
19
+
20
+ def _step_payload(self, action: ViraltestAction) -> Dict[str, Any]:
21
+ payload: Dict[str, Any] = {}
22
+
23
+ if action.tool_calls:
24
+ payload["tool_calls"] = [
25
+ {"name": tc.name, "arguments": tc.arguments}
26
+ for tc in action.tool_calls
27
+ ]
28
+
29
+ actions_list = []
30
+ for sa in action.scheduled_actions:
31
+ item: Dict[str, Any] = {
32
+ "hour": sa.hour,
33
+ "action_type": sa.action_type,
34
+ }
35
+ if sa.content_type is not None:
36
+ item["content_type"] = sa.content_type
37
+ if sa.topic is not None:
38
+ item["topic"] = sa.topic
39
+ if sa.tags is not None:
40
+ item["tags"] = sa.tags
41
+ if sa.intent is not None:
42
+ item["intent"] = sa.intent
43
+ actions_list.append(item)
44
+ payload["scheduled_actions"] = actions_list
45
+
46
+ if action.replies:
47
+ payload["replies"] = [
48
+ {"post_hour": r.post_hour, "reply_hour": r.reply_hour}
49
+ for r in action.replies
50
+ ]
51
+
52
+ if action.collab:
53
+ payload["collab"] = {
54
+ "partner_id": action.collab.partner_id,
55
+ "content_type": action.collab.content_type,
56
+ "hour": action.collab.hour,
57
+ }
58
+
59
+ if action.notes is not None:
60
+ payload["notes"] = action.notes
61
+
62
+ return payload
63
+
64
+ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ViraltestObservation]:
65
+ obs_data = payload.get("observation", {})
66
+ grader_score = obs_data.get("grader_score")
67
+ meta = obs_data.get("metadata", {})
68
+ if grader_score is not None:
69
+ meta["grader_score"] = grader_score
70
+
71
+ signals_raw = obs_data.get("engagement_signals")
72
+ signals = EngagementSignals(**signals_raw) if signals_raw else None
73
+
74
+ tool_results_raw = obs_data.get("tool_results", [])
75
+ tool_results = [ToolResult(**tr) for tr in tool_results_raw]
76
+
77
+ observation = ViraltestObservation(
78
+ current_hour=obs_data.get("current_hour", 0),
79
+ day_of_week=obs_data.get("day_of_week", 0),
80
+ days_elapsed=obs_data.get("days_elapsed", 0),
81
+ creator_energy=obs_data.get("creator_energy", 1.0),
82
+ follower_count=obs_data.get("follower_count", 0),
83
+ engagement_rate=obs_data.get("engagement_rate", 0.0),
84
+ hours_since_sleep=obs_data.get("hours_since_sleep", 0),
85
+ posts_today=obs_data.get("posts_today", 0),
86
+ sleep_debt=obs_data.get("sleep_debt", 0.0),
87
+ time_since_last_post=obs_data.get("time_since_last_post", 0),
88
+ trending_topics=obs_data.get("trending_topics", []),
89
+ content_queue_size=obs_data.get("content_queue_size", 0),
90
+ last_post_type=obs_data.get("last_post_type", "none"),
91
+ burnout_risk=obs_data.get("burnout_risk", 0.0),
92
+ tag_performance=obs_data.get("tag_performance", {}),
93
+ trending_tags=obs_data.get("trending_tags", []),
94
+ competitor_recent_posts=obs_data.get("competitor_recent_posts", []),
95
+ competitor_avg_engagement=obs_data.get("competitor_avg_engagement", 0.0),
96
+ niche_saturation=obs_data.get("niche_saturation", 0.0),
97
+ daily_total_engagement=obs_data.get("daily_total_engagement", 0.0),
98
+ daily_posts_made=obs_data.get("daily_posts_made", 0),
99
+ daily_energy_min=obs_data.get("daily_energy_min", 1.0),
100
+ engagement_signals=signals,
101
+ coach_feedback=obs_data.get("coach_feedback"),
102
+ tool_results=tool_results,
103
+ agent_notes=obs_data.get("agent_notes"),
104
+ api_budget_remaining=obs_data.get("api_budget_remaining", 100),
105
+ grader_score=grader_score,
106
+ error=obs_data.get("error"),
107
+ done=payload.get("done", False),
108
+ reward=payload.get("reward"),
109
+ metadata=meta,
110
+ )
111
+ return StepResult(
112
+ observation=observation,
113
+ reward=payload.get("reward"),
114
+ done=payload.get("done", False),
115
+ )
116
+
117
+ def _parse_state(self, payload: Dict[str, Any]) -> State:
118
+ return State(
119
+ episode_id=payload.get("episode_id"),
120
+ step_count=payload.get("step_count", 0),
121
+ )
inference.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Viraltest Inference Script v2 — Theme #3.1 World-Modeling Agent
3
+ ================================================================
4
+ The agent receives SPARSE observations and must use discoverable tools to learn
5
+ the world (trending topics, competitor activity, tag performance, audience segments).
6
+ No peak-hour hints, no fatigue rules, no content-type tips are provided in the prompt.
7
+
8
+ MANDATORY env vars: API_BASE_URL, MODEL_NAME, HF_TOKEN/OPENAI_API_KEY/API_KEY
9
+ Optional: IMAGE_NAME, ALLOW_SHORT_EPISODE, MAX_STEPS
10
+
11
+ STDOUT FORMAT: [START] [STEP] [END] — match hackathon spec exactly.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import textwrap
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from openai import OpenAI
21
+
22
+ from viraltest import ScheduledAction, ViraltestAction, ViraltestEnv
23
+ from viraltest.models import ToolCall
24
+ from viraltest.server.viraltest_environment import TASK_HORIZON, TOPIC_CATEGORIES
25
+
26
+ DOCKER_IMAGE = os.getenv("IMAGE_NAME") or os.getenv("LOCAL_IMAGE_NAME")
27
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
28
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
29
+ MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-7B-Instruct"
30
+ BENCHMARK = os.getenv("VIRALTEST_BENCHMARK", "viraltest")
31
+
32
+ TASKS = ["monthly_engage", "monthly_strategic", "monthly_competitive"]
33
+ _ALLOW_SHORT = os.getenv("ALLOW_SHORT_EPISODE", "").lower() in ("1", "true", "yes")
34
+ _REQUESTED_MAX = int(os.getenv("MAX_STEPS", str(TASK_HORIZON)))
35
+ MAX_STEPS = _REQUESTED_MAX if _ALLOW_SHORT else max(_REQUESTED_MAX, TASK_HORIZON)
36
+ TEMPERATURE = 0.7
37
+ MAX_TOKENS = 768
38
+ SUCCESS_SCORE_THRESHOLD = 0.50
39
+
40
+ ALL_TOPICS: List[str] = [
41
+ topic for topics in TOPIC_CATEGORIES.values() for topic in topics
42
+ ]
43
+ _TOPIC_CANONICAL: Dict[str, str] = {t.lower(): t for t in ALL_TOPICS}
44
+
45
+ NEAR_ZERO_ENERGY_THRESHOLD = 0.25
46
+
47
+ # The agent is NOT told peak hours, fatigue rules, or content type tips.
48
+ # It must discover these via the tool catalog.
49
+ SYSTEM_PROMPT = textwrap.dedent("""\
50
+ You are an Instagram content strategy agent. Each step is one full day (24 hours).
51
+ You manage a creator account over a 30-day monthly cycle.
52
+
53
+ You receive a SPARSE observation (energy, followers, last reward, notes echo).
54
+ To learn about the world, you MUST use TOOLS before planning your day.
55
+
56
+ AVAILABLE TOOLS (call via tool_calls before scheduling posts):
57
+ - query_trends(niche): Get trending topics and tags for a niche
58
+ - query_competitor(competitor_id, window_days): See competitor activity
59
+ - query_tag_history(tag): Check your past performance with a tag
60
+ - query_audience(segment_id): Learn audience segment preferences
61
+ - predict_engagement(scheduled_actions): Simulate engagement without committing
62
+ - draft_review(scheduled_actions): Get feedback on a draft plan
63
+ - query_creator_pool(): List potential collab partners
64
+ - propose_collab(partner_id, content_type, hour): Propose a collaboration
65
+
66
+ RESPONSE FORMAT (JSON only, no markdown, no prose):
67
+ {
68
+ "tool_calls": [
69
+ {"name": "query_trends", "arguments": {"niche": "tech"}},
70
+ {"name": "query_competitor", "arguments": {"competitor_id": "niche_expert", "window_days": 7}}
71
+ ],
72
+ "scheduled_actions": [
73
+ {"hour": 10, "action_type": "create_content"},
74
+ {"hour": 12, "action_type": "post", "content_type": "reel", "topic": "AI tools", "tags": ["ai", "coding"], "intent": "watch_bait"},
75
+ {"hour": 18, "action_type": "post", "content_type": "carousel", "topic": "startup life", "tags": ["startup", "growth"], "intent": "save_bait"}
76
+ ],
77
+ "replies": [{"post_hour": 12, "reply_hour": 13}],
78
+ "notes": "Day 3: tech niche trending up. Competitor Alpha posted at 10am. Avoiding overlap."
79
+ }
80
+
81
+ RULES:
82
+ - hour: 0-23
83
+ - action_type: "post" or "create_content"
84
+ - For posts: content_type (reel|story|carousel|text_post), topic, tags (max 5), and intent are required
85
+ - intent: what signal you optimize for (send_bait|save_bait|watch_bait|like_bait)
86
+ - Empty scheduled_actions = rest all day
87
+ - Use notes to track hypotheses and observations across days
88
+ - Tool calls cost API budget (starts at 100). Use wisely.
89
+ - Max 2 collaborations per month
90
+ - Reply within 90 minutes of a post for reach bonus
91
+
92
+ Think strategically: use tools to discover what works, then exploit what you learn.""")
93
+
94
+
95
+ def should_force_rest_day(obs: Any) -> bool:
96
+ energy = float(getattr(obs, "creator_energy", 1.0))
97
+ return energy <= NEAR_ZERO_ENERGY_THRESHOLD
98
+
99
+
100
+ def log_start(task: str, env: str, model: str) -> None:
101
+ print(f"[START] task={task} env={env} model={model}", flush=True)
102
+
103
+
104
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
105
+ error_val = error.replace(" ", "_") if error else "null"
106
+ done_val = str(done).lower()
107
+ print(
108
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
109
+ f"done={done_val} error={error_val}",
110
+ flush=True,
111
+ )
112
+
113
+
114
+ def log_end(
115
+ success: bool, steps: int, score: float, rewards: List[float],
116
+ headline: Optional[Any] = None,
117
+ ) -> None:
118
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
119
+ head_str = ""
120
+ if headline is not None:
121
+ retention = headline.retention_under_shift
122
+ retention_str = f"{retention:.2f}" if retention is not None else "n/a"
123
+ head_str = (
124
+ f" vs_baseline_pct={headline.vs_baseline_pct:+.2%} "
125
+ f"score_per_tool={headline.score_per_tool_call:.3f} "
126
+ f"score_per_1k_chars={headline.score_per_1k_chars:.3f} "
127
+ f"retention_under_shift={retention_str}"
128
+ )
129
+ print(
130
+ f"[END] success={str(success).lower()} steps={steps} "
131
+ f"score={score:.2f} rewards={rewards_str}{head_str}",
132
+ flush=True,
133
+ )
134
+
135
+
136
+ def format_observation(obs: Any) -> str:
137
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
138
+ day_name = days[obs.day_of_week] if 0 <= obs.day_of_week < 7 else "?"
139
+
140
+ notes_echo = getattr(obs, "agent_notes", None) or "none"
141
+ budget = getattr(obs, "api_budget_remaining", 100)
142
+ burnout = getattr(obs, "burnout_risk", 0.0)
143
+
144
+ tool_results_str = ""
145
+ for tr in getattr(obs, "tool_results", []):
146
+ if tr.success:
147
+ tool_results_str += f" {tr.name}: {json.dumps(tr.data)[:200]}\n"
148
+ else:
149
+ tool_results_str += f" {tr.name}: ERROR - {tr.error}\n"
150
+
151
+ coach = getattr(obs, "coach_feedback", None)
152
+ coach_str = ""
153
+ if coach:
154
+ coach_str = f"Coach: delta={coach.get('delta', 0):.3f}, suggestion={coach.get('suggestion', '')}\n"
155
+
156
+ judge = getattr(obs, "judge_report", None)
157
+ judge_str = ""
158
+ if judge:
159
+ judge_str = (
160
+ f"Judge: compliance={judge.policy_compliance:.2f} risk={judge.sustainability_risk:.2f} "
161
+ f"strategy={judge.strategic_quality:.2f} | {judge.explanation}\n"
162
+ )
163
+
164
+ signals = getattr(obs, "engagement_signals", None)
165
+ signals_str = ""
166
+ if signals:
167
+ signals_str = (
168
+ f"Signals: watch={signals.watch_time:.3f} sends={signals.sends_per_reach:.3f} "
169
+ f"saves={signals.saves:.3f} likes={signals.likes_per_reach:.3f}\n"
170
+ )
171
+
172
+ return textwrap.dedent(f"""\
173
+ Day: {day_name} (day_of_week={obs.day_of_week}) | days_elapsed={obs.days_elapsed}
174
+ Energy: {obs.creator_energy:.2f} | Burnout risk: {burnout:.2f} | Followers: {obs.follower_count}
175
+ Engagement rate: {obs.engagement_rate:.3f} | Content queue: {obs.content_queue_size}
176
+ API budget remaining: {budget}
177
+ {signals_str}{coach_str}{judge_str}Tool results from last step:
178
+ {tool_results_str if tool_results_str else ' (none)\n'}Your notes from last step: {notes_echo}
179
+ Plan your tool calls and actions for today:""")
180
+
181
+
182
+ def parse_daily_plan(response_text: str) -> ViraltestAction:
183
+ text = response_text.strip()
184
+ if text.startswith("```"):
185
+ lines = text.split("\n")
186
+ lines = [l for l in lines if not l.strip().startswith("```")]
187
+ text = "\n".join(lines).strip()
188
+
189
+ try:
190
+ data: Dict[str, Any] = json.loads(text)
191
+
192
+ tool_calls = []
193
+ for tc in data.get("tool_calls", []):
194
+ if isinstance(tc, dict) and "name" in tc:
195
+ tool_calls.append(ToolCall(name=tc["name"], arguments=tc.get("arguments", {})))
196
+
197
+ actions_raw = data.get("scheduled_actions", [])
198
+ scheduled = []
199
+ if isinstance(actions_raw, list):
200
+ for a in actions_raw:
201
+ if isinstance(a, dict):
202
+ scheduled.append(a)
203
+
204
+ replies_raw = data.get("replies", [])
205
+ notes = data.get("notes")
206
+
207
+ return ViraltestAction(
208
+ tool_calls=tool_calls,
209
+ scheduled_actions=scheduled,
210
+ replies=replies_raw if isinstance(replies_raw, list) else [],
211
+ notes=notes,
212
+ )
213
+ except (json.JSONDecodeError, Exception):
214
+ return ViraltestAction(scheduled_actions=[])
215
+
216
+
217
+ def _resolve_predefined_topic(raw: Optional[str], obs: Any, hour: int) -> str:
218
+ if raw and raw.strip():
219
+ key = raw.strip().lower()
220
+ if key in _TOPIC_CANONICAL:
221
+ return _TOPIC_CANONICAL[key]
222
+ for tt in getattr(obs, "trending_topics", []) or []:
223
+ tl = (tt or "").strip().lower()
224
+ if tl in _TOPIC_CANONICAL:
225
+ return _TOPIC_CANONICAL[tl]
226
+ return ALL_TOPICS[hour % len(ALL_TOPICS)]
227
+
228
+
229
+ def sanitize_predefined_topics(action: ViraltestAction, obs: Any) -> ViraltestAction:
230
+ out = []
231
+ for sa in action.scheduled_actions:
232
+ if sa.action_type == "post":
233
+ out.append(sa.model_copy(update={"topic": _resolve_predefined_topic(sa.topic, obs, sa.hour)}))
234
+ else:
235
+ out.append(sa)
236
+ return ViraltestAction(
237
+ tool_calls=action.tool_calls,
238
+ scheduled_actions=out,
239
+ replies=action.replies,
240
+ collab=action.collab,
241
+ notes=action.notes,
242
+ )
243
+
244
+
245
+ def format_action_str(action: ViraltestAction) -> str:
246
+ parts = []
247
+ if action.tool_calls:
248
+ tools_str = ",".join(tc.name for tc in action.tool_calls)
249
+ parts.append(f"tools({tools_str})")
250
+ if not action.scheduled_actions:
251
+ parts.append("rest_all")
252
+ else:
253
+ for sa in action.scheduled_actions:
254
+ if sa.action_type == "post":
255
+ tags_str = ",".join(sa.tags) if sa.tags else ""
256
+ parts.append(f"h{sa.hour}:post({sa.content_type},\"{sa.topic}\",[{tags_str}],{sa.intent or 'none'})")
257
+ else:
258
+ parts.append(f"h{sa.hour}:{sa.action_type}()")
259
+ return "daily_plan(" + ";".join(parts) + ")"
260
+
261
+
262
+ _model_exhausted = False
263
+
264
+
265
+ def get_model_daily_plan(
266
+ client: OpenAI, obs: Any, history: List[Dict[str, str]]
267
+ ) -> ViraltestAction:
268
+ global _model_exhausted
269
+ if _model_exhausted:
270
+ return ViraltestAction(scheduled_actions=[])
271
+
272
+ user_prompt = format_observation(obs)
273
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
274
+ messages.extend(history[-7:])
275
+ messages.append({"role": "user", "content": user_prompt})
276
+
277
+ try:
278
+ completion = client.chat.completions.create(
279
+ model=MODEL_NAME,
280
+ messages=messages,
281
+ temperature=TEMPERATURE,
282
+ max_tokens=MAX_TOKENS,
283
+ stream=False,
284
+ )
285
+ text = (completion.choices[0].message.content or "").strip()
286
+ plan = parse_daily_plan(text) if text else ViraltestAction(scheduled_actions=[])
287
+ return sanitize_predefined_topics(plan, obs)
288
+ except Exception as exc:
289
+ err_str = str(exc)
290
+ print(f"[DEBUG] Model request failed: {exc}", flush=True)
291
+ if "402" in err_str or "429" in err_str or "credit" in err_str.lower() or "quota" in err_str.lower():
292
+ _model_exhausted = True
293
+ print("[DEBUG] Token/credit limit reached — resting remaining steps", flush=True)
294
+ return ViraltestAction(scheduled_actions=[])
295
+
296
+
297
+ async def run_task(client: OpenAI, task: str) -> None:
298
+ global _model_exhausted
299
+ _model_exhausted = False
300
+
301
+ rewards: List[float] = []
302
+ steps_taken = 0
303
+ score = 0.0
304
+ success = False
305
+ env: Optional[ViraltestEnv] = None
306
+ headline: Optional[Any] = None
307
+
308
+ log_start(task=task, env=BENCHMARK, model=MODEL_NAME)
309
+
310
+ try:
311
+ if DOCKER_IMAGE:
312
+ env = await ViraltestEnv.from_docker_image(DOCKER_IMAGE)
313
+ else:
314
+ env = ViraltestEnv(base_url=os.getenv("ENV_BASE_URL", "http://localhost:8000"))
315
+
316
+ result = await env.reset(task=task)
317
+ history: List[Dict[str, str]] = []
318
+
319
+ for step in range(1, MAX_STEPS + 1):
320
+ if result.done:
321
+ break
322
+
323
+ obs = result.observation
324
+ if should_force_rest_day(obs):
325
+ action = ViraltestAction(scheduled_actions=[], notes="Low energy — forced rest day.")
326
+ else:
327
+ action = get_model_daily_plan(client, obs, history)
328
+
329
+ result = await env.step(action)
330
+
331
+ reward = result.reward or 0.0
332
+ done = result.done
333
+ error = getattr(result.observation, "error", None)
334
+
335
+ rewards.append(reward)
336
+ steps_taken = step
337
+
338
+ log_step(step=step, action=format_action_str(action), reward=reward, done=done, error=error)
339
+
340
+ history.append({
341
+ "role": "assistant",
342
+ "content": json.dumps({
343
+ "tool_calls": [{"name": tc.name, "arguments": tc.arguments} for tc in action.tool_calls],
344
+ "scheduled_actions": [
345
+ {
346
+ "hour": sa.hour, "action_type": sa.action_type,
347
+ "content_type": sa.content_type, "topic": sa.topic,
348
+ "tags": sa.tags, "intent": sa.intent,
349
+ }
350
+ for sa in action.scheduled_actions
351
+ ],
352
+ "notes": action.notes,
353
+ }),
354
+ })
355
+
356
+ if done:
357
+ score = float(getattr(result.observation, "grader_score", 0) or 0)
358
+ if score == 0:
359
+ meta = getattr(result.observation, "metadata", {}) or {}
360
+ score = float(meta.get("grader_score", 0.0))
361
+ headline = getattr(result.observation, "headline_metrics", None)
362
+ break
363
+
364
+ success = score >= SUCCESS_SCORE_THRESHOLD
365
+
366
+ finally:
367
+ if env is not None:
368
+ try:
369
+ await env.close()
370
+ except Exception as e:
371
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
372
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards, headline=headline)
373
+
374
+
375
+ async def main() -> None:
376
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "not-needed")
377
+ for task in TASKS:
378
+ await run_task(client, task)
379
+
380
+
381
+ if __name__ == "__main__":
382
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the Viraltest Creator Optimization Environment (v2 — Theme #3.1)."""
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
+ VALID_INTENTS = ("send_bait", "save_bait", "watch_bait", "like_bait")
11
+
12
+
13
+ class ToolCall(BaseModel):
14
+ """A single tool invocation the agent wants to make before committing actions."""
15
+
16
+ name: str = Field(..., description="Tool name from the /tools catalog")
17
+ arguments: Dict[str, Any] = Field(default_factory=dict)
18
+
19
+
20
+ class ToolResult(BaseModel):
21
+ """Result returned from a single tool invocation."""
22
+
23
+ name: str
24
+ success: bool = True
25
+ data: Any = None
26
+ error: Optional[str] = None
27
+ budget_remaining: int = Field(default=100, ge=0)
28
+
29
+
30
+ class ScheduledAction(BaseModel):
31
+ """A single non-rest action scheduled at a specific hour of the day."""
32
+
33
+ hour: int = Field(..., ge=0, le=23, description="Hour of the day (0-23)")
34
+ action_type: Literal["post", "create_content"] = Field(
35
+ ..., description="What to do at this hour (unlisted hours default to rest)"
36
+ )
37
+ content_type: Optional[Literal["reel", "story", "carousel", "text_post"]] = Field(
38
+ default=None, description="Format of the post (required if posting)"
39
+ )
40
+ topic: Optional[str] = Field(
41
+ default=None, max_length=200, description="Topic of the post"
42
+ )
43
+ tags: Optional[List[str]] = Field(
44
+ default=None, description="Hashtags for the post (max 5)"
45
+ )
46
+ intent: Optional[Literal["send_bait", "save_bait", "watch_bait", "like_bait"]] = Field(
47
+ default=None,
48
+ description="Mosseri signal the post optimizes for (affects which engagement signal gets boosted)",
49
+ )
50
+
51
+ @field_validator("tags")
52
+ @classmethod
53
+ def validate_tags(cls, v: Optional[List[str]]) -> Optional[List[str]]:
54
+ if v is not None and len(v) > 5:
55
+ return v[:5]
56
+ return v
57
+
58
+
59
+ class ReplyAction(BaseModel):
60
+ """Reply to comments on a post made earlier today (within reply window)."""
61
+
62
+ post_hour: int = Field(..., ge=0, le=23, description="Hour of the post to reply on")
63
+ reply_hour: int = Field(..., ge=0, le=23, description="Hour to send replies")
64
+
65
+
66
+ class CollabProposal(BaseModel):
67
+ """Propose a collaboration with a competitor archetype."""
68
+
69
+ partner_id: str = Field(..., description="Competitor archetype id from competitors.json")
70
+ content_type: Optional[Literal["reel", "story", "carousel", "text_post"]] = Field(default="reel")
71
+ hour: int = Field(default=12, ge=0, le=23)
72
+
73
+
74
+ class ViraltestAction(Action):
75
+ """Daily plan: tool calls for discovery, then scheduled actions to commit."""
76
+
77
+ tool_calls: List[ToolCall] = Field(
78
+ default_factory=list,
79
+ description="Tool invocations to run before committing actions (query_audience, query_trends, etc.)",
80
+ )
81
+ scheduled_actions: List[ScheduledAction] = Field(
82
+ default_factory=list,
83
+ description="Actions scheduled at specific hours; unlisted hours are rest",
84
+ )
85
+ replies: List[ReplyAction] = Field(
86
+ default_factory=list,
87
+ description="Reply actions on posts made today (within 90-min window for reach bonus)",
88
+ )
89
+ collab: Optional[CollabProposal] = Field(
90
+ default=None,
91
+ description="Optional collaboration proposal (max 2 per month)",
92
+ )
93
+ notes: Optional[str] = Field(
94
+ default=None,
95
+ max_length=2000,
96
+ description="Agent scratchpad — persisted and echoed back next step for belief tracking",
97
+ )
98
+
99
+ @field_validator("scheduled_actions")
100
+ @classmethod
101
+ def validate_no_duplicate_hours(cls, v: List[ScheduledAction]) -> List[ScheduledAction]:
102
+ seen: set = set()
103
+ deduped: List[ScheduledAction] = []
104
+ for a in v:
105
+ if a.hour not in seen:
106
+ seen.add(a.hour)
107
+ deduped.append(a)
108
+ return deduped
109
+
110
+
111
+ class JudgeReport(BaseModel):
112
+ """Auditable per-day evaluation by the in-env Regulator/Judge.
113
+
114
+ Scores are 0..1. `sustainability_risk` is RISK (higher = worse).
115
+ """
116
+
117
+ policy_compliance: float = Field(default=1.0, ge=0.0, le=1.0)
118
+ sustainability_risk: float = Field(default=0.0, ge=0.0, le=1.0)
119
+ strategic_quality: float = Field(default=0.0, ge=0.0, le=1.0)
120
+ explanation: str = Field(default="")
121
+ violations: List[str] = Field(default_factory=list)
122
+
123
+
124
+ class HeadlineMetrics(BaseModel):
125
+ """Three headline numbers reported once per episode (final observation)."""
126
+
127
+ vs_baseline_pct: float = Field(default=0.0, description="(agent - heuristic_baseline) / heuristic_baseline")
128
+ score_per_tool_call: float = Field(default=0.0, description="grader_score / total_tool_calls (efficiency)")
129
+ score_per_1k_chars: float = Field(default=0.0, description="grader_score per 1k action chars (token-proxy efficiency)")
130
+ retention_under_shift: Optional[float] = Field(
131
+ default=None,
132
+ description="shifted_score / baseline_score, populated when both runs share an episode_chain_id",
133
+ )
134
+ heuristic_baseline_score: float = Field(default=0.0)
135
+ agent_score: float = Field(default=0.0)
136
+ total_tool_calls: int = Field(default=0, ge=0)
137
+ total_action_chars: int = Field(default=0, ge=0)
138
+
139
+
140
+ class EngagementSignals(BaseModel):
141
+ """Mosseri-aligned engagement decomposition (Jan 2025 official ranking signals)."""
142
+
143
+ watch_time: float = Field(default=0.0, ge=0.0, description="Reels watch time signal")
144
+ sends_per_reach: float = Field(default=0.0, ge=0.0, description="DM shares signal (strongest for discovery)")
145
+ saves: float = Field(default=0.0, ge=0.0, description="Bookmark signal (content quality)")
146
+ likes_per_reach: float = Field(default=0.0, ge=0.0, description="Like signal (existing followers)")
147
+
148
+ @property
149
+ def weighted_total(self) -> float:
150
+ return 0.4 * self.watch_time + 0.3 * self.sends_per_reach + 0.2 * self.saves + 0.1 * self.likes_per_reach
151
+
152
+
153
+ class ViraltestObservation(Observation):
154
+ """Observation the agent receives after each daily step.
155
+
156
+ Default observation is SPARSE (Theme #3.1 partial observability).
157
+ Rich data (tag_performance, competitor_posts, trending) available only via tools.
158
+ """
159
+
160
+ current_hour: int = Field(default=0, ge=0, le=23)
161
+ day_of_week: int = Field(default=0, ge=0, le=6)
162
+ days_elapsed: int = Field(default=0, ge=0)
163
+ creator_energy: float = Field(default=1.0, ge=0.0, le=1.0)
164
+ hours_since_sleep: int = Field(default=0, ge=0)
165
+ sleep_debt: float = Field(default=0.0, ge=0.0, le=1.0)
166
+ follower_count: int = Field(default=0, ge=0)
167
+ engagement_rate: float = Field(default=0.0, ge=0.0)
168
+ posts_today: int = Field(default=0, ge=0)
169
+ time_since_last_post: int = Field(default=0, ge=0)
170
+ content_queue_size: int = Field(default=0, ge=0)
171
+ last_post_type: str = Field(default="none")
172
+ burnout_risk: float = Field(default=0.0, ge=0.0, le=1.0, description="0=safe, 1=imminent burnout")
173
+
174
+ # Sparse: these are populated only when agent uses tools
175
+ trending_topics: List[str] = Field(default_factory=list)
176
+ trending_tags: List[str] = Field(default_factory=list)
177
+ tag_performance: Dict[str, float] = Field(default_factory=dict)
178
+ competitor_recent_posts: List[Dict[str, Any]] = Field(default_factory=list)
179
+ competitor_avg_engagement: float = Field(default=0.0, ge=0.0)
180
+ niche_saturation: float = Field(default=0.0, ge=0.0, le=1.0)
181
+
182
+ daily_total_engagement: float = Field(default=0.0, ge=0.0)
183
+ daily_posts_made: int = Field(default=0, ge=0)
184
+ daily_energy_min: float = Field(default=1.0, ge=0.0, le=1.0)
185
+
186
+ engagement_signals: Optional[EngagementSignals] = Field(
187
+ default=None, description="Mosseri-aligned signal breakdown for the day"
188
+ )
189
+ coach_feedback: Optional[Dict[str, Any]] = Field(
190
+ default=None,
191
+ description="Counterfactual feedback: delta between agent plan and heatmap-optimal plan",
192
+ )
193
+ judge_report: Optional[JudgeReport] = Field(
194
+ default=None,
195
+ description="Regulator/Judge audit: policy compliance, sustainability risk, strategic quality + explanation",
196
+ )
197
+ headline_metrics: Optional[HeadlineMetrics] = Field(
198
+ default=None,
199
+ description="Final-observation hard numbers: improvement vs baseline, efficiency, shift retention",
200
+ )
201
+
202
+ tool_results: List[ToolResult] = Field(default_factory=list, description="Results from tool_calls this step")
203
+ agent_notes: Optional[str] = Field(default=None, description="Echo of agent's notes from previous step")
204
+ api_budget_remaining: int = Field(default=100, ge=0)
205
+
206
+ grader_score: Optional[float] = Field(default=None)
207
+ 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,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "openai>=1.0.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0.0",
27
+ "pytest-cov>=4.0.0",
28
+ ]
29
+ # Colab / CUDA: 4-bit QLoRA. On Mac without CUDA, notebook falls back to fp16 (MPS) / fp32 (CPU).
30
+ training = [
31
+ "bitsandbytes>=0.46.1",
32
+ "transformers>=4.45.0",
33
+ "accelerate>=1.0.0",
34
+ "peft>=0.10.0",
35
+ "trl>=0.8.0",
36
+ "datasets>=2.0.0",
37
+ "torch",
38
+ ]
39
+
40
+ [project.scripts]
41
+ # Server entry point - enables running via: uv run --project . server
42
+ # or: python -m viraltest.server.app
43
+ server = "viraltest.server.app:main"
44
+
45
+ [tool.setuptools]
46
+ include-package-data = true
47
+ packages = ["viraltest", "viraltest.server"]
48
+ package-dir = { "viraltest" = ".", "viraltest.server" = "server" }
49
+
50
+ [tool.setuptools.package-data]
51
+ "viraltest.server" = ["*.html", "data/*.json"]
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,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the Viraltest Environment v2 (Theme #3.1).
3
+
4
+ Endpoints:
5
+ - POST /reset, /step, GET /state, /schema — standard OpenEnv
6
+ - GET /tools — tool catalog (Theme #3.1 discovery)
7
+ - GET /tools/{name} — single tool schema
8
+ - GET /dashboard — simulation UI
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import random as stdlib_random
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from fastapi import Body
19
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
20
+
21
+ try:
22
+ from openenv.core.env_server.http_server import create_app
23
+ except Exception as e:
24
+ raise ImportError(
25
+ "openenv is required. Install with 'uv sync'"
26
+ ) from e
27
+
28
+ if "ENABLE_WEB_INTERFACE" not in os.environ:
29
+ os.environ["ENABLE_WEB_INTERFACE"] = "true"
30
+
31
+ try:
32
+ from ..models import ScheduledAction, ViraltestAction, ViraltestObservation
33
+ from .viraltest_environment import TOOL_CATALOG, ViraltestEnvironment
34
+ except ImportError:
35
+ from models import ScheduledAction, ViraltestAction, ViraltestObservation
36
+ from server.viraltest_environment import TOOL_CATALOG, ViraltestEnvironment
37
+
38
+ try:
39
+ from .viraltest_environment import TAG_POOL
40
+ except ImportError:
41
+ from server.viraltest_environment import TAG_POOL
42
+
43
+ _DASHBOARD_HTML = (Path(__file__).parent / "dashboard.html").read_text()
44
+ _TRAINING_HTML_PATH = Path(__file__).parent / "training.html"
45
+ _TRAINING_HTML = _TRAINING_HTML_PATH.read_text() if _TRAINING_HTML_PATH.exists() else "<html><body>Training page not found</body></html>"
46
+
47
+ app = create_app(
48
+ ViraltestEnvironment,
49
+ ViraltestAction,
50
+ ViraltestObservation,
51
+ env_name="viraltest",
52
+ max_concurrent_envs=1,
53
+ )
54
+
55
+ _gradio_web = os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
56
+ if not _gradio_web:
57
+
58
+ @app.get("/", include_in_schema=False)
59
+ async def _root_redirect():
60
+ return RedirectResponse("/dashboard", status_code=302)
61
+
62
+ @app.get("/web", include_in_schema=False)
63
+ @app.get("/web/", include_in_schema=False)
64
+ async def _web_disabled_redirect():
65
+ return RedirectResponse("/dashboard", status_code=302)
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Tool catalog endpoints (Theme #3.1 — tool discovery)
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @app.get("/tools")
72
+ async def list_tools():
73
+ """Return the full tool catalog so the agent can discover available tools."""
74
+ return JSONResponse(content={
75
+ "tools": {name: schema for name, schema in TOOL_CATALOG.items()},
76
+ "count": len(TOOL_CATALOG),
77
+ })
78
+
79
+
80
+ @app.get("/tools/{name}")
81
+ async def get_tool(name: str):
82
+ """Return schema for a single tool."""
83
+ if name not in TOOL_CATALOG:
84
+ return JSONResponse(content={"error": f"unknown tool: {name}"}, status_code=404)
85
+ return JSONResponse(content={"name": name, **TOOL_CATALOG[name]})
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Dashboard
90
+ # ---------------------------------------------------------------------------
91
+
92
+ _dash_env: Optional[ViraltestEnvironment] = None
93
+ _HISTORY_FILE = Path(__file__).parent / "simulation_history.json"
94
+
95
+
96
+ def _obs_to_dict(obs: ViraltestObservation) -> Dict[str, Any]:
97
+ return {
98
+ "observation": obs.model_dump(),
99
+ "reward": obs.reward,
100
+ "done": obs.done,
101
+ }
102
+
103
+
104
+ def _load_history() -> List[Dict[str, Any]]:
105
+ if _HISTORY_FILE.exists():
106
+ try:
107
+ return json.loads(_HISTORY_FILE.read_text())
108
+ except (json.JSONDecodeError, OSError):
109
+ return []
110
+ return []
111
+
112
+
113
+ def _save_history_entry(entry: Dict[str, Any]) -> None:
114
+ history = _load_history()
115
+ history.append(entry)
116
+ if len(history) > 100:
117
+ history = history[-100:]
118
+ _HISTORY_FILE.write_text(json.dumps(history, indent=2))
119
+
120
+
121
+ @app.get("/dashboard", response_class=HTMLResponse)
122
+ async def dashboard():
123
+ return _DASHBOARD_HTML
124
+
125
+
126
+ @app.get("/dashboard/history")
127
+ async def dashboard_history():
128
+ history = _load_history()
129
+ out: List[Dict[str, Any]] = []
130
+ for row in history:
131
+ entry = dict(row)
132
+ if not entry.get("description"):
133
+ sid = entry.get("scenario_id")
134
+ if sid and sid in SCENARIOS:
135
+ entry["description"] = SCENARIOS[sid][1]
136
+ out.append(entry)
137
+ return out
138
+
139
+
140
+ @app.delete("/dashboard/history")
141
+ async def dashboard_history_clear():
142
+ if _HISTORY_FILE.exists():
143
+ _HISTORY_FILE.unlink()
144
+ return {"status": "cleared"}
145
+
146
+
147
+ @app.post("/dashboard/reset")
148
+ async def dashboard_reset(body: Dict[str, Any] = Body(default={})):
149
+ global _dash_env
150
+ _dash_env = ViraltestEnvironment()
151
+ task = body.get("task", "monthly_engage")
152
+ obs = _dash_env.reset(task=task)
153
+ return _obs_to_dict(obs)
154
+
155
+
156
+ @app.post("/dashboard/step")
157
+ async def dashboard_step(body: Dict[str, Any] = Body(...)):
158
+ global _dash_env
159
+ if _dash_env is None:
160
+ _dash_env = ViraltestEnvironment()
161
+ _dash_env.reset()
162
+ action_data = body.get("action", body)
163
+ action = ViraltestAction(**action_data)
164
+ obs = _dash_env.step(action)
165
+ return _obs_to_dict(obs)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Dashboard scenario helpers (v2 action shape)
170
+ # ---------------------------------------------------------------------------
171
+
172
+ _SIM_RNG = stdlib_random.Random(99)
173
+ _CONTENT_TYPES = ["reel", "carousel", "story", "text_post"]
174
+ _TOPICS = ["AI tools", "fitness routine", "growth hacks", "travel guide", "food recipe", "wellness tips"]
175
+
176
+
177
+ def _make_daily_plan(actions: list, notes: Optional[str] = None) -> ViraltestAction:
178
+ return ViraltestAction(
179
+ scheduled_actions=[ScheduledAction(**a) for a in actions],
180
+ notes=notes,
181
+ )
182
+
183
+
184
+ def _plan_always_rest(obs: dict, day: int) -> ViraltestAction:
185
+ return _make_daily_plan([], notes="Resting all day to conserve energy.")
186
+
187
+
188
+ def _plan_spam(obs: dict, day: int) -> ViraltestAction:
189
+ actions = [
190
+ {"hour": h, "action_type": "post", "content_type": "reel",
191
+ "topic": "AI tools", "tags": ["ai"], "intent": "watch_bait"}
192
+ for h in range(24)
193
+ ]
194
+ return _make_daily_plan(actions)
195
+
196
+
197
+ def _plan_smart(obs: dict, day: int) -> ViraltestAction:
198
+ trending = (obs.get("trending_topics") or ["AI tools"])[0]
199
+ t_tags = list((obs.get("trending_tags") or [])[:2])
200
+ pool_tag = TAG_POOL[(day * 2) % len(TAG_POOL)]
201
+ pool_tag2 = TAG_POOL[(day * 2 + 1) % len(TAG_POOL)]
202
+ ct1 = _CONTENT_TYPES[(day * 2) % 4]
203
+ ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
204
+ intent1 = "save_bait" if ct1 == "carousel" else "watch_bait"
205
+ intent2 = "send_bait" if ct2 == "reel" else "save_bait"
206
+ actions = [
207
+ {"hour": 8, "action_type": "create_content"},
208
+ {"hour": 12, "action_type": "post", "content_type": ct1, "topic": trending,
209
+ "tags": t_tags + [pool_tag], "intent": intent1},
210
+ {"hour": 19, "action_type": "post", "content_type": ct2, "topic": trending,
211
+ "tags": t_tags + [pool_tag2], "intent": intent2},
212
+ ]
213
+ return _make_daily_plan(actions, notes=f"Day {day}: posting at peak hours with varied intents.")
214
+
215
+
216
+ def _plan_random(obs: dict, day: int) -> ViraltestAction:
217
+ actions = []
218
+ for h in range(24):
219
+ r = _SIM_RNG.random()
220
+ if r < 0.1:
221
+ ct = _SIM_RNG.choice(_CONTENT_TYPES)
222
+ topic = _SIM_RNG.choice(_TOPICS)
223
+ tags = _SIM_RNG.sample(TAG_POOL[:20], 2)
224
+ actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
225
+ elif r < 0.15:
226
+ actions.append({"hour": h, "action_type": "create_content"})
227
+ return _make_daily_plan(actions)
228
+
229
+
230
+ def _plan_minimal(obs: dict, day: int) -> ViraltestAction:
231
+ trending = (obs.get("trending_topics") or ["minimalism"])[0]
232
+ tags = list((obs.get("trending_tags") or [])[:3])
233
+ return _make_daily_plan([
234
+ {"hour": 12, "action_type": "post", "content_type": "carousel",
235
+ "topic": trending, "tags": tags, "intent": "save_bait"},
236
+ ])
237
+
238
+
239
+ SCENARIOS = {
240
+ "always_rest": ("Always Rest", "Never posts. Tests follower decay.", _plan_always_rest),
241
+ "spam": ("Spam Post", "Same reel every hour. Burns out fast.", _plan_spam),
242
+ "smart": ("Smart Agent", "Optimal: peak hours, trending, varied types+intents.", _plan_smart),
243
+ "minimal": ("Minimal Poster", "1 carousel per day at noon.", _plan_minimal),
244
+ "random": ("Random Actor", "Random actions. Baseline test.", _plan_random),
245
+ }
246
+
247
+
248
+ @app.get("/dashboard/scenarios")
249
+ async def dashboard_scenarios():
250
+ items = [{"id": k, "label": v[0], "description": v[1]} for k, v in SCENARIOS.items()]
251
+ items.sort(key=lambda x: x["label"].lower())
252
+ return JSONResponse(
253
+ content={"count": len(items), "scenarios": items},
254
+ headers={"Cache-Control": "no-store, max-age=0, must-revalidate"},
255
+ )
256
+
257
+
258
+ @app.post("/dashboard/simulate")
259
+ async def dashboard_simulate(body: Dict[str, Any] = Body(...)):
260
+ global _SIM_RNG
261
+ _SIM_RNG = stdlib_random.Random(99)
262
+
263
+ scenario_id = body.get("scenario", "smart")
264
+ task = body.get("task", "monthly_competitive")
265
+ if scenario_id not in SCENARIOS:
266
+ return {"error": f"Unknown scenario: {scenario_id}"}
267
+
268
+ label, desc, plan_fn = SCENARIOS[scenario_id]
269
+ env = ViraltestEnvironment()
270
+ obs = env.reset(task=task, seed=42)
271
+ obs_dict = obs.model_dump()
272
+
273
+ steps: List[Dict[str, Any]] = []
274
+ for day in range(1, 31):
275
+ action = plan_fn(obs_dict, day)
276
+ obs = env.step(action)
277
+ obs_dict = obs.model_dump()
278
+ r = obs.reward if obs.reward is not None else 0.0
279
+
280
+ n_posts = len([sa for sa in action.scheduled_actions if sa.action_type == "post"])
281
+ n_create = len([sa for sa in action.scheduled_actions if sa.action_type == "create_content"])
282
+ action_str = f"day{day}(posts={n_posts},creates={n_create})"
283
+
284
+ steps.append({
285
+ "step": day,
286
+ "action": action_str,
287
+ "reward": round(r, 4),
288
+ "done": obs.done,
289
+ "error": obs.error,
290
+ "energy": round(obs.creator_energy, 3),
291
+ "hours_since_sleep": obs.hours_since_sleep,
292
+ "sleep_debt": round(obs.sleep_debt, 3),
293
+ "followers": obs.follower_count,
294
+ "engagement_rate": round(obs.engagement_rate, 4),
295
+ "burnout_risk": round(obs.burnout_risk, 3),
296
+ "posts_today": obs.posts_today,
297
+ "hour": obs.current_hour,
298
+ "day": obs.day_of_week,
299
+ "days_elapsed": obs.days_elapsed,
300
+ "queue": obs.content_queue_size,
301
+ "api_budget": obs.api_budget_remaining,
302
+ })
303
+ if obs.done:
304
+ break
305
+
306
+ score = (obs.metadata or {}).get("grader_score", 0.0)
307
+ result = {
308
+ "scenario": label,
309
+ "description": desc,
310
+ "task": task,
311
+ "steps": steps,
312
+ "total_steps": len(steps),
313
+ "score": round(score, 4),
314
+ "final": {
315
+ "energy": round(obs.creator_energy, 3),
316
+ "hours_since_sleep": obs.hours_since_sleep,
317
+ "sleep_debt": round(obs.sleep_debt, 3),
318
+ "followers": obs.follower_count,
319
+ "engagement_rate": round(obs.engagement_rate, 4),
320
+ "burned_out": obs.creator_energy <= 0,
321
+ },
322
+ }
323
+
324
+ rewards = [s["reward"] for s in steps]
325
+ total_posts = sum(s.get("daily_posts_made", 0) for s in steps)
326
+ _save_history_entry({
327
+ "id": datetime.now(timezone.utc).isoformat(),
328
+ "scenario": label,
329
+ "scenario_id": scenario_id,
330
+ "description": desc,
331
+ "task": task,
332
+ "score": round(score, 4),
333
+ "total_steps": len(steps),
334
+ "total_posts": total_posts,
335
+ "avg_reward": round(sum(rewards) / len(rewards), 4) if rewards else 0,
336
+ "final": result["final"],
337
+ })
338
+
339
+ return result
340
+
341
+
342
+ _TRAINING_TASKS = ["monthly_engage", "monthly_strategic", "monthly_competitive"]
343
+
344
+ @app.get("/dashboard/training-evidence")
345
+ async def training_evidence():
346
+ """Run all baseline scenarios across all tasks and return structured comparison data."""
347
+ global _SIM_RNG
348
+
349
+ results = []
350
+ for scenario_id, (label, desc, plan_fn) in SCENARIOS.items():
351
+ for task in _TRAINING_TASKS:
352
+ _SIM_RNG = stdlib_random.Random(99)
353
+ env = ViraltestEnvironment()
354
+ obs = env.reset(task=task, seed=42)
355
+ obs_dict = obs.model_dump()
356
+
357
+ rewards: List[float] = []
358
+ energies: List[float] = [obs.creator_energy]
359
+
360
+ for day in range(1, 31):
361
+ action = plan_fn(obs_dict, day)
362
+ obs = env.step(action)
363
+ obs_dict = obs.model_dump()
364
+ r = obs.reward if obs.reward is not None else 0.0
365
+ rewards.append(r)
366
+ energies.append(obs.creator_energy)
367
+ if obs.done:
368
+ break
369
+
370
+ score = (obs.metadata or {}).get("grader_score", 0.0)
371
+ results.append({
372
+ "scenario_id": scenario_id,
373
+ "scenario": label,
374
+ "description": desc,
375
+ "task": task,
376
+ "grader_score": round(score, 4),
377
+ "total_reward": round(sum(rewards), 4),
378
+ "avg_reward": round(sum(rewards) / len(rewards), 4) if rewards else 0,
379
+ "steps": len(rewards),
380
+ "final_energy": round(obs.creator_energy, 3),
381
+ "min_energy": round(min(energies), 3),
382
+ "final_followers": obs.follower_count,
383
+ "follower_delta": obs.follower_count - 10000,
384
+ "burned_out": obs.creator_energy <= 0,
385
+ "rewards": [round(r, 4) for r in rewards],
386
+ "energies": [round(e, 3) for e in energies],
387
+ })
388
+
389
+ return JSONResponse(
390
+ content={"results": results, "tasks": _TRAINING_TASKS, "scenarios": list(SCENARIOS.keys())},
391
+ headers={"Cache-Control": "no-store, max-age=0, must-revalidate"},
392
+ )
393
+
394
+
395
+ @app.get("/dashboard/training", response_class=HTMLResponse)
396
+ async def training_dashboard():
397
+ return _TRAINING_HTML
398
+
399
+
400
+ def main(host: str = "0.0.0.0", port: int = 8000):
401
+ import uvicorn
402
+ uvicorn.run(app, host=host, port=port)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ import argparse
407
+ parser = argparse.ArgumentParser()
408
+ parser.add_argument("--port", type=int, default=None)
409
+ args = parser.parse_args()
410
+ if args.port is not None:
411
+ main(port=args.port)
412
+ else:
413
+ 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">30-day creator 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="/dashboard/training" 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]">science</span><span class="font-label text-sm">Training Evidence</span>
46
+ </a>
47
+ <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">
48
+ <span class="material-symbols-outlined text-[20px]">web</span><span class="font-label text-sm">OpenEnv UI</span>
49
+ </a>
50
+ </nav>
51
+ <!-- Task Selector in Sidebar -->
52
+ <div class="p-4 border-t border-white/5 space-y-3">
53
+ <div class="text-[9px] font-label uppercase tracking-widest text-on-surface-dim/60 mb-1">Task</div>
54
+ <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">
55
+ <option value="monthly_engage">Easy — Engage</option>
56
+ <option value="monthly_strategic">Medium — Strategic</option>
57
+ <option value="monthly_competitive" selected>Hard — Competitive</option>
58
+ </select>
59
+ <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]">
60
+ <span class="material-symbols-outlined text-[16px] align-middle mr-1">restart_alt</span>Reset
61
+ </button>
62
+ </div>
63
+ </aside>
64
+
65
+ <!-- Main -->
66
+ <div class="flex-1 flex flex-col min-w-0">
67
+
68
+ <!-- Top Bar -->
69
+ <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">
70
+ <div class="flex items-center gap-5">
71
+ <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>
72
+ <span class="text-xs font-label text-on-surface-dim">Day <span id="stepNum" class="text-on-surface font-bold">0</span> / <span id="episodeHorizon">7</span></span>
73
+ </div>
74
+ <div class="flex items-center gap-3">
75
+ <span id="rewardBadge" class="text-xs font-label text-on-surface-dim">Last reward: —</span>
76
+ <span class="text-xs font-label text-on-surface-dim/40">|</span>
77
+ <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>
78
+ </div>
79
+ </header>
80
+
81
+ <main class="flex-1 p-6 space-y-5 overflow-y-auto">
82
+
83
+ <!-- Hero Stat Cards -->
84
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
85
+
86
+ <!-- Energy -->
87
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
88
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">bolt</span></div>
89
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Energy</div>
90
+ <div id="energyVal" class="text-3xl font-black tracking-tight">1.00</div>
91
+ <div class="mt-3 h-2 bg-surface-top rounded-full overflow-hidden">
92
+ <div id="energyBar" class="h-full bg-gradient-to-r from-tertiary-ctr to-tertiary energy-bar rounded-full" style="width:100%"></div>
93
+ </div>
94
+ <div id="energyHint" class="mt-1.5 text-[9px] font-label text-tertiary">FULL</div>
95
+ </div>
96
+
97
+ <!-- Followers -->
98
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
99
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">group</span></div>
100
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Followers</div>
101
+ <div id="followersVal" class="text-3xl font-black tracking-tight">10,000</div>
102
+ <div id="followersDelta" class="mt-1.5 text-[9px] font-label text-on-surface-dim">+0 since start</div>
103
+ </div>
104
+
105
+ <!-- Engagement -->
106
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
107
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">trending_up</span></div>
108
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Engagement</div>
109
+ <div id="engVal" class="text-3xl font-black tracking-tight text-secondary">0.000</div>
110
+ <div id="engVsComp" class="mt-1.5 text-[9px] font-label text-on-surface-dim">vs competitors: —</div>
111
+ </div>
112
+
113
+ <!-- Posts Today -->
114
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
115
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">send</span></div>
116
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Posts Today</div>
117
+ <div id="postsVal" class="text-3xl font-black tracking-tight">0</div>
118
+ <div class="mt-1.5 text-[9px] font-label text-on-surface-dim">max 2-3 optimal</div>
119
+ </div>
120
+
121
+ <!-- Queue -->
122
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
123
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">inventory_2</span></div>
124
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Content Queue</div>
125
+ <div id="queueVal" class="text-3xl font-black tracking-tight text-secondary">0</div>
126
+ <div class="mt-1.5 text-[9px] font-label text-on-surface-dim">posts cost 50% less</div>
127
+ </div>
128
+
129
+ <!-- Saturation -->
130
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
131
+ <div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">layers</span></div>
132
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Niche Saturation</div>
133
+ <div id="satVal" class="text-3xl font-black tracking-tight text-primary">0.00</div>
134
+ <div id="satHint" class="mt-1.5 text-[9px] font-label text-primary">LOW — post unique topics</div>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="glass-solid border border-outline/20 rounded-xl px-4 py-3 space-y-3">
139
+ <div class="flex gap-3 items-start">
140
+ <span class="material-symbols-outlined text-secondary text-lg shrink-0">info</span>
141
+ <p class="text-[11px] font-label text-on-surface-dim leading-relaxed flex-1 min-w-0">
142
+ <span class="text-on-surface font-semibold">Simulation only</span> — not live social data. Each <span class="text-on-surface">step</span> is one full simulated day (24 hours of hourly actions inside the env). You submit a daily plan; <span class="text-on-surface">Post</span> and <span class="text-on-surface">Create</span> are scheduled at hours you choose; unlisted hours are rest while rivals keep posting.
143
+ </p>
144
+ </div>
145
+ <div class="border-t border-white/5 pt-3 space-y-2">
146
+ <div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Niche saturation</div>
147
+ <p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
148
+ Shown after each day 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.
149
+ </p>
150
+ </div>
151
+ <div class="border-t border-white/5 pt-3 space-y-2">
152
+ <div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Final score &amp; viral meter</div>
153
+ <p id="taskScoreBlurb" class="text-[10px] font-label text-on-surface-dim leading-relaxed"></p>
154
+ <p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
155
+ <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.
156
+ </p>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Charts Row -->
161
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
162
+ <!-- Reward history chart -->
163
+ <div class="lg:col-span-2 glass-solid p-5 rounded-xl overflow-hidden">
164
+ <div class="flex justify-between items-center mb-2">
165
+ <div>
166
+ <h3 class="text-sm font-bold">Reward history</h3>
167
+ <p class="text-[10px] text-on-surface-dim mt-0.5">Per-day RL reward after each day (axes: day index × reward)</p>
168
+ </div>
169
+ <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>
170
+ </div>
171
+ <div class="h-52 relative">
172
+ <svg id="engagementChart" class="w-full h-full" viewBox="0 0 760 208" preserveAspectRatio="xMidYMid meet"></svg>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Burnout Meter -->
177
+ <div class="glass-solid p-5 rounded-xl flex flex-col items-center overflow-hidden">
178
+ <div class="flex justify-between items-center w-full mb-3">
179
+ <h3 class="text-sm font-bold">Burnout Meter</h3>
180
+ <span class="material-symbols-outlined text-tertiary text-lg">monitor_heart</span>
181
+ </div>
182
+ <div class="relative w-40 h-40 mb-3">
183
+ <svg viewBox="0 0 120 120" class="w-full h-full -rotate-90">
184
+ <circle cx="60" cy="60" r="50" fill="none" stroke="#222a3d" stroke-width="10"/>
185
+ <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"/>
186
+ <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>
187
+ </svg>
188
+ <div class="absolute inset-0 flex flex-col items-center justify-center">
189
+ <span id="burnoutPct" class="text-4xl font-black tracking-tight">0%</span>
190
+ <span class="text-[8px] font-label text-tertiary uppercase tracking-widest mt-0.5">Cortisol Level</span>
191
+ </div>
192
+ </div>
193
+ <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">
194
+ Recommendation: Start with a balanced create-rest cycle.
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <!-- Second Charts Row -->
200
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
201
+ <!-- Follower Growth -->
202
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
203
+ <h3 class="text-sm font-bold mb-3">Follower Growth</h3>
204
+ <div class="h-32 relative">
205
+ <svg id="followerChart" class="w-full h-full" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet"></svg>
206
+ </div>
207
+ <div class="flex items-baseline gap-3 mt-2">
208
+ <span id="followerTotal" class="text-2xl font-black tracking-tight text-secondary">+0</span>
209
+ <span id="followerDeltaPct" class="text-xs font-label text-secondary/60">+0% vs start</span>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- Top Performing Tags -->
214
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
215
+ <h3 class="text-sm font-bold mb-3">Top Performing Tags</h3>
216
+ <div id="topTagsList" class="space-y-3">
217
+ <div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>
218
+ </div>
219
+ </div>
220
+
221
+ <!-- Recent RL Actions -->
222
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
223
+ <h3 class="text-sm font-bold mb-3">Recent RL Actions</h3>
224
+ <div id="recentActions" class="space-y-3 max-h-44 overflow-y-auto">
225
+ <div class="text-on-surface-dim italic text-[10px]">No actions yet</div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <!-- Day & hour analytics -->
231
+ <div class="space-y-3">
232
+ <div class="flex items-center gap-2 px-1">
233
+ <span class="material-symbols-outlined text-secondary text-lg">show_chart</span>
234
+ <h2 class="text-sm font-bold">Day &amp; hour analytics</h2>
235
+ <span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">X = day index (1–7); line charts = metrics per day; posts histogram = clock hour (0–23) within days</span>
236
+ </div>
237
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-3">
238
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
239
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Energy / day</div>
240
+ <svg id="tsEnergy" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
241
+ </div>
242
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
243
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Followers / day</div>
244
+ <svg id="tsFollowers" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
245
+ </div>
246
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
247
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Follower Δ / day</div>
248
+ <svg id="tsFollowDelta" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
249
+ </div>
250
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
251
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Engagement rate / day</div>
252
+ <svg id="tsEngagement" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
253
+ </div>
254
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
255
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Reward / day</div>
256
+ <svg id="tsReward" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
257
+ </div>
258
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
259
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Niche saturation / day</div>
260
+ <svg id="tsSat" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
261
+ </div>
262
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
263
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Content queue / day</div>
264
+ <svg id="tsQueue" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
265
+ </div>
266
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
267
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Competitor avg engagement / day</div>
268
+ <svg id="tsComp" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
269
+ </div>
270
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
271
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Sleep debt / day</div>
272
+ <svg id="tsSleep" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
273
+ </div>
274
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
275
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Hours since sleep / day</div>
276
+ <svg id="tsAwake" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
277
+ </div>
278
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
279
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Posts by clock hour (0–23)</div>
280
+ <svg id="tsPostsHour" class="w-full h-20" viewBox="0 0 320 72" preserveAspectRatio="xMidYMid meet"></svg>
281
+ <div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mt-2 mb-0.5">Action counts (run)</div>
282
+ <svg id="tsActionMix" class="w-full h-14" viewBox="0 0 320 52" preserveAspectRatio="xMidYMid meet"></svg>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Bottom Stats -->
288
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
289
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
290
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Avg Reward</div>
291
+ <div id="bottomAvgReward" class="text-3xl font-black tracking-tight">0.00</div>
292
+ <div id="bottomAvgDelta" class="text-[10px] font-label text-on-surface-dim mt-1">—</div>
293
+ </div>
294
+ <div class="glass-solid p-4 rounded-xl overflow-hidden">
295
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Total Posts</div>
296
+ <div id="bottomTotalPosts" class="text-3xl font-black tracking-tight">0</div>
297
+ <div class="text-[10px] font-label text-on-surface-dim mt-1">across episode</div>
298
+ </div>
299
+ <div class="glass-solid relative p-4 rounded-xl overflow-hidden">
300
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Viral Probability</div>
301
+ <div id="bottomViralProb" class="text-3xl font-black tracking-tight">LOW (0%)</div>
302
+ <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>
303
+ <div class="absolute bottom-0 right-0 w-2/3 h-10 opacity-30 pointer-events-none">
304
+ <svg viewBox="0 0 200 30" class="w-full h-full" preserveAspectRatio="none">
305
+ <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>
306
+ <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)"/>
307
+ </svg>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ <!-- Main Grid: Actions / History / Intelligence -->
313
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-5">
314
+
315
+ <!-- Left: Actions + History -->
316
+ <div class="lg:col-span-8 space-y-5">
317
+
318
+ <!-- Action Panel -->
319
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
320
+ <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>
321
+ <div class="grid grid-cols-3 gap-3 mb-3">
322
+ <button type="button" title="Submit a full rest day (empty schedule). Advances one simulated day; 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">
323
+ <span class="material-symbols-outlined text-tertiary text-3xl group-hover:scale-110 transition-transform">hotel</span>
324
+ <div class="text-sm font-bold text-tertiary mt-1">Rest</div>
325
+ <div class="text-[9px] text-on-surface-dim mt-0.5">+0.12 energy recovery</div>
326
+ </button>
327
+ <button type="button" title="Schedule create_content at a default hour for this day (daily plan). Queue lowers post energy cost." 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">
328
+ <span class="material-symbols-outlined text-secondary text-3xl group-hover:scale-110 transition-transform">edit_note</span>
329
+ <div class="text-sm font-bold text-secondary mt-1">Create</div>
330
+ <div class="text-[9px] text-on-surface-dim mt-0.5">-0.05 energy, +1 queue</div>
331
+ </button>
332
+ <button type="button" title="Schedule a post at a default hour for this day (daily plan). 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">
333
+ <span class="material-symbols-outlined text-primary text-3xl group-hover:scale-110 transition-transform">send</span>
334
+ <div class="text-sm font-bold text-primary mt-1">Post</div>
335
+ <div class="text-[9px] text-on-surface-dim mt-0.5">type + topic + tags</div>
336
+ </button>
337
+ </div>
338
+ <!-- Post Form -->
339
+ <div id="postForm" class="hidden fade-in space-y-2.5 p-4 rounded-xl bg-surface border border-outline/30">
340
+ <div class="grid grid-cols-2 gap-2.5">
341
+ <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">
342
+ <option value="reel">Reel (-0.25 energy)</option>
343
+ <option value="carousel">Carousel (-0.20)</option>
344
+ <option value="story">Story (-0.08)</option>
345
+ <option value="text_post">Text Post (-0.06)</option>
346
+ </select>
347
+ <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)"/>
348
+ </div>
349
+ <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)"/>
350
+ <div class="flex gap-2">
351
+ <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>
352
+ <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>
353
+ </div>
354
+ </div>
355
+ </div>
356
+
357
+ <!-- Simulate Scenarios (loaded from /dashboard/scenarios) -->
358
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
359
+ <div class="flex flex-wrap justify-between items-center gap-2 mb-3">
360
+ <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>
361
+ <div class="flex flex-col items-end gap-0.5">
362
+ <div class="flex items-center gap-2">
363
+ <span id="scenarioCount" class="text-[9px] font-label text-primary font-bold">…</span>
364
+ <span class="text-[9px] font-label text-on-surface-dim">30-day episode</span>
365
+ </div>
366
+ <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>
367
+ </div>
368
+ </div>
369
+ <div class="mb-3 space-y-2">
370
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Easy</div>
371
+ <div class="flex flex-wrap gap-2">
372
+ <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>
373
+ <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>
374
+ <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>
375
+ </div>
376
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Medium</div>
377
+ <div class="flex flex-wrap gap-2">
378
+ <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>
379
+ <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>
380
+ <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>
381
+ </div>
382
+ </div>
383
+ <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"/>
384
+ <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">
385
+ <div class="col-span-full text-on-surface-dim text-[10px] italic py-4 text-center">Loading strategies…</div>
386
+ </div>
387
+ <!-- Sim Progress -->
388
+ <div id="simProgress" class="hidden">
389
+ <div class="flex items-center gap-3 mb-2">
390
+ <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>
391
+ <span id="simPct" class="text-[10px] font-label text-on-surface-dim w-8 text-right">0%</span>
392
+ </div>
393
+ <div id="simResult" class="hidden"></div>
394
+ </div>
395
+ </div>
396
+
397
+ <!-- Day History -->
398
+ <div class="glass-solid rounded-xl overflow-hidden">
399
+ <div class="p-4 border-b border-white/5 flex justify-between items-center">
400
+ <h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-on-surface-dim text-lg">history</span>Day History</h3>
401
+ </div>
402
+ <div id="historyLog" class="p-4 space-y-1.5 max-h-72 overflow-y-auto text-[11px] font-mono leading-relaxed">
403
+ <div class="text-on-surface-dim italic">Reset the environment to begin...</div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <!-- Right: Intelligence Panels -->
409
+ <div class="lg:col-span-4 space-y-5">
410
+
411
+ <!-- Grader Score (shown when done) -->
412
+ <div id="graderCard" class="hidden glass-solid p-5 rounded-xl border-2 border-primary pulse-glow overflow-hidden">
413
+ <div class="flex justify-between items-start">
414
+ <div>
415
+ <div class="text-[9px] font-label text-primary uppercase tracking-widest">Final Score</div>
416
+ <div id="graderScore" class="text-5xl font-black text-primary tracking-tighter mt-1">—</div>
417
+ </div>
418
+ <span class="material-symbols-outlined text-primary/20 text-5xl">emoji_events</span>
419
+ </div>
420
+ <div id="graderLabel" class="mt-2 text-xs font-label text-on-surface-dim">Episode complete</div>
421
+ </div>
422
+
423
+ <!-- Trending -->
424
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
425
+ <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>
426
+ <div class="mb-3">
427
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Topics</div>
428
+ <div id="trendTopics" class="flex flex-wrap gap-1.5"></div>
429
+ </div>
430
+ <div>
431
+ <div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Tags</div>
432
+ <div id="trendTags" class="flex flex-wrap gap-1.5"></div>
433
+ </div>
434
+ </div>
435
+
436
+ <!-- Tag Performance -->
437
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
438
+ <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>
439
+ <div id="tagPerf" class="space-y-2.5 text-xs">
440
+ <div class="text-on-surface-dim italic">No data yet</div>
441
+ </div>
442
+ </div>
443
+
444
+ <!-- Competitors -->
445
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
446
+ <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>
447
+ <div class="mb-3 flex justify-between items-center">
448
+ <span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Avg Engagement</span>
449
+ <span id="compEng" class="text-sm font-bold text-tertiary">0.000</span>
450
+ </div>
451
+ <div id="compPosts" class="space-y-2 text-xs">
452
+ <div class="text-on-surface-dim italic">No competitor posts yet</div>
453
+ </div>
454
+ </div>
455
+ </div>
456
+ </div>
457
+
458
+ <!-- Simulation History -->
459
+ <div class="glass-solid rounded-xl overflow-hidden">
460
+ <div class="p-4 border-b border-white/5 flex justify-between items-center">
461
+ <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>
462
+ <div class="flex items-center gap-2">
463
+ <button onclick="loadHistory()" class="text-[9px] font-label text-secondary hover:text-secondary/80 transition">Refresh</button>
464
+ <button onclick="clearHistory()" class="text-[9px] font-label text-on-surface-dim/50 hover:text-tertiary transition">Clear</button>
465
+ </div>
466
+ </div>
467
+ <div class="overflow-x-auto">
468
+ <table class="w-full text-[11px] font-label">
469
+ <thead>
470
+ <tr class="text-on-surface-dim/60 uppercase tracking-wider border-b border-white/5">
471
+ <th class="text-left px-4 py-2.5">Time</th>
472
+ <th class="text-left px-4 py-2.5">Scenario</th>
473
+ <th class="text-left px-4 py-2.5">Task</th>
474
+ <th class="text-right px-4 py-2.5">Score</th>
475
+ <th class="text-right px-4 py-2.5">Days</th>
476
+ <th class="text-right px-4 py-2.5">Posts</th>
477
+ <th class="text-right px-4 py-2.5">Followers</th>
478
+ <th class="text-right px-4 py-2.5">Delta</th>
479
+ <th class="text-right px-4 py-2.5">Energy</th>
480
+ <th class="text-center px-4 py-2.5">Status</th>
481
+ </tr>
482
+ </thead>
483
+ <tbody id="historyTable">
484
+ <tr><td colspan="10" class="px-4 py-6 text-center text-on-surface-dim italic">No history yet — run a simulation</td></tr>
485
+ </tbody>
486
+ </table>
487
+ </div>
488
+ </div>
489
+
490
+ </main>
491
+ </div>
492
+
493
+ <script>
494
+ const API=window.location.origin;
495
+ const EPISODE_DAYS=30;
496
+ const DAYS=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
497
+ function fmtAxisNum(v){
498
+ const a=Math.abs(v);
499
+ if(a>=1e6)return (v/1e6).toFixed(1)+"M";
500
+ if(a>=1e3)return (v/1e3).toFixed(1)+"k";
501
+ if(a>=100)return v.toFixed(0);
502
+ if(a>=10)return v.toFixed(1);
503
+ return v.toFixed(2);
504
+ }
505
+ function refreshTaskScoreBlurb(){
506
+ const el=document.getElementById("taskScoreBlurb");
507
+ if(!el)return;
508
+ const t=document.getElementById("taskSelect").value;
509
+ if(t==="monthly_engage"){
510
+ 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.";
511
+ }else if(t==="monthly_strategic"){
512
+ 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.";
513
+ }else{
514
+ 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.";
515
+ }
516
+ }
517
+ let currentObs=null;
518
+ const energyHistory=[];
519
+ const rewardHistory=[];
520
+ const followerHistory=[];
521
+ const actionLog=[];
522
+ const timelineHistory=[];
523
+ let totalPostsCount=0;
524
+
525
+ function recordTimelineFromObs(d, actionType){
526
+ const o=d.observation||d;
527
+ const step=o.metadata?.step??timelineHistory.length;
528
+ timelineHistory.push({
529
+ step,
530
+ simHour:(o.days_elapsed??0)*24+(o.current_hour??0),
531
+ hour:o.current_hour??0,
532
+ day:o.day_of_week??0,
533
+ energy:o.creator_energy??0,
534
+ followers:o.follower_count??0,
535
+ engagement:o.engagement_rate??0,
536
+ reward:d.reward??0,
537
+ sat:o.niche_saturation??0,
538
+ queue:o.content_queue_size??0,
539
+ postsToday:o.posts_today??0,
540
+ compAvg:o.competitor_avg_engagement??0,
541
+ sleepDebt:o.sleep_debt??0,
542
+ hoursSinceSleep:o.hours_since_sleep??0,
543
+ action:actionType||null,
544
+ });
545
+ }
546
+
547
+ function simActionType(actionStr){
548
+ const a=actionStr||"";
549
+ if(a.startsWith("post"))return "post";
550
+ if(a.startsWith("rest"))return "rest";
551
+ if(a.startsWith("create"))return "create_content";
552
+ return null;
553
+ }
554
+
555
+ function redrawTimelineCharts(){
556
+ drawStepLineChart("tsEnergy","energy","#ffb2b9");
557
+ drawStepLineChart("tsFollowers","followers","#7bd0ff");
558
+ drawFollowerDeltaChart("tsFollowDelta");
559
+ drawStepLineChart("tsEngagement","engagement","#a078ff");
560
+ drawStepLineChart("tsReward","reward","#d0bcff");
561
+ drawStepLineChart("tsSat","sat","#ea6479");
562
+ drawStepLineChart("tsQueue","queue","#00a6e0");
563
+ drawStepLineChart("tsComp","compAvg","#7bd0ff");
564
+ drawStepLineChart("tsSleep","sleepDebt","#958ea0");
565
+ drawStepLineChart("tsAwake","hoursSinceSleep","#cbc3d7");
566
+ drawPostsByHour("tsPostsHour");
567
+ drawActionMix("tsActionMix");
568
+ }
569
+
570
+ function drawStepLineChart(svgId,key,color){
571
+ const svg=document.getElementById(svgId);
572
+ const data=timelineHistory;
573
+ if(!svg)return;
574
+ const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
575
+ const plotW=W-pL-pR,plotH=H-pT-pB;
576
+ if(!data.length){
577
+ svg.innerHTML=`<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No days yet</text>`;
578
+ return;
579
+ }
580
+ const vals=data.map(d=>Number(d[key]??0));
581
+ let minV=Math.min(...vals),maxV=Math.max(...vals);
582
+ if(maxV-minV<1e-9){minV-=0.5;maxV+=0.5;}
583
+ const n=data.length;
584
+ const pts=data.map((d,i)=>{
585
+ const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
586
+ const v=Number(d[key]??0);
587
+ const y=pT+(1-(v-minV)/(maxV-minV))*plotH;
588
+ return {x,y};
589
+ });
590
+ let lineD;
591
+ if(pts.length===1)lineD=`M${pts[0].x},${pts[0].y} L${(pts[0].x+1)},${pts[0].y}`;
592
+ else lineD=smoothPath(pts);
593
+ const last=pts[pts.length-1],first=pts[0];
594
+ const areaD=lineD+` L${last.x},${H-pB} L${first.x},${H-pB} Z`;
595
+ const gid="g_"+svgId.replace(/[^a-zA-Z0-9_]/g,"_");
596
+ let h="";
597
+ for(let g=0;g<=4;g++){
598
+ const y=pT+(g/4)*plotH;
599
+ const val=maxV-(g/4)*(maxV-minV);
600
+ h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
601
+ 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>`;
602
+ }
603
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
604
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
605
+ 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>`;
606
+ h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="${color}" stroke-width="2"/>`;
607
+ const lastI=n-1;
608
+ h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
609
+ 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>`;
610
+ h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
611
+ 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">day</text>`;
612
+ svg.innerHTML=h;
613
+ }
614
+
615
+ function drawFollowerDeltaChart(svgId){
616
+ const svg=document.getElementById(svgId);
617
+ const data=timelineHistory;
618
+ if(!svg)return;
619
+ const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
620
+ const plotW=W-pL-pR,plotH=H-pT-pB;
621
+ if(data.length<2){
622
+ 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+ days</text>`;
623
+ return;
624
+ }
625
+ const dlt=data.map((d,i)=>i===0?0:d.followers-data[i-1].followers);
626
+ const maxA=Math.max(...dlt.map(a=>Math.abs(a)),1);
627
+ const midY=pT+plotH/2;
628
+ const amp=(plotH/2-4);
629
+ const n=data.length;
630
+ const pts=dlt.map((dv,i)=>{
631
+ const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
632
+ const y=midY-(dv/maxA)*amp;
633
+ return {x,y};
634
+ });
635
+ const lineD=smoothPath(pts);
636
+ let h="";
637
+ h+=`<line x1="${pL}" y1="${midY}" x2="${W-pR}" y2="${midY}" stroke="#494454" stroke-width="0.6" opacity="0.45"/>`;
638
+ 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>`;
639
+ 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>`;
640
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
641
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
642
+ h+=`<path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2"/>`;
643
+ const lastI=n-1;
644
+ h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
645
+ 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>`;
646
+ h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
647
+ 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">day · Δ followers</text>`;
648
+ svg.innerHTML=h;
649
+ }
650
+
651
+ function drawPostsByHour(svgId){
652
+ const svg=document.getElementById(svgId);
653
+ if(!svg)return;
654
+ const buckets=new Array(24).fill(0);
655
+ for(const p of timelineHistory){
656
+ if(p.action==="post")buckets[p.hour]++;
657
+ }
658
+ const postN=buckets.reduce((a,b)=>a+b,0);
659
+ if(!postN){
660
+ 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>';
661
+ return;
662
+ }
663
+ const max=Math.max(...buckets,1);
664
+ const W=320,H=64,pL=16,pR=4,pT=4,pB=16;
665
+ const slot=(W-pL-pR)/24;
666
+ const bw=slot*0.72;
667
+ let rects="";
668
+ for(let h=0;h<24;h++){
669
+ const bh=(buckets[h]/max)*(H-pT-pB);
670
+ const x=pL+h*slot+(slot-bw)/2;
671
+ const y=H-pB-Math.max(bh,0.5);
672
+ 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"/>`;
673
+ }
674
+ let labels="";
675
+ for(let h=0;h<24;h+=6){
676
+ 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>`;
677
+ }
678
+ svg.innerHTML=rects+labels;
679
+ }
680
+
681
+ function drawActionMix(svgId){
682
+ const svg=document.getElementById(svgId);
683
+ if(!svg)return;
684
+ if(!timelineHistory.length){
685
+ svg.innerHTML='<text x="160" y="28" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No days yet</text>';
686
+ return;
687
+ }
688
+ let r=0,c=0,p=0;
689
+ for(const x of timelineHistory){
690
+ if(x.action==="rest")r++;
691
+ else if(x.action==="create_content")c++;
692
+ else if(x.action==="post")p++;
693
+ }
694
+ const W=320,H=44,pT=6,pB=4;
695
+ const labels=[["Rest",r,"#ffb2b9"],["Create",c,"#7bd0ff"],["Post",p,"#d0bcff"]];
696
+ const max=Math.max(r,c,p,1);
697
+ const bw=90;
698
+ let out="";
699
+ labels.forEach(([lab,n,col],i)=>{
700
+ const x=20+i*100;
701
+ const bh=(n/max)*(H-pT-pB);
702
+ const y=H-pB-bh;
703
+ out+=`<rect x="${x}" y="${y}" width="${bw}" height="${Math.max(bh,2)}" fill="${col}" rx="2"/>`;
704
+ 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>`;
705
+ });
706
+ svg.innerHTML=out;
707
+ }
708
+
709
+ async function doReset(){
710
+ setStatus("Resetting...");
711
+ const task=document.getElementById("taskSelect").value;
712
+ energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;actionLog.length=0;timelineHistory.length=0;totalPostsCount=0;
713
+ try{
714
+ const r=await fetch(API+"/dashboard/reset",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({task})});
715
+ const d=await r.json();
716
+ updateUI(d);
717
+ document.getElementById("historyLog").innerHTML='<div class="text-secondary font-bold">Environment reset — task: '+task+'</div>';
718
+ document.getElementById("graderCard").classList.add("hidden");
719
+ document.getElementById("engagementChart").innerHTML="";
720
+ document.getElementById("followerChart").innerHTML="";
721
+ document.getElementById("recentActions").innerHTML='<div class="text-on-surface-dim italic text-[10px]">No actions yet</div>';
722
+ drawBurnoutMeter(1);
723
+ setStatus("Running");
724
+ }catch(e){setStatus("Error: "+e.message)}
725
+ }
726
+
727
+ async function doAction(type){
728
+ setStatus("Running day…");
729
+ try{
730
+ const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:{action_type:type}})});
731
+ const d=await r.json();
732
+ updateUI(d,{actionType:type});
733
+ addLog(type+"()",d.reward,d.done,d.observation?.error);
734
+ }catch(e){setStatus("Error: "+e.message)}
735
+ }
736
+
737
+ async function doPost(){
738
+ const ct=document.getElementById("contentType").value;
739
+ const topic=document.getElementById("topicInput").value.trim();
740
+ const tagsRaw=document.getElementById("tagsInput").value.trim();
741
+ const tags=tagsRaw?tagsRaw.split(",").map(t=>t.trim()).filter(Boolean):[];
742
+ if(!topic){alert("Enter a topic");return}
743
+ setStatus("Running day…");
744
+ try{
745
+ const body={action:{action_type:"post",content_type:ct,topic,tags:tags.length?tags:undefined}};
746
+ const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
747
+ const d=await r.json();
748
+ updateUI(d,{actionType:"post"});
749
+ addLog(`post(${ct},"${topic}",[${tags.join(",")}])`,d.reward,d.done,d.observation?.error);
750
+ hidePostForm();
751
+ }catch(e){setStatus("Error: "+e.message)}
752
+ }
753
+
754
+ function updateUI(d, opts={}){
755
+ const o=d.observation||d;
756
+ currentObs=o;
757
+ recordTimelineFromObs(d, opts.actionType);
758
+ const energy=o.creator_energy??1;
759
+ const followers=o.follower_count??0;
760
+ const eng=o.engagement_rate??0;
761
+ const sat=o.niche_saturation??0;
762
+ const compAvg=o.competitor_avg_engagement??0;
763
+ const reward=d.reward??0;
764
+
765
+ document.getElementById("energyVal").textContent=energy.toFixed(2);
766
+ document.getElementById("energyBar").style.width=(energy*100)+"%";
767
+ const eHint=document.getElementById("energyHint");
768
+ if(energy<=0){eHint.textContent="BURNED OUT";eHint.className="mt-1.5 text-[9px] font-label text-error"}
769
+ else if(energy<0.3){eHint.textContent="CRITICAL";eHint.className="mt-1.5 text-[9px] font-label text-tertiary-ctr"}
770
+ else if(energy<0.5){eHint.textContent="LOW — REST NOW";eHint.className="mt-1.5 text-[9px] font-label text-tertiary"}
771
+ else if(energy<0.8){eHint.textContent="MODERATE";eHint.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
772
+ else{eHint.textContent="FULL";eHint.className="mt-1.5 text-[9px] font-label text-secondary"}
773
+
774
+ document.getElementById("followersVal").textContent=followers.toLocaleString();
775
+ const delta=followers-10000;
776
+ const dEl=document.getElementById("followersDelta");
777
+ dEl.textContent=(delta>=0?"+":"")+delta+" since start";
778
+ dEl.className="mt-1.5 text-[9px] font-label "+(delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim");
779
+
780
+ document.getElementById("engVal").textContent=eng.toFixed(3);
781
+ const diff=eng-compAvg;
782
+ const evc=document.getElementById("engVsComp");
783
+ evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
784
+ evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
785
+
786
+ document.getElementById("timeVal").textContent=(o.current_hour??0)+":00";
787
+ document.getElementById("dayVal").textContent=DAYS[o.day_of_week??0];
788
+ document.getElementById("postsVal").textContent=o.posts_today??0;
789
+ document.getElementById("queueVal").textContent=o.content_queue_size??0;
790
+ document.getElementById("satVal").textContent=sat.toFixed(2);
791
+ const sH=document.getElementById("satHint");
792
+ if(sat>0.7){sH.textContent="HIGH — diversify topics";sH.className="mt-1.5 text-[9px] font-label text-tertiary"}
793
+ else if(sat>0.4){sH.textContent="MEDIUM — some room";sH.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
794
+ else{sH.textContent="LOW — post unique topics";sH.className="mt-1.5 text-[9px] font-label text-primary"}
795
+ document.getElementById("stepNum").textContent=o.metadata?.step??0;
796
+
797
+ // Charts
798
+ energyHistory.push(energy);
799
+ rewardHistory.push(reward);
800
+ followerHistory.push(followers);
801
+ drawEngagementChart();
802
+ drawBurnoutMeter(energy);
803
+ drawFollowerBars();
804
+ updateBottomStats();
805
+ if(d.action_type||d.observation?.metadata)addRecentAction(d);
806
+
807
+ // Trending
808
+ const tt=document.getElementById("trendTopics");
809
+ 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("");
810
+ const tg=document.getElementById("trendTags");
811
+ 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("");
812
+
813
+ // Tag perf — sidebar panel
814
+ const tp=document.getElementById("tagPerf");
815
+ const perf=o.tag_performance||{};
816
+ const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
817
+ if(entries.length){
818
+ const maxV=Math.max(...entries.map(e=>e[1]),0.01);
819
+ tp.innerHTML=entries.slice(0,6).map(([tag,val],i)=>{
820
+ const w=Math.min(100,(val/maxV)*100);
821
+ const c=i%2===0?"primary":"secondary";
822
+ 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>`;
823
+ }).join("");
824
+ }else{tp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
825
+
826
+ // Top tags styled list
827
+ const ttl=document.getElementById("topTagsList");
828
+ const colors=["secondary","primary","tertiary","on-surface-dim"];
829
+ if(entries.length){
830
+ ttl.innerHTML=entries.slice(0,4).map(([tag,val],i)=>{
831
+ const c=colors[i%colors.length];
832
+ const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
833
+ 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>`;
834
+ }).join("");
835
+ }else{ttl.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
836
+
837
+ // Competitors
838
+ document.getElementById("compEng").textContent=compAvg.toFixed(3);
839
+ const cp=document.getElementById("compPosts");
840
+ const posts=o.competitor_recent_posts||[];
841
+ if(posts.length){
842
+ const icons={reel:"movie",carousel:"view_carousel",story:"auto_stories",text_post:"article"};
843
+ 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("");
844
+ }else{cp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No competitor posts yet</div>'}
845
+
846
+ // Done state
847
+ if(d.done){
848
+ setStatus("Episode Done");
849
+ document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=true;b.classList.add("opacity-30","pointer-events-none")});
850
+ const score=o.metadata?.grader_score;
851
+ if(score!=null){
852
+ const gc=document.getElementById("graderCard");
853
+ gc.classList.remove("hidden");
854
+ document.getElementById("graderScore").textContent=score.toFixed(4);
855
+ const lbl=document.getElementById("graderLabel");
856
+ if(score>=0.7)lbl.textContent="Excellent performance!";
857
+ else if(score>=0.4)lbl.textContent="Decent strategy, room for improvement";
858
+ else lbl.textContent="Poor performance — agent needs better strategy";
859
+ }
860
+ }else{
861
+ document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=false;b.classList.remove("opacity-30","pointer-events-none")});
862
+ setStatus("Running");
863
+ }
864
+ redrawTimelineCharts();
865
+ }
866
+
867
+ function smoothPath(pts){
868
+ if(pts.length<2)return pts.map((p,i)=>(i===0?"M":"L")+p.x.toFixed(1)+","+p.y.toFixed(1)).join(" ");
869
+ let d="M"+pts[0].x.toFixed(1)+","+pts[0].y.toFixed(1);
870
+ for(let i=1;i<pts.length;i++){
871
+ const cp=(pts[i].x-pts[i-1].x)/3;
872
+ 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)}`;
873
+ }
874
+ return d;
875
+ }
876
+
877
+ function drawEngagementChart(){
878
+ const svg=document.getElementById("engagementChart");
879
+ const data=rewardHistory;
880
+ if(!svg||!data.length)return;
881
+ const W=760,H=200,pL=56,pR=14,pT=12,pB=40;
882
+ const plotW=W-pL-pR,plotH=H-pT-pB;
883
+ const minR=Math.min(0,Math.min(...data));
884
+ const maxR=Math.max(...data,0.01);
885
+ const span=Math.max(maxR-minR,1e-6)*1.08;
886
+ const y0=minR;
887
+ const pts=data.map((v,i)=>({
888
+ x:pL+(i/Math.max(data.length-1,1))*plotW,
889
+ y:pT+(1-(v-y0)/span)*plotH,
890
+ }));
891
+ const lineD=smoothPath(pts);
892
+ 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`;
893
+ const gid="eng_reward_grad";
894
+ let h="";
895
+ for(let g=0;g<=4;g++){
896
+ const y=pT+(g/4)*plotH;
897
+ const val=y0+(1-g/4)*span;
898
+ h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
899
+ 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>`;
900
+ }
901
+ h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
902
+ h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
903
+ 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>`;
904
+ h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2.5"/>`;
905
+ const lastI=data.length-1;
906
+ h+=`<text x="${pL}" y="${H-18}" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">day 0</text>`;
907
+ h+=`<text x="${pL+plotW/2}" y="${H-18}" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">day ${Math.floor(lastI/2)}</text>`;
908
+ h+=`<text x="${W-pR}" y="${H-18}" text-anchor="end" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">day ${lastI}</text>`;
909
+ 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">day index (1–${EPISODE_DAYS})</text>`;
910
+ 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>`;
911
+ svg.innerHTML=h;
912
+ }
913
+
914
+ function drawBurnoutMeter(energy){
915
+ const burnout=Math.round((1-energy)*100);
916
+ const circ=2*Math.PI*50;
917
+ const fill=(burnout/100)*circ;
918
+ document.getElementById("burnoutArc").setAttribute("stroke-dasharray",fill.toFixed(1)+" "+circ.toFixed(1));
919
+ document.getElementById("burnoutPct").textContent=burnout+"%";
920
+ const rec=document.getElementById("burnoutRec");
921
+ if(burnout>=70)rec.textContent="Recommendation: Ease off scheduled posts for the next day to prevent creative fatigue.";
922
+ else if(burnout>=40)rec.textContent="Recommendation: Alternate between creating and resting to maintain output quality.";
923
+ else rec.textContent="Recommendation: Energy levels healthy. Good window for high-effort content.";
924
+ }
925
+
926
+ function drawFollowerBars(){
927
+ const svg=document.getElementById("followerChart");
928
+ const data=followerHistory;
929
+ if(data.length<2){svg.innerHTML="";return}
930
+ const W=300,H=120,pL=40,pR=8,pT=6,pB=22,plotW=W-pL-pR,plotH=H-pT-pB;
931
+ const chunks=Math.min(data.length,7);
932
+ const chunkSize=Math.max(1,Math.floor(data.length/chunks));
933
+ const bars=[];
934
+ for(let i=0;i<chunks;i++){
935
+ const start=i*chunkSize;
936
+ const end=Math.min(start+chunkSize,data.length);
937
+ const avg=data.slice(start,end).reduce((a,b)=>a+b,0)/(end-start);
938
+ bars.push(avg);
939
+ }
940
+ const fMin=Math.min(...bars),fMax=Math.max(...bars);
941
+ const base=fMin*0.998;
942
+ const maxDelta=Math.max(...bars.map(b=>b-base),1);
943
+ const barW=plotW/bars.length*0.58;
944
+ const gap=plotW/bars.length*0.42;
945
+ let html="";
946
+ html+=`<text x="4" y="${pT+10}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMax)}</text>`;
947
+ html+=`<text x="4" y="${pT+plotH}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMin)}</text>`;
948
+ 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>`;
949
+ bars.forEach((v,i)=>{
950
+ const h=Math.max(4,((v-base)/maxDelta)*plotH);
951
+ const x=pL+i*(plotW/bars.length)+(gap/2);
952
+ const y=pT+plotH-h;
953
+ const opacity=0.5+0.5*(i/bars.length);
954
+ 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)}"/>`;
955
+ 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>`;
956
+ });
957
+ svg.innerHTML=html;
958
+ const delta=data[data.length-1]-data[0];
959
+ const pct=((delta/data[0])*100);
960
+ document.getElementById("followerTotal").textContent=(delta>=0?"+":"")+Math.round(delta).toLocaleString();
961
+ document.getElementById("followerDeltaPct").textContent=(pct>=0?"+":"")+pct.toFixed(0)+"% vs start";
962
+ }
963
+
964
+ function updateBottomStats(){
965
+ if(rewardHistory.length){
966
+ const avg=rewardHistory.reduce((a,b)=>a+b,0)/rewardHistory.length;
967
+ document.getElementById("bottomAvgReward").textContent=avg.toFixed(2);
968
+ if(rewardHistory.length>10){
969
+ const recent=rewardHistory.slice(-10).reduce((a,b)=>a+b,0)/10;
970
+ const old=rewardHistory.slice(0,10).reduce((a,b)=>a+b,0)/Math.min(10,rewardHistory.length);
971
+ const d=((recent-old)/Math.max(Math.abs(old),0.001)*100);
972
+ document.getElementById("bottomAvgDelta").textContent=(d>=0?"+":"")+d.toFixed(0)+"%";
973
+ document.getElementById("bottomAvgDelta").className="text-[10px] font-label mt-1 "+(d>=0?"text-secondary":"text-tertiary");
974
+ }
975
+ }
976
+ document.getElementById("bottomTotalPosts").textContent=totalPostsCount;
977
+ const eng=currentObs?.engagement_rate??0;
978
+ const viral=Math.min(100,Math.round(eng*1000));
979
+ const label=viral>=70?"HIGH":viral>=40?"MEDIUM":"LOW";
980
+ document.getElementById("bottomViralProb").textContent=label+" ("+viral+"%)";
981
+ const vn=document.getElementById("viralFormulaNote");
982
+ if(vn)vn.textContent="min(100, round("+eng.toFixed(3)+" × 1000)) = "+viral+" — labels LOW/MED/HIGH at 40 and 70 (display only).";
983
+ }
984
+
985
+ function addRecentAction(d){
986
+ const el=document.getElementById("recentActions");
987
+ const step=currentObs?.metadata?.step??0;
988
+ const reward=d.reward??0;
989
+ const icons={rest:"hotel",create_content:"edit_note",post:"send"};
990
+ const colors={rest:"tertiary",create_content:"secondary",post:"primary"};
991
+ const action=d.action_type||d.observation?.last_action||"step";
992
+ const icon=icons[action]||"play_arrow";
993
+ const c=colors[action]||"on-surface-dim";
994
+ 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">day ${step} · r=${reward.toFixed(2)}</div></div></div>`;
995
+ if(el.querySelector(".italic"))el.innerHTML="";
996
+ el.innerHTML=entry+el.innerHTML;
997
+ if(el.children.length>8)el.removeChild(el.lastChild);
998
+ }
999
+
1000
+ function addLog(action,reward,done,error){
1001
+ if(action.startsWith("post"))totalPostsCount++;
1002
+ const step=currentObs?.metadata?.step??0;
1003
+ const log=document.getElementById("historyLog");
1004
+ const errStr=error?` <span class="text-error">err=${error}</span>`:"";
1005
+ const color=reward>0.5?"text-secondary":reward>0.2?"text-primary":"text-on-surface-dim";
1006
+ const doneStr=done?'<span class="text-tertiary font-bold"> DONE</span>':"";
1007
+ log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[day ${step}]</span> <span class="text-on-surface">${action}</span> <span class="${color}">r=${(reward??0).toFixed(2)}</span>${doneStr}${errStr}</div>`;
1008
+ log.scrollTop=log.scrollHeight;
1009
+ document.getElementById("rewardBadge").textContent="Last reward: "+(reward??0).toFixed(2);
1010
+ }
1011
+
1012
+ let simRunning=false;
1013
+ async function runSim(scenario){
1014
+ if(simRunning)return;
1015
+ simRunning=true;
1016
+ const task=document.getElementById("taskSelect").value;
1017
+ document.querySelectorAll(".sim-btn").forEach(b=>b.classList.add("opacity-30","pointer-events-none"));
1018
+ document.getElementById("simProgress").classList.remove("hidden");
1019
+ document.getElementById("simResult").classList.add("hidden");
1020
+ document.getElementById("simBar").style.width="0%";
1021
+ document.getElementById("simPct").textContent="0%";
1022
+ document.getElementById("graderCard").classList.add("hidden");
1023
+ energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;timelineHistory.length=0;totalPostsCount=0;
1024
+ setStatus("Simulating...");
1025
+
1026
+ try{
1027
+ const r=await fetch(API+"/dashboard/simulate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({scenario,task})});
1028
+ const d=await r.json();
1029
+ if(d.error){setStatus("Error: "+d.error);simRunning=false;return}
1030
+
1031
+ const log=document.getElementById("historyLog");
1032
+ 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>`;
1033
+
1034
+ const total=d.steps.length;
1035
+ for(let i=0;i<total;i++){
1036
+ const s=d.steps[i];
1037
+ rewardHistory.push(s.reward);
1038
+ energyHistory.push(s.energy);
1039
+ followerHistory.push(s.followers);
1040
+ timelineHistory.push({
1041
+ step:s.step,
1042
+ simHour:(s.days_elapsed??0)*24+(s.hour??0),
1043
+ hour:s.hour??0,
1044
+ day:s.day??0,
1045
+ energy:s.energy,
1046
+ followers:s.followers,
1047
+ engagement:s.engagement_rate,
1048
+ reward:s.reward,
1049
+ sat:s.niche_saturation,
1050
+ queue:s.queue,
1051
+ postsToday:s.posts_today,
1052
+ compAvg:s.competitor_avg_engagement,
1053
+ sleepDebt:s.sleep_debt??0,
1054
+ hoursSinceSleep:s.hours_since_sleep??0,
1055
+ action:simActionType(s.action),
1056
+ });
1057
+ if(s.action.startsWith("post"))totalPostsCount++;
1058
+
1059
+ const pct=Math.round((i+1)/total*100);
1060
+ document.getElementById("simBar").style.width=pct+"%";
1061
+ document.getElementById("simPct").textContent=pct+"%";
1062
+
1063
+ document.getElementById("energyVal").textContent=s.energy.toFixed(2);
1064
+ document.getElementById("energyBar").style.width=(s.energy*100)+"%";
1065
+ document.getElementById("followersVal").textContent=s.followers.toLocaleString();
1066
+ document.getElementById("engVal").textContent=s.engagement_rate.toFixed(3);
1067
+ document.getElementById("stepNum").textContent=s.step;
1068
+ document.getElementById("timeVal").textContent=s.hour+":00";
1069
+ document.getElementById("dayVal").textContent=DAYS[s.day];
1070
+ document.getElementById("postsVal").textContent=s.posts_today;
1071
+ document.getElementById("queueVal").textContent=s.queue;
1072
+ document.getElementById("satVal").textContent=s.niche_saturation.toFixed(2);
1073
+ document.getElementById("compEng").textContent=s.competitor_avg_engagement.toFixed(3);
1074
+ const diff=s.engagement_rate-s.competitor_avg_engagement;
1075
+ const evc=document.getElementById("engVsComp");
1076
+ evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
1077
+ evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
1078
+ const fdelta=s.followers-10000;
1079
+ const fdEl=document.getElementById("followersDelta");
1080
+ fdEl.textContent=(fdelta>=0?"+":"")+fdelta+" since start";
1081
+ fdEl.className="mt-1.5 text-[9px] font-label "+(fdelta>0?"text-secondary":fdelta<0?"text-tertiary":"text-on-surface-dim");
1082
+
1083
+ drawEngagementChart();
1084
+ drawBurnoutMeter(s.energy);
1085
+ drawFollowerBars();
1086
+ updateBottomStats();
1087
+ redrawTimelineCharts();
1088
+
1089
+ const tt=document.getElementById("trendTopics");
1090
+ 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("");
1091
+ const tg=document.getElementById("trendTags");
1092
+ 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("");
1093
+
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
+ const color=s.reward>0.5?"text-secondary":s.reward>0.2?"text-primary":"text-on-surface-dim";
1118
+ const err=s.error?` <span class="text-error">err=${s.error}</span>`:"";
1119
+ const dn=s.done?'<span class="text-tertiary font-bold"> DONE</span>':"";
1120
+ log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[day ${s.step}]</span> <span class="text-on-surface">${s.action}</span> <span class="${color}">r=${s.reward.toFixed(2)}</span>${dn}${err}</div>`;
1121
+ log.scrollTop=log.scrollHeight;
1122
+ }
1123
+
1124
+ const f=d.final;
1125
+ const sc=d.score;
1126
+ redrawTimelineCharts();
1127
+
1128
+ // Final update of all panels using last step data
1129
+ const lastStep=d.steps[d.steps.length-1];
1130
+ if(lastStep){
1131
+ const tt=document.getElementById("trendTopics");
1132
+ 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("");
1133
+ const tg=document.getElementById("trendTags");
1134
+ 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("");
1135
+
1136
+ const perf=lastStep.tag_performance||{};
1137
+ const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
1138
+ const tp=document.getElementById("tagPerf");
1139
+ if(entries.length){
1140
+ const maxV=Math.max(...entries.map(e=>e[1]),0.01);
1141
+ tp.innerHTML=entries.slice(0,6).map(([tag,val],j)=>{
1142
+ const c=j%2===0?"primary":"secondary";
1143
+ const w=Math.min(100,(val/maxV)*100);
1144
+ 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>`;
1145
+ }).join("");
1146
+ }
1147
+ const ttl=document.getElementById("topTagsList");
1148
+ const colors=["secondary","primary","tertiary","on-surface-dim"];
1149
+ if(entries.length){
1150
+ ttl.innerHTML=entries.slice(0,4).map(([tag,val],j)=>{
1151
+ const c=colors[j%colors.length];
1152
+ const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
1153
+ 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>`;
1154
+ }).join("");
1155
+ }
1156
+
1157
+ document.getElementById("compEng").textContent=lastStep.competitor_avg_engagement.toFixed(3);
1158
+ currentObs={engagement_rate:lastStep.engagement_rate,metadata:{}};
1159
+ }
1160
+
1161
+ // Show grader card
1162
+ const gc=document.getElementById("graderCard");
1163
+ gc.classList.remove("hidden");
1164
+ document.getElementById("graderScore").textContent=sc.toFixed(4);
1165
+ const lbl=document.getElementById("graderLabel");
1166
+ if(sc>=0.7)lbl.textContent="Excellent performance!";
1167
+ else if(sc>=0.4)lbl.textContent="Decent strategy, room for improvement";
1168
+ else lbl.textContent="Poor performance — agent needs better strategy";
1169
+
1170
+ const res=document.getElementById("simResult");
1171
+ res.classList.remove("hidden");
1172
+ const scoreColor=sc>=0.7?"text-primary":sc>=0.3?"text-secondary":"text-tertiary";
1173
+ 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";
1174
+ res.innerHTML=`
1175
+ <div class="p-4 rounded-xl border ${scoreBg} space-y-2">
1176
+ <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>
1177
+ <div class="grid grid-cols-2 gap-x-6 gap-y-1 text-[10px] font-label">
1178
+ <div class="flex justify-between"><span class="text-on-surface-dim">Days</span><span>${d.total_steps}</span></div>
1179
+ <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>
1180
+ <div class="flex justify-between"><span class="text-on-surface-dim">Final Energy</span><span>${f.energy.toFixed(2)}</span></div>
1181
+ <div class="flex justify-between"><span class="text-on-surface-dim">Followers</span><span>${f.followers.toLocaleString()}</span></div>
1182
+ <div class="flex justify-between"><span class="text-on-surface-dim">Engagement</span><span>${f.engagement_rate.toFixed(4)}</span></div>
1183
+ <div class="flex justify-between"><span class="text-on-surface-dim">Total Posts</span><span>${totalPostsCount}</span></div>
1184
+ </div>
1185
+ </div>`;
1186
+ updateBottomStats();
1187
+ setStatus("Simulation Done");
1188
+ loadHistory();
1189
+ }catch(e){setStatus("Error: "+e.message)}
1190
+ document.querySelectorAll(".sim-btn").forEach(b=>b.classList.remove("opacity-30","pointer-events-none"));
1191
+ simRunning=false;
1192
+ }
1193
+
1194
+ function showPostForm(){document.getElementById("postForm").classList.remove("hidden")}
1195
+ function hidePostForm(){document.getElementById("postForm").classList.add("hidden")}
1196
+ function setStatus(s){
1197
+ const el=document.getElementById("statusDot");
1198
+ const color=s.includes("Error")?"text-error":s==="Running"?"text-secondary":s.includes("Done")?"text-primary":"text-on-surface-dim";
1199
+ el.className="flex items-center gap-2 text-xs font-label "+color;
1200
+ el.innerHTML=`<span class="w-2 h-2 rounded-full ${color.replace("text-","bg-")}"></span>${s}`;
1201
+ }
1202
+
1203
+ async function loadHistory(){
1204
+ try{
1205
+ const r=await fetch(API+"/dashboard/history");
1206
+ const data=await r.json();
1207
+ const tb=document.getElementById("historyTable");
1208
+ 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}
1209
+ const taskLabels={monthly_engage:"Easy",monthly_strategic:"Medium",monthly_competitive:"Hard",weekly_engage:"Easy",weekly_strategic:"Medium",weekly_competitive:"Hard"};
1210
+ tb.innerHTML=data.slice().reverse().map(h=>{
1211
+ const dt=new Date(h.id);
1212
+ const time=dt.toLocaleDateString("en-US",{month:"short",day:"numeric"})+' '+dt.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"});
1213
+ const f=h.final||{};
1214
+ const delta=f.followers-10000;
1215
+ const deltaStr=(delta>=0?"+":"")+delta.toLocaleString();
1216
+ const deltaClass=delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim";
1217
+ const scoreColor=h.score>=0.7?"text-primary":h.score>=0.3?"text-secondary":"text-tertiary";
1218
+ const status=f.burned_out?'<span class="text-tertiary font-bold">BURNED</span>':h.total_steps>=EPISODE_DAYS?'<span class="text-secondary">DONE</span>':'<span class="text-on-surface-dim">PARTIAL</span>';
1219
+ const energyColor=f.energy>=0.5?"text-secondary":f.energy>0?"text-tertiary":"text-error";
1220
+ const desc=(h.description||"").trim();
1221
+ return `<tr class="border-b border-white/5 hover:bg-white/[.02] transition">
1222
+ <td class="px-4 py-2.5 text-on-surface-dim whitespace-nowrap">${time}</td>
1223
+ <td class="px-4 py-2.5 min-w-[14rem] max-w-lg align-top">
1224
+ <div class="text-on-surface font-bold">${_escapeHtml(h.scenario)}</div>
1225
+ ${desc?`<div class="text-[10px] text-on-surface/75 mt-1 leading-relaxed whitespace-normal">${_escapeHtml(desc)}</div>`:""}
1226
+ </td>
1227
+ <td class="px-4 py-2.5 text-on-surface-dim">${taskLabels[h.task]||h.task}</td>
1228
+ <td class="px-4 py-2.5 text-right ${scoreColor} font-bold">${h.score.toFixed(4)}</td>
1229
+ <td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_steps}</td>
1230
+ <td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_posts}</td>
1231
+ <td class="px-4 py-2.5 text-right text-on-surface">${(f.followers||0).toLocaleString()}</td>
1232
+ <td class="px-4 py-2.5 text-right ${deltaClass}">${deltaStr}</td>
1233
+ <td class="px-4 py-2.5 text-right ${energyColor}">${(f.energy||0).toFixed(2)}</td>
1234
+ <td class="px-4 py-2.5 text-center">${status}</td>
1235
+ </tr>`;
1236
+ }).join("");
1237
+ }catch(e){console.error("History load failed",e)}
1238
+ }
1239
+
1240
+ async function clearHistory(){
1241
+ if(!confirm("Clear all simulation history?"))return;
1242
+ await fetch(API+"/dashboard/history",{method:"DELETE"});
1243
+ loadHistory();
1244
+ }
1245
+
1246
+ function _escapeHtml(t){
1247
+ const d=document.createElement("div");
1248
+ d.textContent=t??"";
1249
+ return d.innerHTML;
1250
+ }
1251
+
1252
+ let _scenarioItems=[];
1253
+
1254
+ async function loadScenarioButtons(){
1255
+ const grid=document.getElementById("scenarioGrid");
1256
+ const countEl=document.getElementById("scenarioCount");
1257
+ const filterEl=document.getElementById("scenarioFilter");
1258
+ if(!grid)return;
1259
+ try{
1260
+ const r=await fetch(API+"/dashboard/scenarios",{cache:"no-store",headers:{"Cache-Control":"no-cache"}});
1261
+ const data=await r.json();
1262
+ _scenarioItems=data.scenarios||[];
1263
+ if(countEl)countEl.textContent=_scenarioItems.length+" strategies";
1264
+ 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"]);
1265
+ _scenarioItems.sort((a,b)=>{
1266
+ const pa=pin.has(a.id)?0:1,pb=pin.has(b.id)?0:1;
1267
+ if(pa!==pb)return pa-pb;
1268
+ return (a.label||"").localeCompare(b.label||"","en",{sensitivity:"base"});
1269
+ });
1270
+ function render(){
1271
+ const q=(filterEl&&filterEl.value||"").trim().toLowerCase();
1272
+ grid.innerHTML="";
1273
+ let n=0;
1274
+ for(const s of _scenarioItems){
1275
+ const lab=(s.label||"").toLowerCase();
1276
+ const id=(s.id||"").toLowerCase();
1277
+ const desc=(s.description||"").toLowerCase();
1278
+ if(q&&!(lab.includes(q)||id.includes(q)||desc.includes(q)))continue;
1279
+ n++;
1280
+ const btn=document.createElement("button");
1281
+ btn.type="button";
1282
+ btn.className="sim-btn p-2.5 rounded-lg bg-surface border border-outline/20 hover:border-secondary/40 text-left transition";
1283
+ if(pin.has(s.id))btn.classList.add("border-primary/25","hover:border-primary/55");
1284
+ btn.onclick=()=>runSim(s.id);
1285
+ 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>`;
1286
+ grid.appendChild(btn);
1287
+ }
1288
+ 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>';
1289
+ }
1290
+ if(filterEl)filterEl.oninput=render;
1291
+ render();
1292
+ }catch(e){
1293
+ console.error(e);
1294
+ grid.innerHTML='<div class="col-span-full text-error text-[10px] py-3">Could not load strategies. Refresh the page.</div>';
1295
+ if(countEl)countEl.textContent="";
1296
+ }
1297
+ }
1298
+
1299
+ (function(){const h=document.getElementById("episodeHorizon");if(h)h.textContent=String(EPISODE_DAYS);})();
1300
+ loadScenarioButtons();
1301
+ loadHistory();
1302
+ doReset();
1303
+ refreshTaskScoreBlurb();
1304
+ </script>
1305
+ </body>
1306
+ </html>
server/data/audience_overlap_matrix.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "8x8 symmetric audience overlap matrix between competitor archetypes and the user creator. Values 0.0-1.0 represent fraction of shared audience. Used by propose_collab to compute collab reward multipliers and by query_creator_pool to expose overlap to the agent. Same-niche pairs ~0.4-0.65, cross-niche ~0.05-0.20.",
4
+ "source": "Competitor pairs estimated from Rival IQ 2025 cross-industry overlap patterns + niche proximity heuristic. user_creator row tuned to a generic micro-creator (no locked niche): broad mass-market partners (lifestyle_blogger, viral_chaser) score highest; specialist partners (b2b_thought_leader, niche_expert) score lowest."
5
+ },
6
+ "archetype_ids": ["niche_expert", "viral_chaser", "lifestyle_blogger", "b2b_thought_leader", "food_creator", "fitness_coach", "travel_creator", "user_creator"],
7
+ "matrix": [
8
+ [1.00, 0.12, 0.10, 0.40, 0.08, 0.10, 0.15, 0.10],
9
+ [0.12, 1.00, 0.55, 0.10, 0.20, 0.25, 0.30, 0.35],
10
+ [0.10, 0.55, 1.00, 0.15, 0.30, 0.35, 0.40, 0.40],
11
+ [0.40, 0.10, 0.15, 1.00, 0.08, 0.10, 0.12, 0.08],
12
+ [0.08, 0.20, 0.30, 0.08, 1.00, 0.45, 0.35, 0.25],
13
+ [0.10, 0.25, 0.35, 0.10, 0.45, 1.00, 0.30, 0.28],
14
+ [0.15, 0.30, 0.40, 0.12, 0.35, 0.30, 1.00, 0.30],
15
+ [0.10, 0.35, 0.40, 0.08, 0.25, 0.28, 0.30, 1.00]
16
+ ]
17
+ }
server/data/audience_segments.json ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "5 hidden audience segments the agent discovers via query_audience tool. Based on Pew Research 2024 (teens survey n=1391; adults survey n=5733) and Sprout Social Index 2025 (n=4044 consumers). Agent sees segment names but must query to learn affinities.",
4
+ "hidden_from_default_obs": true
5
+ },
6
+ "segments": [
7
+ {
8
+ "id": "young_professionals",
9
+ "label": "Young Professionals (22-34)",
10
+ "size_fraction": 0.35,
11
+ "timezone_peak_offset_hours": 0,
12
+ "topic_affinity": {
13
+ "tech": 0.9,
14
+ "business": 0.8,
15
+ "lifestyle": 0.6,
16
+ "fitness": 0.7,
17
+ "food": 0.5
18
+ },
19
+ "content_type_preference": {
20
+ "reel": 0.9,
21
+ "carousel": 0.7,
22
+ "story": 0.8,
23
+ "text_post": 0.4
24
+ },
25
+ "active_hours": [7, 8, 9, 12, 13, 18, 19, 20, 21, 22]
26
+ },
27
+ {
28
+ "id": "students",
29
+ "label": "Students (16-22)",
30
+ "size_fraction": 0.25,
31
+ "timezone_peak_offset_hours": 2,
32
+ "topic_affinity": {
33
+ "lifestyle": 0.9,
34
+ "fitness": 0.6,
35
+ "education": 0.7,
36
+ "food": 0.8,
37
+ "fashion": 0.8
38
+ },
39
+ "content_type_preference": {
40
+ "reel": 1.0,
41
+ "carousel": 0.5,
42
+ "story": 0.9,
43
+ "text_post": 0.2
44
+ },
45
+ "active_hours": [10, 11, 12, 13, 14, 15, 20, 21, 22, 23]
46
+ },
47
+ {
48
+ "id": "parents",
49
+ "label": "Parents (30-45)",
50
+ "size_fraction": 0.20,
51
+ "timezone_peak_offset_hours": -1,
52
+ "topic_affinity": {
53
+ "food": 0.9,
54
+ "fitness": 0.7,
55
+ "lifestyle": 0.8,
56
+ "education": 0.6,
57
+ "travel": 0.5
58
+ },
59
+ "content_type_preference": {
60
+ "reel": 0.6,
61
+ "carousel": 0.9,
62
+ "story": 0.7,
63
+ "text_post": 0.6
64
+ },
65
+ "active_hours": [6, 7, 8, 12, 13, 20, 21]
66
+ },
67
+ {
68
+ "id": "global_night_owls",
69
+ "label": "Global Night Owls (mixed age, non-US timezone)",
70
+ "size_fraction": 0.12,
71
+ "timezone_peak_offset_hours": 8,
72
+ "topic_affinity": {
73
+ "tech": 0.8,
74
+ "photography": 0.7,
75
+ "travel": 0.8,
76
+ "lifestyle": 0.5,
77
+ "beauty": 0.4
78
+ },
79
+ "content_type_preference": {
80
+ "reel": 0.8,
81
+ "carousel": 0.8,
82
+ "story": 0.5,
83
+ "text_post": 0.5
84
+ },
85
+ "active_hours": [0, 1, 2, 3, 14, 15, 16, 17]
86
+ },
87
+ {
88
+ "id": "passive_scrollers",
89
+ "label": "Passive Scrollers (35-55, low engagement)",
90
+ "size_fraction": 0.08,
91
+ "timezone_peak_offset_hours": 0,
92
+ "topic_affinity": {
93
+ "travel": 0.6,
94
+ "food": 0.7,
95
+ "photography": 0.8,
96
+ "lifestyle": 0.5,
97
+ "fashion": 0.4
98
+ },
99
+ "content_type_preference": {
100
+ "reel": 0.4,
101
+ "carousel": 0.6,
102
+ "story": 0.3,
103
+ "text_post": 0.7
104
+ },
105
+ "active_hours": [7, 8, 12, 19, 20, 21]
106
+ }
107
+ ]
108
+ }
server/data/competitors.json ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "7 competitor archetypes. posts_per_week from Buffer 2.1M study (3-5 optimal). base_engagement_rate from Rival IQ 2025 per-industry. posting_frequency is posts/WEEK (divide by 7 for daily probability).",
4
+ "sources": ["Buffer 2026 frequency study (2.1M posts, 102K accounts)", "Rival IQ 2025 Benchmark (1.9M IG posts, 14 industries)"]
5
+ },
6
+ "archetypes": [
7
+ {
8
+ "id": "niche_expert",
9
+ "name": "Creator Alpha (Niche Expert)",
10
+ "niche": "tech",
11
+ "niche_topics": ["AI tools", "coding tips", "tech news", "prompt engineering"],
12
+ "preferred_types": ["carousel", "text_post"],
13
+ "posts_per_week": 3,
14
+ "base_engagement_rate": 0.55,
15
+ "tag_preferences": ["ai", "coding", "devtools", "buildinpublic"],
16
+ "style": "low_frequency_high_depth"
17
+ },
18
+ {
19
+ "id": "viral_chaser",
20
+ "name": "Creator Beta (Viral Chaser)",
21
+ "niche": "lifestyle",
22
+ "niche_topics": ["morning routine", "self improvement", "productivity hacks", "digital detox"],
23
+ "preferred_types": ["reel", "story"],
24
+ "posts_per_week": 7,
25
+ "base_engagement_rate": 0.38,
26
+ "tag_preferences": ["viral", "trending", "motivation", "grwm"],
27
+ "style": "high_frequency_volatile"
28
+ },
29
+ {
30
+ "id": "lifestyle_blogger",
31
+ "name": "Creator Gamma (Lifestyle Blogger)",
32
+ "niche": "lifestyle",
33
+ "niche_topics": ["minimalist living", "slow living", "work life balance", "journaling"],
34
+ "preferred_types": ["carousel", "reel"],
35
+ "posts_per_week": 4,
36
+ "base_engagement_rate": 0.45,
37
+ "tag_preferences": ["lifestyle", "wellness", "selfcare", "minimalism"],
38
+ "style": "consistent_moderate"
39
+ },
40
+ {
41
+ "id": "b2b_thought_leader",
42
+ "name": "Creator Delta (B2B Thought Leader)",
43
+ "niche": "business",
44
+ "niche_topics": ["growth hacks", "marketing strategy", "personal branding", "sales funnel"],
45
+ "preferred_types": ["carousel", "text_post"],
46
+ "posts_per_week": 3,
47
+ "base_engagement_rate": 0.42,
48
+ "tag_preferences": ["entrepreneur", "businesstips", "growth", "leadership"],
49
+ "style": "low_frequency_high_depth"
50
+ },
51
+ {
52
+ "id": "food_creator",
53
+ "name": "Creator Epsilon (Food Creator)",
54
+ "niche": "food",
55
+ "niche_topics": ["food recipe", "meal prep ideas", "baking tutorial", "food photography"],
56
+ "preferred_types": ["reel", "carousel"],
57
+ "posts_per_week": 5,
58
+ "base_engagement_rate": 0.48,
59
+ "tag_preferences": ["foodie", "recipe", "cooking", "healthyfood"],
60
+ "style": "consistent_moderate"
61
+ },
62
+ {
63
+ "id": "fitness_coach",
64
+ "name": "Creator Zeta (Fitness Coach)",
65
+ "niche": "fitness",
66
+ "niche_topics": ["fitness routine", "home workout", "gym transformation", "strength training"],
67
+ "preferred_types": ["reel", "story"],
68
+ "posts_per_week": 5,
69
+ "base_engagement_rate": 0.52,
70
+ "tag_preferences": ["fitness", "gym", "workout", "fitfam"],
71
+ "style": "high_frequency_volatile"
72
+ },
73
+ {
74
+ "id": "travel_creator",
75
+ "name": "Creator Eta (Travel Creator)",
76
+ "niche": "travel",
77
+ "niche_topics": ["travel guide", "hidden gems", "travel photography", "digital nomad"],
78
+ "preferred_types": ["reel", "carousel"],
79
+ "posts_per_week": 3,
80
+ "base_engagement_rate": 0.50,
81
+ "tag_preferences": ["travel", "wanderlust", "adventure", "travelgram"],
82
+ "style": "low_frequency_high_depth"
83
+ }
84
+ ]
85
+ }
server/data/hour_heatmap.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "7×24 engagement multiplier grid (day_of_week × hour). 1.0 = platform-wide average. Sources: Buffer 2026 (9.6M posts), Sprout Social 2026 (2B engagements, 307K profiles). Days: 0=Mon..6=Sun. Hours: 0-23 local time.",
4
+ "methodology": "Buffer identified per-day best hours; Sprout provided per-industry peak windows. Cross-referenced: peaks where both agree get 1.3-1.5×; dead zones where both agree get 0.3-0.5×. Intermediate hours interpolated."
5
+ },
6
+ "grid": {
7
+ "0": [0.30, 0.25, 0.25, 0.25, 0.30, 0.35, 0.50, 0.65, 0.80, 0.90, 0.95, 1.00, 1.05, 1.10, 1.20, 1.15, 1.10, 1.05, 1.20, 1.30, 1.25, 1.15, 1.00, 0.60],
8
+ "1": [0.30, 0.25, 0.25, 0.25, 0.30, 0.35, 0.50, 0.70, 0.85, 0.95, 1.05, 1.10, 1.20, 1.35, 1.40, 1.35, 1.25, 1.20, 1.30, 1.35, 1.25, 1.10, 0.95, 0.55],
9
+ "2": [0.30, 0.25, 0.25, 0.25, 0.30, 0.35, 0.55, 0.75, 0.95, 1.05, 1.10, 1.15, 1.35, 1.45, 1.45, 1.40, 1.30, 1.25, 1.40, 1.45, 1.40, 1.30, 1.10, 0.60],
10
+ "3": [0.30, 0.25, 0.25, 0.25, 0.30, 0.35, 0.55, 0.80, 1.05, 1.25, 1.15, 1.10, 1.30, 1.35, 1.30, 1.20, 1.10, 1.05, 1.15, 1.20, 1.10, 1.00, 0.85, 0.50],
11
+ "4": [0.30, 0.25, 0.25, 0.25, 0.30, 0.35, 0.50, 0.60, 0.70, 0.75, 0.80, 0.80, 0.85, 0.85, 0.80, 0.75, 0.70, 0.65, 0.70, 0.75, 0.70, 0.80, 0.85, 0.50],
12
+ "5": [0.30, 0.25, 0.25, 0.25, 0.30, 0.30, 0.40, 0.45, 0.50, 0.55, 0.60, 0.60, 0.65, 0.65, 0.60, 0.55, 0.55, 0.50, 0.55, 0.60, 0.65, 0.75, 0.80, 0.50],
13
+ "6": [0.30, 0.25, 0.25, 0.25, 0.30, 0.30, 0.40, 0.50, 0.55, 0.60, 0.65, 0.70, 0.70, 0.70, 0.65, 0.60, 0.55, 0.55, 0.60, 0.70, 0.80, 0.85, 0.80, 0.55]
14
+ }
15
+ }
server/data/tags.json ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "Instagram tag pool tiered by usage volume. Sources: Rival IQ 2025 Benchmark (1.9M IG posts), Socialinsider 2026 (31M posts).",
4
+ "tiers": {
5
+ "broad": "High-volume generic tags (>100M posts). High reach, low engagement lift.",
6
+ "niche": "Mid-volume vertical tags (1M-100M). Better engagement, narrower audience.",
7
+ "trending": "Rotated daily by env. Volatile reach bonus.",
8
+ "seasonal": "Calendar-driven. Active only near their season window."
9
+ }
10
+ },
11
+ "broad": [
12
+ {"tag": "love", "volume_hint": "2.1B"},
13
+ {"tag": "instagood", "volume_hint": "1.9B"},
14
+ {"tag": "photography", "volume_hint": "1.1B"},
15
+ {"tag": "photooftheday", "volume_hint": "1B"},
16
+ {"tag": "reels", "volume_hint": "985M"},
17
+ {"tag": "beautiful", "volume_hint": "854M"},
18
+ {"tag": "nature", "volume_hint": "838M"},
19
+ {"tag": "travel", "volume_hint": "767M"},
20
+ {"tag": "happy", "volume_hint": "728M"},
21
+ {"tag": "style", "volume_hint": "683M"},
22
+ {"tag": "fitness", "volume_hint": "560M"},
23
+ {"tag": "food", "volume_hint": "538M"},
24
+ {"tag": "life", "volume_hint": "471M"},
25
+ {"tag": "motivation", "volume_hint": "423M"},
26
+ {"tag": "art", "volume_hint": "900M"},
27
+ {"tag": "music", "volume_hint": "491M"},
28
+ {"tag": "trending", "volume_hint": "350M"},
29
+ {"tag": "lifestyle", "volume_hint": "340M"},
30
+ {"tag": "explore", "volume_hint": "330M"},
31
+ {"tag": "health", "volume_hint": "280M"},
32
+ {"tag": "design", "volume_hint": "360M"},
33
+ {"tag": "inspiration", "volume_hint": "400M"},
34
+ {"tag": "viral", "volume_hint": "200M"},
35
+ {"tag": "tips", "volume_hint": "180M"},
36
+ {"tag": "howto", "volume_hint": "120M"}
37
+ ],
38
+ "niche": {
39
+ "tech": [
40
+ {"tag": "ai", "volume_hint": "85M"},
41
+ {"tag": "ml", "volume_hint": "12M"},
42
+ {"tag": "coding", "volume_hint": "45M"},
43
+ {"tag": "startup", "volume_hint": "38M"},
44
+ {"tag": "saas", "volume_hint": "4M"},
45
+ {"tag": "devtools", "volume_hint": "2M"},
46
+ {"tag": "techreview", "volume_hint": "8M"},
47
+ {"tag": "artificialintelligence", "volume_hint": "22M"},
48
+ {"tag": "futuretech", "volume_hint": "5M"},
49
+ {"tag": "programming", "volume_hint": "30M"},
50
+ {"tag": "webdev", "volume_hint": "15M"},
51
+ {"tag": "buildinpublic", "volume_hint": "1.5M"},
52
+ {"tag": "technews", "volume_hint": "10M"},
53
+ {"tag": "gadgets", "volume_hint": "18M"}
54
+ ],
55
+ "lifestyle": [
56
+ {"tag": "grwm", "volume_hint": "45M"},
57
+ {"tag": "wellness", "volume_hint": "65M"},
58
+ {"tag": "selfcare", "volume_hint": "55M"},
59
+ {"tag": "minimalism", "volume_hint": "18M"},
60
+ {"tag": "stoic", "volume_hint": "5M"},
61
+ {"tag": "productivity", "volume_hint": "25M"},
62
+ {"tag": "mentalhealth", "volume_hint": "40M"},
63
+ {"tag": "healthylifestyle", "volume_hint": "80M"},
64
+ {"tag": "luxurylifestyle", "volume_hint": "30M"},
65
+ {"tag": "goodlife", "volume_hint": "20M"}
66
+ ],
67
+ "fitness": [
68
+ {"tag": "gym", "volume_hint": "120M"},
69
+ {"tag": "workout", "volume_hint": "95M"},
70
+ {"tag": "fitfam", "volume_hint": "55M"},
71
+ {"tag": "bodybuilding", "volume_hint": "42M"},
72
+ {"tag": "running", "volume_hint": "38M"},
73
+ {"tag": "yoga", "volume_hint": "60M"},
74
+ {"tag": "fitover40", "volume_hint": "2M"},
75
+ {"tag": "homeworkout", "volume_hint": "15M"},
76
+ {"tag": "gymlife", "volume_hint": "35M"},
77
+ {"tag": "nutrition", "volume_hint": "28M"}
78
+ ],
79
+ "business": [
80
+ {"tag": "entrepreneur", "volume_hint": "90M"},
81
+ {"tag": "smallbusiness", "volume_hint": "75M"},
82
+ {"tag": "businesstips", "volume_hint": "20M"},
83
+ {"tag": "sidehustle", "volume_hint": "15M"},
84
+ {"tag": "growyourbusiness", "volume_hint": "10M"},
85
+ {"tag": "financialfreedom", "volume_hint": "18M"},
86
+ {"tag": "passiveincome", "volume_hint": "12M"},
87
+ {"tag": "growth", "volume_hint": "45M"},
88
+ {"tag": "leadership", "volume_hint": "22M"},
89
+ {"tag": "digitalmarketing", "volume_hint": "35M"}
90
+ ],
91
+ "food": [
92
+ {"tag": "foodie", "volume_hint": "110M"},
93
+ {"tag": "recipe", "volume_hint": "55M"},
94
+ {"tag": "healthyfood", "volume_hint": "65M"},
95
+ {"tag": "cooking", "volume_hint": "45M"},
96
+ {"tag": "mealprep", "volume_hint": "18M"},
97
+ {"tag": "vegan", "volume_hint": "40M"},
98
+ {"tag": "baking", "volume_hint": "30M"}
99
+ ],
100
+ "travel": [
101
+ {"tag": "wanderlust", "volume_hint": "85M"},
102
+ {"tag": "travelgram", "volume_hint": "70M"},
103
+ {"tag": "adventure", "volume_hint": "60M"},
104
+ {"tag": "backpacking", "volume_hint": "20M"},
105
+ {"tag": "roadtrip", "volume_hint": "25M"},
106
+ {"tag": "solotravel", "volume_hint": "12M"},
107
+ {"tag": "islandlife", "volume_hint": "15M"}
108
+ ],
109
+ "fashion": [
110
+ {"tag": "ootd", "volume_hint": "95M"},
111
+ {"tag": "fashionblogger", "volume_hint": "65M"},
112
+ {"tag": "streetstyle", "volume_hint": "40M"},
113
+ {"tag": "skincare", "volume_hint": "55M"},
114
+ {"tag": "makeup", "volume_hint": "80M"}
115
+ ],
116
+ "web3": [
117
+ {"tag": "web3", "volume_hint": "8M"},
118
+ {"tag": "crypto", "volume_hint": "35M"},
119
+ {"tag": "nft", "volume_hint": "25M"},
120
+ {"tag": "blockchain", "volume_hint": "18M"},
121
+ {"tag": "defi", "volume_hint": "5M"},
122
+ {"tag": "gaming", "volume_hint": "50M"}
123
+ ]
124
+ },
125
+ "trending": [
126
+ {"tag": "aitools2026", "volume_hint": "3M"},
127
+ {"tag": "techtrends2026", "volume_hint": "2M"},
128
+ {"tag": "chatgpt", "volume_hint": "15M"},
129
+ {"tag": "midjourney", "volume_hint": "8M"},
130
+ {"tag": "threads", "volume_hint": "12M"},
131
+ {"tag": "climateaction", "volume_hint": "6M"},
132
+ {"tag": "genai", "volume_hint": "4M"},
133
+ {"tag": "remotework", "volume_hint": "18M"},
134
+ {"tag": "creatoreconomy", "volume_hint": "5M"},
135
+ {"tag": "sustainableliving", "volume_hint": "10M"}
136
+ ],
137
+ "seasonal": [
138
+ {"tag": "summer", "volume_hint": "300M", "active_months": [5, 6, 7, 8]},
139
+ {"tag": "newyear", "volume_hint": "150M", "active_months": [12, 1]},
140
+ {"tag": "worldcup", "volume_hint": "80M", "active_months": [6, 7]},
141
+ {"tag": "oscars", "volume_hint": "45M", "active_months": [2, 3]},
142
+ {"tag": "election", "volume_hint": "60M", "active_months": [10, 11]},
143
+ {"tag": "blackfriday", "volume_hint": "55M", "active_months": [11]},
144
+ {"tag": "christmas", "volume_hint": "200M", "active_months": [11, 12]},
145
+ {"tag": "backtoschool", "volume_hint": "30M", "active_months": [8, 9]},
146
+ {"tag": "valentines", "volume_hint": "70M", "active_months": [1, 2]},
147
+ {"tag": "halloween", "volume_hint": "90M", "active_months": [10]}
148
+ ]
149
+ }
server/data/topics.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "description": "Niche → topics with engagement multipliers and seasonal trending calendar. Multipliers from Rival IQ 2025 Benchmark (1.9M IG posts, 14 industries). Normalized so overall avg ≈ 1.0.",
4
+ "multiplier_source": "Rival IQ 2025: Animals 2.00%, Photo 1.99%, Outdoors 1.91%, Travel 1.83%, Sports/Fitness 1.75%, Music 1.63%, Entertainment 1.55%, Food 1.55%, Lifestyle 1.53%, Education 1.48%, Finance 1.34%, Tech 1.31%, Real Estate 1.25%, Fashion 1.24%, Beauty 1.19%. Normalized by dividing by median (1.53)."
5
+ },
6
+ "niches": {
7
+ "tech": {
8
+ "engagement_multiplier": 0.86,
9
+ "topics": [
10
+ "AI tools", "coding tips", "startup life", "tech news",
11
+ "SaaS growth", "dev workflow", "open source", "gadget review",
12
+ "prompt engineering", "AI art"
13
+ ]
14
+ },
15
+ "lifestyle": {
16
+ "engagement_multiplier": 1.00,
17
+ "topics": [
18
+ "morning routine", "minimalist living", "self improvement",
19
+ "productivity hacks", "mental health", "stoic philosophy",
20
+ "journaling", "digital detox", "work life balance", "slow living"
21
+ ]
22
+ },
23
+ "fitness": {
24
+ "engagement_multiplier": 1.14,
25
+ "topics": [
26
+ "fitness routine", "home workout", "running tips",
27
+ "gym transformation", "meal prep", "yoga flow",
28
+ "strength training", "recovery", "marathon training", "calisthenics"
29
+ ]
30
+ },
31
+ "business": {
32
+ "engagement_multiplier": 0.88,
33
+ "topics": [
34
+ "growth hacks", "marketing strategy", "creator economy",
35
+ "monetization", "brand deals", "analytics deep dive",
36
+ "side hustle", "personal branding", "email marketing", "sales funnel"
37
+ ]
38
+ },
39
+ "food": {
40
+ "engagement_multiplier": 1.01,
41
+ "topics": [
42
+ "food recipe", "meal prep ideas", "restaurant review",
43
+ "baking tutorial", "healthy eating", "vegan recipes",
44
+ "street food", "coffee culture", "kitchen hacks", "food photography"
45
+ ]
46
+ },
47
+ "travel": {
48
+ "engagement_multiplier": 1.20,
49
+ "topics": [
50
+ "travel guide", "hidden gems", "budget travel",
51
+ "solo travel tips", "road trip", "beach destinations",
52
+ "cultural immersion", "travel photography", "hostel life", "digital nomad"
53
+ ]
54
+ },
55
+ "fashion": {
56
+ "engagement_multiplier": 0.81,
57
+ "topics": [
58
+ "fashion haul", "outfit of the day", "streetwear",
59
+ "sustainable fashion", "thrift finds", "seasonal trends",
60
+ "capsule wardrobe", "accessory styling", "luxury fashion", "sneaker culture"
61
+ ]
62
+ },
63
+ "beauty": {
64
+ "engagement_multiplier": 0.78,
65
+ "topics": [
66
+ "skincare routine", "makeup tutorial", "hair care",
67
+ "clean beauty", "anti aging", "nail art",
68
+ "fragrance review", "dermatologist tips", "glow up", "beauty on budget"
69
+ ]
70
+ },
71
+ "photography": {
72
+ "engagement_multiplier": 1.30,
73
+ "topics": [
74
+ "photo editing", "golden hour shots", "street photography",
75
+ "landscape photography", "portrait tips", "mobile photography",
76
+ "lightroom presets", "composition rules", "astrophotography", "film photography"
77
+ ]
78
+ },
79
+ "education": {
80
+ "engagement_multiplier": 0.97,
81
+ "topics": [
82
+ "study tips", "online courses", "career advice",
83
+ "book recommendations", "science explainer", "history facts",
84
+ "language learning", "financial literacy", "college life", "exam prep"
85
+ ]
86
+ }
87
+ },
88
+ "seasonal_trends": [
89
+ {"topic": "New Year goals", "peak_month": 1, "halflife_hours": 72, "niches": ["lifestyle", "fitness", "business"]},
90
+ {"topic": "Valentine gift guide", "peak_month": 2, "halflife_hours": 48, "niches": ["fashion", "food", "lifestyle"]},
91
+ {"topic": "Oscar predictions", "peak_month": 3, "halflife_hours": 36, "niches": ["lifestyle", "photography"]},
92
+ {"topic": "Spring fitness challenge", "peak_month": 4, "halflife_hours": 96, "niches": ["fitness"]},
93
+ {"topic": "Summer travel plans", "peak_month": 6, "halflife_hours": 120, "niches": ["travel", "photography"]},
94
+ {"topic": "World Cup watch party", "peak_month": 7, "halflife_hours": 60, "niches": ["lifestyle", "food"]},
95
+ {"topic": "Back to school essentials", "peak_month": 8, "halflife_hours": 72, "niches": ["education", "tech", "fashion"]},
96
+ {"topic": "Fall fashion lookbook", "peak_month": 9, "halflife_hours": 96, "niches": ["fashion", "beauty"]},
97
+ {"topic": "Halloween costumes", "peak_month": 10, "halflife_hours": 48, "niches": ["fashion", "lifestyle", "food"]},
98
+ {"topic": "Black Friday deals", "peak_month": 11, "halflife_hours": 36, "niches": ["tech", "business", "fashion"]},
99
+ {"topic": "Holiday gift guide", "peak_month": 12, "halflife_hours": 96, "niches": ["tech", "fashion", "food", "beauty"]},
100
+ {"topic": "Year in review", "peak_month": 12, "halflife_hours": 48, "niches": ["lifestyle", "business", "photography"]}
101
+ ]
102
+ }
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 @@
 
 
1
+ []
server/training.html ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Viraltest — Training Evidence</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-solid{background:#131b2e;border:1px solid rgba(73,68,84,.15)}
17
+ .fade-in{animation:fadeIn .3s ease}
18
+ @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
19
+ ::-webkit-scrollbar{width:6px}
20
+ ::-webkit-scrollbar-track{background:transparent}
21
+ ::-webkit-scrollbar-thumb{background:rgba(73,68,84,.4);border-radius:3px}
22
+ </style>
23
+ </head>
24
+ <body class="min-h-screen flex">
25
+
26
+ <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">
27
+ <div class="p-6 pb-4">
28
+ <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>
29
+ <div class="text-[9px] font-label uppercase tracking-[.2em] text-on-surface-dim/50">Training evidence</div>
30
+ </div>
31
+ <nav class="flex-1 px-3 space-y-1">
32
+ <a href="/dashboard" 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">
33
+ <span class="material-symbols-outlined text-[20px]">dashboard</span><span class="font-label text-sm">Dashboard</span>
34
+ </a>
35
+ <a href="/dashboard/training" 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">
36
+ <span class="material-symbols-outlined text-[20px]">science</span><span class="font-label text-sm">Training Evidence</span>
37
+ </a>
38
+ <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">
39
+ <span class="material-symbols-outlined text-[20px]">web</span><span class="font-label text-sm">OpenEnv UI</span>
40
+ </a>
41
+ </nav>
42
+ <div class="p-4 border-t border-white/5">
43
+ <div class="text-[9px] font-label text-on-surface-dim/60 leading-relaxed">
44
+ This page shows that the environment can <span class="text-on-surface font-bold">differentiate agent strategies</span> and produce meaningful reward signals for RL training.
45
+ </div>
46
+ </div>
47
+ </aside>
48
+
49
+ <div class="flex-1 flex flex-col min-w-0">
50
+ <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">
51
+ <div class="flex items-center gap-3">
52
+ <span class="material-symbols-outlined text-primary text-lg">science</span>
53
+ <h1 class="text-sm font-bold">Training Evidence — Baseline Leaderboard</h1>
54
+ </div>
55
+ <div class="flex items-center gap-3">
56
+ <span id="statusBadge" class="text-xs font-label text-on-surface-dim">Click "Run Baselines" to generate</span>
57
+ <button onclick="runBaselines()" id="runBtn" class="px-4 py-2 rounded-lg bg-gradient-to-br from-primary to-primary-ctr text-[#23005c] font-bold text-sm hover:opacity-90 transition active:scale-[.97]">
58
+ <span class="material-symbols-outlined text-[16px] align-middle mr-1">play_arrow</span>Run Baselines
59
+ </button>
60
+ </div>
61
+ </header>
62
+
63
+ <main class="flex-1 p-6 space-y-6 overflow-y-auto">
64
+
65
+ <div class="glass-solid border border-outline/20 rounded-xl px-5 py-4 space-y-3">
66
+ <div class="flex gap-3 items-start">
67
+ <span class="material-symbols-outlined text-primary text-lg shrink-0">info</span>
68
+ <div class="text-[11px] font-label text-on-surface-dim leading-relaxed flex-1 min-w-0">
69
+ <span class="text-on-surface font-semibold">What this proves:</span>
70
+ The environment produces a <span class="text-on-surface">rich, informative reward signal</span> that differentiates between agent strategies.
71
+ Smart agents (peak-hour posting, tag diversity, energy management) consistently outscore naive baselines (spam, random, always-rest).
72
+ This is the prerequisite for RL training &mdash; if the reward didn't differentiate, training couldn't improve behavior.
73
+ <div class="mt-2 text-on-surface font-semibold">5 heuristic strategies &times; 3 tasks = 15 runs, deterministic (seed=42).</div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <div id="loadingState" class="hidden">
79
+ <div class="flex items-center justify-center gap-4 py-12">
80
+ <div class="animate-spin h-8 w-8 border-4 border-primary/30 border-t-primary rounded-full"></div>
81
+ <span class="text-sm font-label text-on-surface-dim">Running all baseline scenarios... (~5 seconds)</span>
82
+ </div>
83
+ </div>
84
+
85
+ <div id="resultsSection" class="hidden space-y-6">
86
+
87
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
88
+ <div id="chart_engage" class="glass-solid p-5 rounded-xl overflow-hidden">
89
+ <h3 class="text-sm font-bold mb-1 text-secondary">Engage (Easy)</h3>
90
+ <p class="text-[9px] font-label text-on-surface-dim mb-3">Total engagement vs theoretical max</p>
91
+ <svg id="svg_engage" class="w-full" viewBox="0 0 380 240" preserveAspectRatio="xMidYMid meet"></svg>
92
+ </div>
93
+ <div id="chart_strategic" class="glass-solid p-5 rounded-xl overflow-hidden">
94
+ <h3 class="text-sm font-bold mb-1 text-primary">Strategic (Medium)</h3>
95
+ <p class="text-[9px] font-label text-on-surface-dim mb-3">Engagement + tag discovery + energy + consistency</p>
96
+ <svg id="svg_strategic" class="w-full" viewBox="0 0 380 240" preserveAspectRatio="xMidYMid meet"></svg>
97
+ </div>
98
+ <div id="chart_competitive" class="glass-solid p-5 rounded-xl overflow-hidden">
99
+ <h3 class="text-sm font-bold mb-1 text-tertiary">Competitive (Hard)</h3>
100
+ <p class="text-[9px] font-label text-on-surface-dim mb-3">+ growth vs competitors + differentiation</p>
101
+ <svg id="svg_competitive" class="w-full" viewBox="0 0 380 240" preserveAspectRatio="xMidYMid meet"></svg>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
106
+ <h3 class="text-sm font-bold mb-1 flex items-center gap-2">
107
+ <span class="material-symbols-outlined text-secondary text-lg">show_chart</span>
108
+ Reward Trajectories (30-day episodes)
109
+ </h3>
110
+ <p class="text-[9px] font-label text-on-surface-dim mb-3">Daily reward over the episode for each agent &times; task. Shows that smart strategies maintain higher rewards throughout.</p>
111
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
112
+ <div>
113
+ <div class="text-[10px] font-bold text-secondary uppercase tracking-widest mb-1">Engage</div>
114
+ <svg id="traj_engage" class="w-full" viewBox="0 0 400 180" preserveAspectRatio="xMidYMid meet"></svg>
115
+ </div>
116
+ <div>
117
+ <div class="text-[10px] font-bold text-primary uppercase tracking-widest mb-1">Strategic</div>
118
+ <svg id="traj_strategic" class="w-full" viewBox="0 0 400 180" preserveAspectRatio="xMidYMid meet"></svg>
119
+ </div>
120
+ <div>
121
+ <div class="text-[10px] font-bold text-tertiary uppercase tracking-widest mb-1">Competitive</div>
122
+ <svg id="traj_competitive" class="w-full" viewBox="0 0 400 180" preserveAspectRatio="xMidYMid meet"></svg>
123
+ </div>
124
+ </div>
125
+ <div id="trajectoryLegend" class="flex flex-wrap gap-4 mt-3 justify-center"></div>
126
+ </div>
127
+
128
+ <div class="glass-solid rounded-xl overflow-hidden">
129
+ <div class="p-4 border-b border-white/5">
130
+ <h3 class="text-sm font-bold flex items-center gap-2">
131
+ <span class="material-symbols-outlined text-primary text-lg">table_chart</span>
132
+ Full Results Table
133
+ </h3>
134
+ </div>
135
+ <div class="overflow-x-auto">
136
+ <table class="w-full text-[11px] font-label">
137
+ <thead>
138
+ <tr class="text-on-surface-dim/60 uppercase tracking-wider border-b border-white/5">
139
+ <th class="text-left px-4 py-2.5">Agent</th>
140
+ <th class="text-left px-4 py-2.5">Task</th>
141
+ <th class="text-right px-4 py-2.5">Grader Score</th>
142
+ <th class="text-right px-4 py-2.5">Total Reward</th>
143
+ <th class="text-right px-4 py-2.5">Steps</th>
144
+ <th class="text-right px-4 py-2.5">Energy</th>
145
+ <th class="text-right px-4 py-2.5">Followers</th>
146
+ <th class="text-right px-4 py-2.5">&Delta;</th>
147
+ <th class="text-center px-4 py-2.5">Status</th>
148
+ </tr>
149
+ </thead>
150
+ <tbody id="resultsTable"></tbody>
151
+ </table>
152
+ </div>
153
+ </div>
154
+
155
+ <div class="glass-solid p-5 rounded-xl overflow-hidden">
156
+ <h3 class="text-sm font-bold mb-3 flex items-center gap-2">
157
+ <span class="material-symbols-outlined text-tertiary text-lg">insights</span>
158
+ Key Takeaways
159
+ </h3>
160
+ <div id="takeaways" class="space-y-2 text-[11px] font-label text-on-surface-dim leading-relaxed"></div>
161
+ </div>
162
+ </div>
163
+
164
+ </main>
165
+ </div>
166
+
167
+ <script>
168
+ const API=window.location.origin;
169
+ const COLORS={"always_rest":"#E53935","spam":"#FF9800","random":"#9E9E9E","minimal":"#42A5F5","smart":"#4CAF50"};
170
+ const TASK_MAP={"monthly_engage":"engage","monthly_strategic":"strategic","monthly_competitive":"competitive"};
171
+ const TASK_LABELS={"monthly_engage":"Engage","monthly_strategic":"Strategic","monthly_competitive":"Competitive"};
172
+
173
+ let allData=null;
174
+
175
+ async function runBaselines(){
176
+ const btn=document.getElementById("runBtn");
177
+ btn.disabled=true;btn.classList.add("opacity-50");
178
+ document.getElementById("loadingState").classList.remove("hidden");
179
+ document.getElementById("resultsSection").classList.add("hidden");
180
+ document.getElementById("statusBadge").textContent="Running...";
181
+
182
+ try{
183
+ const r=await fetch(API+"/dashboard/training-evidence");
184
+ allData=await r.json();
185
+ renderAll();
186
+ document.getElementById("loadingState").classList.add("hidden");
187
+ document.getElementById("resultsSection").classList.remove("hidden");
188
+ document.getElementById("statusBadge").textContent=`${allData.results.length} runs completed`;
189
+ }catch(e){
190
+ document.getElementById("statusBadge").textContent="Error: "+e.message;
191
+ document.getElementById("loadingState").classList.add("hidden");
192
+ }
193
+ btn.disabled=false;btn.classList.remove("opacity-50");
194
+ }
195
+
196
+ function renderAll(){
197
+ if(!allData)return;
198
+ renderBarCharts();
199
+ renderTrajectories();
200
+ renderTable();
201
+ renderTakeaways();
202
+ }
203
+
204
+ function renderBarCharts(){
205
+ const tasks=["monthly_engage","monthly_strategic","monthly_competitive"];
206
+ for(const task of tasks){
207
+ const key=TASK_MAP[task];
208
+ const svg=document.getElementById("svg_"+key);
209
+ if(!svg)continue;
210
+
211
+ const taskResults=allData.results.filter(r=>r.task===task);
212
+ taskResults.sort((a,b)=>b.grader_score-a.grader_score);
213
+
214
+ const W=380,H=240,pL=110,pR=60,pT=10,pB=10;
215
+ const plotW=W-pL-pR,plotH=H-pT-pB;
216
+ const n=taskResults.length;
217
+ if(!n){svg.innerHTML="";continue;}
218
+ const barH=Math.min(28,plotH/n*0.7);
219
+ const gap=(plotH-barH*n)/(n+1);
220
+ const maxScore=Math.max(...taskResults.map(r=>r.grader_score),0.01);
221
+
222
+ let html="";
223
+ taskResults.forEach((r,i)=>{
224
+ const y=pT+gap+(barH+gap)*i;
225
+ const w=Math.max(2,(r.grader_score/Math.max(maxScore*1.1,0.01))*plotW);
226
+ const color=COLORS[r.scenario_id]||"#9E9E9E";
227
+ const burned=r.burned_out?" (BURNED)":"";
228
+
229
+ html+=`<rect x="${pL}" y="${y}" width="${w}" height="${barH}" fill="${color}" rx="4" opacity="0.85"/>`;
230
+ html+=`<text x="${pL-6}" y="${y+barH/2+4}" text-anchor="end" fill="#dae2fd" font-size="10" font-family="Space Grotesk,sans-serif" font-weight="600">${r.scenario}</text>`;
231
+ html+=`<text x="${pL+w+6}" y="${y+barH/2+4}" fill="${color}" font-size="11" font-family="Space Grotesk,sans-serif" font-weight="700">${r.grader_score.toFixed(4)}${burned}</text>`;
232
+ });
233
+
234
+ svg.innerHTML=html;
235
+ }
236
+ }
237
+
238
+ function smoothPath(pts){
239
+ if(pts.length<2)return pts.map((p,i)=>(i===0?"M":"L")+p.x.toFixed(1)+","+p.y.toFixed(1)).join(" ");
240
+ let d="M"+pts[0].x.toFixed(1)+","+pts[0].y.toFixed(1);
241
+ for(let i=1;i<pts.length;i++){
242
+ const cp=(pts[i].x-pts[i-1].x)/3;
243
+ 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)}`;
244
+ }
245
+ return d;
246
+ }
247
+
248
+ function renderTrajectories(){
249
+ const tasks=["monthly_engage","monthly_strategic","monthly_competitive"];
250
+ const legend=document.getElementById("trajectoryLegend");
251
+ let legendHtml="";
252
+
253
+ for(const task of tasks){
254
+ const key=TASK_MAP[task];
255
+ const svg=document.getElementById("traj_"+key);
256
+ if(!svg)continue;
257
+
258
+ const taskResults=allData.results.filter(r=>r.task===task);
259
+ const W=400,H=180,pL=40,pR=10,pT=10,pB=30;
260
+ const plotW=W-pL-pR,plotH=H-pT-pB;
261
+
262
+ let allRewards=[];
263
+ taskResults.forEach(r=>allRewards.push(...r.rewards));
264
+ const minR=Math.min(0,...allRewards);
265
+ const maxR=Math.max(...allRewards,0.01);
266
+
267
+ let html="";
268
+ for(let g=0;g<=4;g++){
269
+ const y=pT+(g/4)*plotH;
270
+ const val=maxR-(g/4)*(maxR-minR);
271
+ html+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.3"/>`;
272
+ html+=`<text x="${pL-5}" y="${y+3}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${val.toFixed(2)}</text>`;
273
+ }
274
+ html+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.7"/>`;
275
+ html+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.7"/>`;
276
+ html+=`<text x="${pL}" y="${H-10}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">Day 1</text>`;
277
+ html+=`<text x="${W-pR}" y="${H-10}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">Day 30</text>`;
278
+ html+=`<text x="${pL+plotW/2}" y="${H-2}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif" opacity="0.75">day</text>`;
279
+
280
+ taskResults.forEach(r=>{
281
+ const color=COLORS[r.scenario_id]||"#9E9E9E";
282
+ const rewards=r.rewards;
283
+ const n=rewards.length;
284
+ if(!n)return;
285
+ const pts=rewards.map((v,i)=>({
286
+ x:pL+(n<=1?plotW/2:i/(n-1)*plotW),
287
+ y:pT+(1-((v-minR)/(maxR-minR||1)))*plotH,
288
+ }));
289
+ const lineD=smoothPath(pts);
290
+ const opacity=r.scenario_id==="smart"?"1":"0.6";
291
+ const width=r.scenario_id==="smart"?"2.5":"1.5";
292
+ html+=`<path d="${lineD}" fill="none" stroke="${color}" stroke-width="${width}" opacity="${opacity}"/>`;
293
+ });
294
+
295
+ svg.innerHTML=html;
296
+ }
297
+
298
+ const scenarios=[...new Set(allData.results.map(r=>r.scenario_id))];
299
+ legendHtml=scenarios.map(sid=>{
300
+ const label=allData.results.find(r=>r.scenario_id===sid)?.scenario||sid;
301
+ const color=COLORS[sid]||"#9E9E9E";
302
+ return `<div class="flex items-center gap-1.5"><span class="w-3 h-1 rounded-full" style="background:${color}"></span><span class="text-[10px] font-label text-on-surface-dim">${label}</span></div>`;
303
+ }).join("");
304
+ legend.innerHTML=legendHtml;
305
+ }
306
+
307
+ function renderTable(){
308
+ const tb=document.getElementById("resultsTable");
309
+ const rows=allData.results.slice().sort((a,b)=>{
310
+ const taskOrder={"monthly_engage":0,"monthly_strategic":1,"monthly_competitive":2};
311
+ if(taskOrder[a.task]!==taskOrder[b.task])return taskOrder[a.task]-taskOrder[b.task];
312
+ return b.grader_score-a.grader_score;
313
+ });
314
+
315
+ tb.innerHTML=rows.map(r=>{
316
+ const color=COLORS[r.scenario_id]||"#9E9E9E";
317
+ const scoreColor=r.grader_score>=0.5?"text-primary":r.grader_score>=0.2?"text-secondary":"text-tertiary";
318
+ const energyColor=r.final_energy>=0.5?"text-secondary":r.final_energy>0?"text-tertiary":"text-error";
319
+ const deltaColor=r.follower_delta>0?"text-secondary":r.follower_delta<0?"text-tertiary":"text-on-surface-dim";
320
+ const status=r.burned_out?'<span class="text-tertiary font-bold">BURNED</span>':r.steps>=30?'<span class="text-secondary">DONE</span>':'<span class="text-on-surface-dim">EARLY</span>';
321
+ return `<tr class="border-b border-white/5 hover:bg-white/[.02]">
322
+ <td class="px-4 py-2"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full" style="background:${color}"></span><span class="text-on-surface font-bold">${r.scenario}</span></div></td>
323
+ <td class="px-4 py-2 text-on-surface-dim">${TASK_LABELS[r.task]||r.task}</td>
324
+ <td class="px-4 py-2 text-right ${scoreColor} font-bold">${r.grader_score.toFixed(4)}</td>
325
+ <td class="px-4 py-2 text-right text-on-surface-dim">${r.total_reward.toFixed(3)}</td>
326
+ <td class="px-4 py-2 text-right text-on-surface-dim">${r.steps}</td>
327
+ <td class="px-4 py-2 text-right ${energyColor}">${r.final_energy.toFixed(2)}</td>
328
+ <td class="px-4 py-2 text-right text-on-surface">${r.final_followers.toLocaleString()}</td>
329
+ <td class="px-4 py-2 text-right ${deltaColor}">${r.follower_delta>=0?"+":""}${r.follower_delta}</td>
330
+ <td class="px-4 py-2 text-center">${status}</td>
331
+ </tr>`;
332
+ }).join("");
333
+ }
334
+
335
+ function renderTakeaways(){
336
+ const el=document.getElementById("takeaways");
337
+ if(!allData)return;
338
+
339
+ const byScenario={};
340
+ allData.results.forEach(r=>{
341
+ if(!byScenario[r.scenario_id])byScenario[r.scenario_id]={scores:[],label:r.scenario};
342
+ byScenario[r.scenario_id].scores.push(r.grader_score);
343
+ });
344
+
345
+ const avgs=Object.entries(byScenario).map(([id,d])=>({
346
+ id,label:d.label,avg:d.scores.reduce((a,b)=>a+b,0)/d.scores.length
347
+ })).sort((a,b)=>b.avg-a.avg);
348
+
349
+ const best=avgs[0];
350
+ const worst=avgs[avgs.length-1];
351
+ const ratio=worst.avg>0?(best.avg/worst.avg).toFixed(1):"∞";
352
+
353
+ const burnedOut=allData.results.filter(r=>r.burned_out);
354
+ const completed=allData.results.filter(r=>!r.burned_out&&r.steps>=30);
355
+
356
+ const points=[
357
+ `<span class="text-on-surface font-bold">Best agent: ${best.label}</span> (avg score ${best.avg.toFixed(4)}) — ${ratio}× better than worst (${worst.label}, avg ${worst.avg.toFixed(4)}).`,
358
+ `<span class="text-on-surface font-bold">Score spread:</span> The environment produces a ${(avgs[0].avg-avgs[avgs.length-1].avg).toFixed(4)} spread between best and worst agents, proving the reward is informative and not flat.`,
359
+ `<span class="text-on-surface font-bold">${burnedOut.length} burnout events</span> across ${allData.results.length} runs — the burnout penalty correctly punishes unsustainable strategies (spam, no-rest).`,
360
+ `<span class="text-on-surface font-bold">${completed.length}/${allData.results.length} episodes completed</span> all 30 days — agents that manage energy survive; those that don't burn out early.`,
361
+ `<span class="text-on-surface font-bold">Reward is hard to game:</span> Spamming posts burns out immediately (score ≈ 0). Always resting loses followers. The optimal strategy requires balancing multiple objectives.`,
362
+ `<span class="text-on-surface font-bold">Grader difficulty scales correctly:</span> All agents score lower on Competitive than on Engage, confirming the three-tier difficulty progression works.`,
363
+ ];
364
+
365
+ el.innerHTML=points.map(p=>`<div class="flex gap-2"><span class="text-primary shrink-0">▸</span><span>${p}</span></div>`).join("");
366
+ }
367
+ </script>
368
+ </body>
369
+ </html>
server/viraltest_environment.py ADDED
@@ -0,0 +1,1313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Viraltest Environment v2 — Theme #3.1 World-Modeling Simulation.
3
+
4
+ 30-day creator optimization with:
5
+ - Mosseri-aligned engagement signals (watch_time, sends, saves, likes)
6
+ - Discoverable tool catalog (partial observability)
7
+ - Piecewise-linear sleep model (Van Dongen 2003)
8
+ - Data-driven hour heatmap (Buffer 9.6M + Sprout 2B)
9
+ - Tiered audience fatigue (Buffer 2.1M)
10
+ - Multi-episode brand persistence
11
+ - Counterfactual coach feedback
12
+ """
13
+
14
+ import json
15
+ import math
16
+ import random
17
+ from collections import defaultdict
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+ from uuid import uuid4
22
+
23
+ from openenv.core.env_server.interfaces import Environment
24
+ from openenv.core.env_server.types import State
25
+
26
+ try:
27
+ from ..models import (
28
+ CollabProposal,
29
+ EngagementSignals,
30
+ HeadlineMetrics,
31
+ JudgeReport,
32
+ ReplyAction,
33
+ ScheduledAction,
34
+ ToolCall,
35
+ ToolResult,
36
+ ViraltestAction,
37
+ ViraltestObservation,
38
+ )
39
+ except ImportError:
40
+ from models import (
41
+ CollabProposal,
42
+ EngagementSignals,
43
+ HeadlineMetrics,
44
+ JudgeReport,
45
+ ReplyAction,
46
+ ScheduledAction,
47
+ ToolCall,
48
+ ToolResult,
49
+ ViraltestAction,
50
+ ViraltestObservation,
51
+ )
52
+
53
+ _DATA_DIR = Path(__file__).parent / "data"
54
+
55
+ def _load_json(name: str) -> Any:
56
+ return json.loads((_DATA_DIR / name).read_text())
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Data files (loaded once at module level)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ _TAGS_DATA = _load_json("tags.json")
63
+ _TOPICS_DATA = _load_json("topics.json")
64
+ _COMPETITORS_DATA = _load_json("competitors.json")
65
+ _HEATMAP_DATA = _load_json("hour_heatmap.json")
66
+ _AUDIENCE_DATA = _load_json("audience_segments.json")
67
+ _OVERLAP_DATA = _load_json("audience_overlap_matrix.json")
68
+
69
+ # Flatten tag pool for validation
70
+ TAG_POOL: List[str] = []
71
+ for t in _TAGS_DATA.get("broad", []):
72
+ TAG_POOL.append(t["tag"])
73
+ for _cat, tags in _TAGS_DATA.get("niche", {}).items():
74
+ for t in tags:
75
+ TAG_POOL.append(t["tag"])
76
+ for t in _TAGS_DATA.get("trending", []):
77
+ TAG_POOL.append(t["tag"])
78
+ for t in _TAGS_DATA.get("seasonal", []):
79
+ TAG_POOL.append(t["tag"])
80
+
81
+ TOPIC_CATEGORIES: Dict[str, List[str]] = {}
82
+ for niche_name, niche_data in _TOPICS_DATA.get("niches", {}).items():
83
+ TOPIC_CATEGORIES[niche_name] = niche_data["topics"]
84
+
85
+ _NICHE_MULTIPLIERS: Dict[str, float] = {}
86
+ for niche_name, niche_data in _TOPICS_DATA.get("niches", {}).items():
87
+ _NICHE_MULTIPLIERS[niche_name] = niche_data["engagement_multiplier"]
88
+
89
+ _HEATMAP_GRID: Dict[int, List[float]] = {
90
+ int(k): v for k, v in _HEATMAP_DATA.get("grid", {}).items()
91
+ }
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Constants (research-backed, Tier 1-3 sources)
95
+ # ---------------------------------------------------------------------------
96
+
97
+ TASK_HORIZON = 30 # 30 daily steps (monthly cycle)
98
+
99
+ # Socialinsider 2026 (31M posts)
100
+ CONTENT_ENERGY_COST = {
101
+ "reel": 0.25,
102
+ "carousel": 0.20,
103
+ "story": 0.08,
104
+ "text_post": 0.06,
105
+ }
106
+
107
+ BASE_ENGAGEMENT = {
108
+ "reel": 0.52,
109
+ "carousel": 0.55,
110
+ "story": 0.30,
111
+ "text_post": 0.45,
112
+ }
113
+
114
+ # Socialinsider 2026 + CreatorsJet 10K study
115
+ REACH_MULT = {
116
+ "reel": 2.25,
117
+ "carousel": 1.0,
118
+ "story": 0.5,
119
+ "text_post": 0.91,
120
+ }
121
+
122
+ # Mosseri Jan-2025: format→signal affinity (which signal each format naturally excels at)
123
+ FORMAT_SIGNAL_WEIGHTS = {
124
+ "reel": {"watch_time": 0.50, "sends_per_reach": 0.25, "saves": 0.10, "likes_per_reach": 0.15},
125
+ "carousel": {"watch_time": 0.10, "sends_per_reach": 0.15, "saves": 0.50, "likes_per_reach": 0.25},
126
+ "story": {"watch_time": 0.20, "sends_per_reach": 0.40, "saves": 0.05, "likes_per_reach": 0.35},
127
+ "text_post": {"watch_time": 0.05, "sends_per_reach": 0.10, "saves": 0.30, "likes_per_reach": 0.55},
128
+ }
129
+
130
+ # Intent multiplier matrix: when intent matches format's strong signal, boost that signal
131
+ INTENT_MULTIPLIER = {
132
+ "send_bait": {"sends_per_reach": 1.6},
133
+ "save_bait": {"saves": 1.7},
134
+ "watch_bait": {"watch_time": 1.5},
135
+ "like_bait": {"likes_per_reach": 1.3},
136
+ }
137
+
138
+ VALID_TASKS = ("monthly_engage", "monthly_strategic", "monthly_competitive")
139
+
140
+ INITIAL_FOLLOWERS = 10000
141
+ REST_RECOVERY = 0.12
142
+ CREATE_CONTENT_COST = 0.05
143
+ REPETITION_ENERGY_PENALTY = 0.05
144
+ FOLLOWER_DECAY_HOURS = 72
145
+ ALGORITHM_PENALTY_MULT = 0.6
146
+ ALGORITHM_PENALTY_BASE_DURATION = 2
147
+
148
+ # Van Dongen 2003 *Sleep* PMID 12683469: lapses linear above 15.84h
149
+ SLEEP_OPTIMAL_AWAKE = 16
150
+ SLEEP_LINEAR_DECAY_PER_HOUR = 0.0625 # reaches ~50% at 24h awake (8h × 0.0625 = 0.5)
151
+ SLEEP_MIN_QUALITY = 0.30
152
+ SLEEP_ENERGY_DRAIN_START = 16
153
+ SLEEP_ENERGY_DRAIN_RATE = 0.015
154
+ SLEEP_RECOVERY_PER_REST = 2
155
+
156
+ # Buffer 2.1M study + arxiv:2410.13108: tiered fatigue
157
+ FATIGUE_TIERS = {2: 1.0, 3: 0.75, 4: 0.50, 5: 0.25}
158
+ WEEKLY_FATIGUE_THRESHOLD = 7
159
+ WEEKLY_FATIGUE_MULT = 0.75
160
+
161
+ SATURATION_PENALTY_K = 0.25
162
+ TREND_DEFAULT_HALFLIFE_HOURS = 60
163
+ # Collab reward shaping (Later 2023 reach study, HypeAuditor 2024 niche affinity, Rival IQ 2025 overlap patterns,
164
+ # Cen et al. 2024 disengagement model for diminishing returns instead of a hard cap).
165
+ COLLAB_REACH_K = 0.60 # cross-audience exposure: capped reach uplift when overlap is 0
166
+ COLLAB_AFFINITY_K = 0.30 # same-audience affinity: per-impression engagement uplift when overlap is 1
167
+ COLLAB_GROWTH_K = 1.50 # cross-pollination follower spillover, scales (1 - overlap)
168
+ COLLAB_PARTNER_REPEAT_PENALTY = 0.7 # discount on multipliers when partner reused this brand
169
+ COLLAB_FATIGUE_K = 0.3 # per-collab diminishing-returns factor: 1/(1+K*prior_collabs_this_episode)
170
+
171
+ REPLY_WINDOW_MINUTES = 90
172
+ REPLY_REACH_BONUS = 1.4
173
+ API_BUDGET_INITIAL = 100
174
+
175
+ # Heuristic baselines for headline metric `vs_baseline_pct`.
176
+ # Data-driven: loaded from `plots/training_summary.json["smart_heuristic"]` recorded by
177
+ # `training/run_training_evidence.py`. Falls back to conservative calibration constants
178
+ # if the file is missing (audit trail: see RESEARCH.md for the rule-based policy spec).
179
+ def _load_heuristic_baselines() -> Dict[str, float]:
180
+ summary = Path(__file__).parent.parent / "plots" / "training_summary.json"
181
+ try:
182
+ data = json.loads(summary.read_text())
183
+ empirical = data.get("smart_heuristic") or {}
184
+ return {k: float(v) for k, v in empirical.items() if k in VALID_TASKS}
185
+ except Exception:
186
+ return {}
187
+
188
+ HEURISTIC_BASELINE_SCORES: Dict[str, float] = _load_heuristic_baselines() or {
189
+ "monthly_engage": 0.43,
190
+ "monthly_strategic": 0.77,
191
+ "monthly_competitive": 0.81,
192
+ }
193
+
194
+ # Cross-episode store for distribution-shift retention. Keyed by episode_chain_id, stores
195
+ # {"baseline": score, "shifted": score} so the second run can compute retention_under_shift.
196
+ _SHIFT_HISTORY: Dict[str, Dict[str, float]] = {}
197
+
198
+ # Tool costs
199
+ TOOL_COSTS = {
200
+ "query_audience": 2,
201
+ "query_competitor": 2,
202
+ "query_tag_history": 1,
203
+ "query_trends": 1,
204
+ "predict_engagement": 3,
205
+ "draft_review": 3,
206
+ "query_creator_pool": 1,
207
+ "propose_collab": 5,
208
+ }
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Brand state for multi-episode persistence
212
+ # ---------------------------------------------------------------------------
213
+
214
+ _BRAND_STORE: Dict[str, Dict[str, Any]] = {}
215
+
216
+
217
+ @dataclass
218
+ class CompetitorState:
219
+ id: str
220
+ name: str
221
+ niche: str
222
+ niche_topics: List[str]
223
+ preferred_types: List[str]
224
+ posts_per_week: float
225
+ base_engagement_rate: float
226
+ tag_preferences: List[str]
227
+ style: str
228
+ recent_posts: List[Dict[str, Any]] = field(default_factory=list)
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Tool catalog (schemas for GET /tools)
233
+ # ---------------------------------------------------------------------------
234
+
235
+ TOOL_CATALOG = {
236
+ "query_audience": {
237
+ "description": "Query a specific audience segment to learn its topic affinities, content preferences, and active hours.",
238
+ "parameters": {"segment_id": {"type": "string", "enum": [s["id"] for s in _AUDIENCE_DATA.get("segments", [])]}},
239
+ },
240
+ "query_competitor": {
241
+ "description": "Get recent posts and strategy of a competitor archetype within a time window.",
242
+ "parameters": {
243
+ "competitor_id": {"type": "string", "enum": [a["id"] for a in _COMPETITORS_DATA.get("archetypes", [])]},
244
+ "window_days": {"type": "integer", "default": 7, "minimum": 1, "maximum": 30},
245
+ },
246
+ },
247
+ "query_tag_history": {
248
+ "description": "Get your historical engagement signals (watch, sends, saves, likes) for a specific tag.",
249
+ "parameters": {"tag": {"type": "string"}},
250
+ },
251
+ "query_trends": {
252
+ "description": "Get currently trending topics and tags for a niche, with decay-adjusted strength.",
253
+ "parameters": {"niche": {"type": "string", "enum": list(TOPIC_CATEGORIES.keys())}},
254
+ },
255
+ "predict_engagement": {
256
+ "description": "Simulate engagement signals for a hypothetical daily plan WITHOUT committing it. Returns predicted watch/sends/saves/likes.",
257
+ "parameters": {"scheduled_actions": {"type": "array", "description": "Same format as ViraltestAction.scheduled_actions"}},
258
+ },
259
+ "draft_review": {
260
+ "description": "Get AI review of a draft plan: strengths, weaknesses, suggested improvements.",
261
+ "parameters": {"scheduled_actions": {"type": "array"}},
262
+ },
263
+ "query_creator_pool": {
264
+ "description": "List available competitor archetypes for potential collaboration, with audience overlap %.",
265
+ "parameters": {},
266
+ },
267
+ "propose_collab": {
268
+ "description": "Propose a collab post with a competitor at a specific hour. The post you schedule at that hour will be co-authored with the partner.",
269
+ "parameters": {
270
+ "partner_id": {"type": "string"},
271
+ "content_type": {"type": "string", "enum": ["reel", "story", "carousel", "text_post"]},
272
+ "hour": {"type": "integer", "minimum": 0, "maximum": 23},
273
+ },
274
+ },
275
+ }
276
+
277
+
278
+ class ViraltestEnvironment(Environment):
279
+ """Monthly creator optimization simulation (Theme #3.1 World Modeling)."""
280
+
281
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
282
+
283
+ def __init__(self) -> None:
284
+ self._state = State(episode_id=str(uuid4()), step_count=0)
285
+ self._task = "monthly_engage"
286
+ self._rng = random.Random(42)
287
+ self._init_state()
288
+
289
+ def _init_state(self) -> None:
290
+ self._energy = 1.0
291
+ self._followers = INITIAL_FOLLOWERS
292
+ self._initial_followers = INITIAL_FOLLOWERS
293
+ self._hour = 9
294
+ self._day = 0
295
+ self._posts_today = 0
296
+ self._last_post_types: List[str] = []
297
+ self._time_since_last_post = 0
298
+ self._engagement_history: List[float] = []
299
+ self._tag_history: Dict[str, List[Dict[str, float]]] = defaultdict(list)
300
+ self._content_queue = 0
301
+ self._unique_tags_used: set = set()
302
+ self._unique_content_types: set = set()
303
+ self._energy_history: List[float] = [1.0]
304
+ self._posting_steps = 0
305
+ self._episode_done = False
306
+ self._last_topic: Optional[str] = None
307
+ self._final_observation: Optional[ViraltestObservation] = None
308
+ self._unique_topic_steps = 0
309
+ self._days_with_good_posts: set = set()
310
+ self._total_engagement = 0.0
311
+ self._posts_per_day: Dict[int, int] = defaultdict(int)
312
+ self._algorithm_penalty_remaining = 0
313
+ self._agent_notes: Optional[str] = None
314
+ self._api_budget = API_BUDGET_INITIAL
315
+ self._collabs_this_month = 0
316
+ self._collab_history: List[str] = []
317
+ self._active_collab: Optional[CollabProposal] = None
318
+ self._low_energy_days = 0
319
+ self._total_posts_this_week = 0
320
+ self._week_start_day = 0
321
+ self._daily_signals = EngagementSignals()
322
+ self._total_tool_calls = 0
323
+ self._total_action_chars = 0
324
+ self._shift_label: Optional[str] = None
325
+ self._chain_id: Optional[str] = None
326
+
327
+ self._trending_topics = self._pick_trending_topics()
328
+ self._trending_tags = self._pick_trending_tags()
329
+ self._competitors = self._load_competitors()
330
+
331
+ self._hours_since_sleep = 2
332
+ self._sleep_debt = 0.0
333
+
334
+ def _load_competitors(self) -> List[CompetitorState]:
335
+ archetypes = _COMPETITORS_DATA.get("archetypes", [])
336
+ return [
337
+ CompetitorState(
338
+ id=a["id"],
339
+ name=a["name"],
340
+ niche=a["niche"],
341
+ niche_topics=a["niche_topics"],
342
+ preferred_types=a["preferred_types"],
343
+ posts_per_week=a["posts_per_week"],
344
+ base_engagement_rate=a["base_engagement_rate"],
345
+ tag_preferences=a["tag_preferences"],
346
+ style=a.get("style", "consistent_moderate"),
347
+ )
348
+ for a in archetypes
349
+ ]
350
+
351
+ def _pick_trending_topics(self) -> List[str]:
352
+ all_topics = []
353
+ for niche_data in _TOPICS_DATA.get("niches", {}).values():
354
+ all_topics.extend(niche_data["topics"])
355
+ return self._rng.sample(all_topics, min(3, len(all_topics)))
356
+
357
+ def _pick_trending_tags(self) -> List[str]:
358
+ return self._rng.sample(TAG_POOL, min(5, len(TAG_POOL)))
359
+
360
+ def _rotate_trends(self) -> None:
361
+ self._trending_topics = self._pick_trending_topics()
362
+ self._trending_tags = self._pick_trending_tags()
363
+
364
+ # ----- hour multiplier (heatmap-based) -----
365
+
366
+ def _get_hour_multiplier(self) -> float:
367
+ dow = self._day % 7
368
+ h = self._hour
369
+ row = _HEATMAP_GRID.get(dow)
370
+ if row and 0 <= h < len(row):
371
+ return row[h]
372
+ return 0.8
373
+
374
+ # ----- quality (piecewise-linear sleep, Van Dongen 2003) -----
375
+
376
+ def _get_quality_modifier(self) -> float:
377
+ if self._energy > 0.5:
378
+ energy_factor = 1.0
379
+ else:
380
+ energy_factor = max(0.48, self._energy * 1.5)
381
+
382
+ if self._hours_since_sleep <= SLEEP_OPTIMAL_AWAKE:
383
+ sleep_factor = 1.0
384
+ else:
385
+ hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
386
+ sleep_factor = max(SLEEP_MIN_QUALITY, 1.0 - SLEEP_LINEAR_DECAY_PER_HOUR * hours_over)
387
+
388
+ return energy_factor * sleep_factor
389
+
390
+ # ----- niche multiplier -----
391
+
392
+ def _get_niche_multiplier(self, topic: Optional[str]) -> float:
393
+ if not topic:
394
+ return 1.0
395
+ topic_lower = topic.lower()
396
+ for niche_name, niche_data in _TOPICS_DATA.get("niches", {}).items():
397
+ for t in niche_data["topics"]:
398
+ if t.lower() == topic_lower:
399
+ return _NICHE_MULTIPLIERS.get(niche_name, 1.0)
400
+ return 1.0
401
+
402
+ # ----- tags -----
403
+
404
+ def _calc_tag_boost(self, tags: Optional[List[str]]) -> float:
405
+ if not tags:
406
+ return 1.0
407
+ trending_count = sum(1 for t in tags if t in self._trending_tags)
408
+ perf_values = [self._tag_performance_avg(t) for t in tags if self._tag_performance_avg(t) > 0]
409
+ perf_avg = sum(perf_values) / len(perf_values) if perf_values else 0.0
410
+ return 1.0 + 0.1 * trending_count + 0.05 * perf_avg
411
+
412
+ def _tag_performance_avg(self, tag: str) -> float:
413
+ history = self._tag_history.get(tag, [])
414
+ if not history:
415
+ return 0.0
416
+ window = history[-5:]
417
+ totals = [h.get("total", 0.0) for h in window]
418
+ return sum(totals) / len(totals) if totals else 0.0
419
+
420
+ def _get_tag_performance_dict(self) -> Dict[str, float]:
421
+ return {tag: self._tag_performance_avg(tag) for tag in self._unique_tags_used}
422
+
423
+ # ----- competitors -----
424
+
425
+ def _advance_competitors(self) -> None:
426
+ for comp in self._competitors:
427
+ for p in comp.recent_posts:
428
+ p["hours_ago"] += 1
429
+ comp.recent_posts = [p for p in comp.recent_posts if p["hours_ago"] < 72]
430
+
431
+ daily_prob = comp.posts_per_week / (7.0 * 24.0)
432
+ if self._rng.random() < daily_prob:
433
+ ct = self._rng.choice(comp.preferred_types)
434
+ topic = self._rng.choice(comp.niche_topics)
435
+ tags = self._rng.sample(comp.tag_preferences, min(3, len(comp.tag_preferences)))
436
+ eng = comp.base_engagement_rate + self._rng.uniform(-0.1, 0.1)
437
+ eng = max(0.0, min(1.0, eng))
438
+ comp.recent_posts.append({
439
+ "content_type": ct, "topic": topic, "tags": tags,
440
+ "engagement": round(eng, 3), "hours_ago": 0,
441
+ })
442
+
443
+ def _get_competitor_recent_posts(self, limit: int = 5) -> List[Dict[str, Any]]:
444
+ all_posts: List[Dict[str, Any]] = []
445
+ for comp in self._competitors:
446
+ for p in comp.recent_posts:
447
+ all_posts.append(p)
448
+ all_posts.sort(key=lambda x: x["hours_ago"])
449
+ return all_posts[:limit]
450
+
451
+ def _get_competitor_avg_engagement(self) -> float:
452
+ engagements = [p["engagement"] for comp in self._competitors for p in comp.recent_posts]
453
+ return sum(engagements) / len(engagements) if engagements else 0.0
454
+
455
+ def _calc_niche_saturation(self, topic: Optional[str]) -> float:
456
+ if not topic:
457
+ return 0.0
458
+ recent_topics = []
459
+ for comp in self._competitors:
460
+ for p in comp.recent_posts:
461
+ if p["hours_ago"] < 12:
462
+ recent_topics.append(p["topic"].lower())
463
+ if not recent_topics:
464
+ return 0.0
465
+ topic_lower = topic.lower()
466
+ overlap = sum(1 for t in recent_topics if _topic_overlap(topic_lower, t))
467
+ return min(1.0, overlap / max(1, len(recent_topics)))
468
+
469
+ def _calc_competitor_diff(self, topic: Optional[str]) -> float:
470
+ if not topic:
471
+ return 1.0
472
+ saturation = self._calc_niche_saturation(topic)
473
+ recent_topics = [
474
+ p["topic"].lower()
475
+ for comp in self._competitors
476
+ for p in comp.recent_posts
477
+ if p["hours_ago"] < 12
478
+ ]
479
+ has_overlap = any(_topic_overlap(topic.lower(), t) for t in recent_topics)
480
+ if not has_overlap:
481
+ return 1.3
482
+ if saturation > 0.7:
483
+ return 0.6
484
+ return 1.0
485
+
486
+ def _count_competitors_same_hour(self) -> int:
487
+ count = 0
488
+ for comp in self._competitors:
489
+ for p in comp.recent_posts:
490
+ if p["hours_ago"] <= 1:
491
+ count += 1
492
+ return count
493
+
494
+ # ----- fatigue (tiered, Buffer 2.1M) -----
495
+
496
+ def _get_fatigue_multiplier(self) -> float:
497
+ if self._posts_today <= 2:
498
+ daily_fatigue = 1.0
499
+ elif self._posts_today in FATIGUE_TIERS:
500
+ daily_fatigue = FATIGUE_TIERS[self._posts_today]
501
+ else:
502
+ daily_fatigue = 0.25
503
+
504
+ weekly_mult = 1.0
505
+ if self._total_posts_this_week >= WEEKLY_FATIGUE_THRESHOLD:
506
+ weekly_mult = WEEKLY_FATIGUE_MULT
507
+
508
+ return daily_fatigue * weekly_mult
509
+
510
+ # ----- collab multipliers (overlap-driven) -----
511
+
512
+ def _user_partner_overlap(self, partner_id: str) -> Optional[float]:
513
+ ids = _OVERLAP_DATA.get("archetype_ids", [])
514
+ if "user_creator" not in ids or partner_id not in ids:
515
+ return None
516
+ u = ids.index("user_creator")
517
+ p = ids.index(partner_id)
518
+ return _OVERLAP_DATA["matrix"][u][p]
519
+
520
+ def _collab_multipliers(self, partner_id: str) -> Tuple[float, float]:
521
+ """Returns (engagement_multiplier, follower_growth_multiplier)."""
522
+ o = self._user_partner_overlap(partner_id)
523
+ if o is None:
524
+ return 1.0, 1.0
525
+ reach = 1.0 + (1.0 - o) * COLLAB_REACH_K
526
+ affinity = 1.0 + o * COLLAB_AFFINITY_K
527
+ growth = 1.0 + (1.0 - o) * COLLAB_GROWTH_K
528
+ eng_boost = reach * affinity
529
+ if partner_id in self._collab_history[:-1]:
530
+ eng_boost *= COLLAB_PARTNER_REPEAT_PENALTY
531
+ growth *= COLLAB_PARTNER_REPEAT_PENALTY
532
+ prior = max(0, self._collabs_this_month - 1)
533
+ fatigue = 1.0 / (1.0 + COLLAB_FATIGUE_K * prior)
534
+ return eng_boost * fatigue, growth * fatigue
535
+
536
+ # ----- engagement signals (Mosseri-aligned) -----
537
+
538
+ def _compute_engagement_signals(
539
+ self, content_type: str, base_eng: float, intent: Optional[str]
540
+ ) -> EngagementSignals:
541
+ weights = FORMAT_SIGNAL_WEIGHTS.get(content_type, FORMAT_SIGNAL_WEIGHTS["text_post"])
542
+ signals = {k: base_eng * v for k, v in weights.items()}
543
+
544
+ if intent and intent in INTENT_MULTIPLIER:
545
+ for signal_name, mult in INTENT_MULTIPLIER[intent].items():
546
+ if signal_name in signals:
547
+ signals[signal_name] *= mult
548
+
549
+ return EngagementSignals(**signals)
550
+
551
+ # ----- tool dispatcher -----
552
+
553
+ def _dispatch_tool(self, tool: ToolCall) -> ToolResult:
554
+ cost = TOOL_COSTS.get(tool.name, 1)
555
+ if self._api_budget < cost:
556
+ return ToolResult(name=tool.name, success=False, error="rate_limit_exceeded", budget_remaining=self._api_budget)
557
+
558
+ self._api_budget -= cost
559
+
560
+ if tool.name == "query_audience":
561
+ seg_id = tool.arguments.get("segment_id", "")
562
+ for seg in _AUDIENCE_DATA.get("segments", []):
563
+ if seg["id"] == seg_id:
564
+ return ToolResult(name=tool.name, data=seg, budget_remaining=self._api_budget)
565
+ return ToolResult(name=tool.name, success=False, error=f"unknown segment: {seg_id}", budget_remaining=self._api_budget)
566
+
567
+ elif tool.name == "query_competitor":
568
+ comp_id = tool.arguments.get("competitor_id", "")
569
+ window = tool.arguments.get("window_days", 7)
570
+ for comp in self._competitors:
571
+ if comp.id == comp_id:
572
+ posts = [p for p in comp.recent_posts if p["hours_ago"] < window * 24]
573
+ return ToolResult(name=tool.name, data={
574
+ "id": comp.id, "name": comp.name, "niche": comp.niche,
575
+ "posts_per_week": comp.posts_per_week,
576
+ "recent_posts": posts[:10],
577
+ "avg_engagement": round(sum(p["engagement"] for p in posts) / max(1, len(posts)), 3),
578
+ }, budget_remaining=self._api_budget)
579
+ return ToolResult(name=tool.name, success=False, error=f"unknown competitor: {comp_id}", budget_remaining=self._api_budget)
580
+
581
+ elif tool.name == "query_tag_history":
582
+ tag = tool.arguments.get("tag", "").lower()
583
+ history = self._tag_history.get(tag, [])
584
+ return ToolResult(name=tool.name, data={
585
+ "tag": tag, "uses": len(history),
586
+ "avg_signals": _avg_signal_dicts(history[-10:]) if history else {},
587
+ }, budget_remaining=self._api_budget)
588
+
589
+ elif tool.name == "query_trends":
590
+ niche = tool.arguments.get("niche", "tech")
591
+ return ToolResult(name=tool.name, data={
592
+ "trending_topics": self._trending_topics,
593
+ "trending_tags": self._trending_tags,
594
+ "niche_saturation": round(self._calc_niche_saturation(self._last_topic), 3),
595
+ }, budget_remaining=self._api_budget)
596
+
597
+ elif tool.name == "predict_engagement":
598
+ raw_actions = tool.arguments.get("scheduled_actions", [])
599
+ predicted_total = 0.0
600
+ for sa_dict in raw_actions[:5]:
601
+ sa = ScheduledAction(**sa_dict) if isinstance(sa_dict, dict) else sa_dict
602
+ if sa.action_type == "post" and sa.content_type:
603
+ base = BASE_ENGAGEMENT.get(sa.content_type, 0.3)
604
+ reach = REACH_MULT.get(sa.content_type, 1.0)
605
+ niche_m = self._get_niche_multiplier(sa.topic)
606
+ predicted_total += base * reach * niche_m * self._get_hour_multiplier()
607
+ return ToolResult(name=tool.name, data={"predicted_daily_engagement": round(predicted_total, 4)}, budget_remaining=self._api_budget)
608
+
609
+ elif tool.name == "draft_review":
610
+ raw_actions = tool.arguments.get("scheduled_actions", [])
611
+ n_posts = sum(1 for a in raw_actions if (a.get("action_type") if isinstance(a, dict) else getattr(a, "action_type", "")) == "post")
612
+ feedback = []
613
+ if n_posts == 0:
614
+ feedback.append("No posts planned — you'll lose algorithmic momentum.")
615
+ elif n_posts > 3:
616
+ feedback.append(f"{n_posts} posts in one day risks audience fatigue (optimal: 1-2).")
617
+ if n_posts >= 1 and n_posts <= 2:
618
+ feedback.append("Good posting frequency for today.")
619
+ return ToolResult(name=tool.name, data={"feedback": feedback, "post_count": n_posts}, budget_remaining=self._api_budget)
620
+
621
+ elif tool.name == "query_creator_pool":
622
+ pool = []
623
+ for comp in self._competitors:
624
+ overlap = self._user_partner_overlap(comp.id)
625
+ pool.append({
626
+ "id": comp.id, "name": comp.name, "niche": comp.niche,
627
+ "audience_overlap": round(overlap, 2) if overlap is not None else None,
628
+ })
629
+ return ToolResult(name=tool.name, data=pool, budget_remaining=self._api_budget)
630
+
631
+ elif tool.name == "propose_collab":
632
+ partner_id = tool.arguments.get("partner_id", "")
633
+ if partner_id not in [c.id for c in self._competitors]:
634
+ return ToolResult(name=tool.name, success=False, error=f"unknown partner: {partner_id}", budget_remaining=self._api_budget)
635
+ return ToolResult(name=tool.name, data={"status": "proposal_accepted", "partner_id": partner_id}, budget_remaining=self._api_budget)
636
+
637
+ return ToolResult(name=tool.name, success=False, error=f"unknown tool: {tool.name}", budget_remaining=self._api_budget)
638
+
639
+ # ----- counterfactual coach -----
640
+
641
+ def _compute_coach_feedback(self, agent_engagement: float) -> Dict[str, Any]:
642
+ # World-modeling discipline: emit a SCALAR delta only (no optimal_hours leak).
643
+ # Agents must use `query_trends` / `predict_engagement` to discover *which* hours
644
+ # are optimal — coach only signals "you're above/below the heatmap optimum today".
645
+ dow = self._day % 7
646
+ row = _HEATMAP_GRID.get(dow, [1.0] * 24)
647
+ best_hours = sorted(range(24), key=lambda h: row[h] if h < len(row) else 0, reverse=True)[:2]
648
+ best_base = max(BASE_ENGAGEMENT.values())
649
+ best_reach = max(REACH_MULT.values())
650
+ optimal_eng = sum(row[h] * best_base * best_reach for h in best_hours)
651
+ delta = agent_engagement - optimal_eng
652
+ return {
653
+ "delta": round(delta, 4),
654
+ "suggestion": (
655
+ "Above heatmap optimum today."
656
+ if delta >= 0
657
+ else "Below heatmap optimum — try `query_trends` / `predict_engagement` to find peak hours."
658
+ ),
659
+ }
660
+
661
+ # ----- regulator / judge mode (deterministic, explainable) -----
662
+
663
+ def _compute_judge_report(
664
+ self,
665
+ action: ViraltestAction,
666
+ daily_engagement: float,
667
+ daily_posts: int,
668
+ energy_min: float,
669
+ errors: List[str],
670
+ ) -> JudgeReport:
671
+ violations: List[str] = []
672
+
673
+ pc = 1.0
674
+ if daily_posts > 5:
675
+ violations.append(f"posts_today={daily_posts} exceeds tier-4 fatigue cliff (Buffer 2.1M)")
676
+ pc -= 0.30
677
+ elif daily_posts > 2:
678
+ violations.append(f"posts_today={daily_posts} enters fatigue tier (>2/day)")
679
+ pc -= 0.10
680
+ if self._total_posts_this_week > WEEKLY_FATIGUE_THRESHOLD:
681
+ violations.append(f"weekly posts={self._total_posts_this_week} > {WEEKLY_FATIGUE_THRESHOLD} (Buffer 2.1M cap)")
682
+ pc -= 0.20
683
+ if self._collabs_this_month >= 4:
684
+ violations.append(f"collab cadence={self._collabs_this_month} net-negative beyond 3 (Cen 2024)")
685
+ pc -= 0.20
686
+ if errors:
687
+ violations.append(f"plan_errors={len(errors)}")
688
+ pc -= 0.05 * len(errors)
689
+ if self._hours_since_sleep > 22:
690
+ violations.append(f"sleep_debt: {self._hours_since_sleep}h awake (Van Dongen 2003)")
691
+ pc -= 0.10
692
+
693
+ burnout_pressure = (1.0 - energy_min) * 0.4 + self._sleep_debt * 0.3 + (self._low_energy_days / 5.0) * 0.3
694
+ sustainability_risk = max(0.0, min(1.0, burnout_pressure))
695
+
696
+ intents_used = {sa.intent for sa in action.scheduled_actions if sa.intent}
697
+ formats_used = {sa.content_type for sa in action.scheduled_actions if sa.action_type == "post" and sa.content_type}
698
+ eng_per_post = daily_engagement / max(1, daily_posts)
699
+ sq = (
700
+ 0.40 * min(1.0, eng_per_post / 1.2)
701
+ + 0.30 * min(1.0, len(intents_used) / 2.0)
702
+ + 0.30 * min(1.0, len(formats_used) / 2.0)
703
+ )
704
+
705
+ explanation = (
706
+ f"compliance={max(0.0, pc):.2f} risk={sustainability_risk:.2f} strategy={sq:.2f} | "
707
+ + (("violations: " + "; ".join(violations)) if violations else "no policy violations")
708
+ )
709
+
710
+ return JudgeReport(
711
+ policy_compliance=max(0.0, min(1.0, pc)),
712
+ sustainability_risk=sustainability_risk,
713
+ strategic_quality=max(0.0, min(1.0, sq)),
714
+ explanation=explanation,
715
+ violations=violations,
716
+ )
717
+
718
+ def _compute_headline_metrics(self, grader_score: float) -> HeadlineMetrics:
719
+ baseline = HEURISTIC_BASELINE_SCORES.get(self._task, 0.30)
720
+ vs_pct = (grader_score - baseline) / baseline if baseline > 0 else 0.0
721
+ spt = grader_score / max(1, self._total_tool_calls)
722
+ sp1k = grader_score / max(1.0, self._total_action_chars / 1000.0)
723
+
724
+ retention: Optional[float] = None
725
+ if self._chain_id:
726
+ entry = _SHIFT_HISTORY.setdefault(self._chain_id, {})
727
+ label = self._shift_label or "baseline"
728
+ entry[label] = grader_score
729
+ base = entry.get("baseline")
730
+ shifted = entry.get("shifted")
731
+ if base is not None and shifted is not None and base > 0:
732
+ retention = shifted / base
733
+
734
+ return HeadlineMetrics(
735
+ vs_baseline_pct=round(vs_pct, 4),
736
+ score_per_tool_call=round(spt, 4),
737
+ score_per_1k_chars=round(sp1k, 4),
738
+ retention_under_shift=round(retention, 4) if retention is not None else None,
739
+ heuristic_baseline_score=round(baseline, 4),
740
+ agent_score=round(grader_score, 4),
741
+ total_tool_calls=self._total_tool_calls,
742
+ total_action_chars=self._total_action_chars,
743
+ )
744
+
745
+ # ----- core API -----
746
+
747
+ def reset(self, seed: Optional[int] = None, episode_id: Optional[str] = None, **kwargs: Any) -> ViraltestObservation:
748
+ self._task = kwargs.get("task", "monthly_engage")
749
+ if self._task not in VALID_TASKS:
750
+ self._task = "monthly_engage"
751
+
752
+ self._rng = random.Random(seed if seed is not None else 42)
753
+ self._state = State(episode_id=episode_id or str(uuid4()), step_count=0)
754
+ self._init_state()
755
+
756
+ self._shift_label = kwargs.get("shift_label")
757
+ self._chain_id = kwargs.get("episode_chain_id")
758
+
759
+ chain_id = kwargs.get("episode_chain_id")
760
+ if chain_id and chain_id in _BRAND_STORE:
761
+ brand = _BRAND_STORE[chain_id]
762
+ self._unique_tags_used = set(brand.get("top_tags", []))
763
+ self._unique_content_types = set(brand.get("dominant_types", []))
764
+ self._collab_history = brand.get("collab_history", [])
765
+ self._followers = brand.get("followers", INITIAL_FOLLOWERS)
766
+ self._initial_followers = self._followers
767
+
768
+ return self._build_observation(reward=0.0, error=None)
769
+
770
+ def step(self, action: ViraltestAction, **kwargs: Any) -> ViraltestObservation:
771
+ if self._episode_done and self._final_observation is not None:
772
+ return self._final_observation
773
+
774
+ self._state.step_count += 1
775
+
776
+ # Store agent notes for echo
777
+ if action.notes:
778
+ self._agent_notes = action.notes
779
+
780
+ try:
781
+ self._total_action_chars += len(action.model_dump_json())
782
+ except Exception:
783
+ pass
784
+
785
+ tool_results: List[ToolResult] = []
786
+ for tc in action.tool_calls:
787
+ result = self._dispatch_tool(tc)
788
+ tool_results.append(result)
789
+ if result.success:
790
+ self._total_tool_calls += 1
791
+
792
+ # Process collab proposal (no hard cap; diminishing returns enforced via _collab_multipliers)
793
+ self._active_collab = None
794
+ if action.collab:
795
+ self._collabs_this_month += 1
796
+ self._collab_history.append(action.collab.partner_id)
797
+ self._active_collab = action.collab
798
+
799
+ # Validate scheduled actions
800
+ schedule: Dict[int, ScheduledAction] = {}
801
+ errors: List[str] = []
802
+ for sa in action.scheduled_actions:
803
+ if sa.hour < 0 or sa.hour > 23:
804
+ errors.append(f"Invalid hour: {sa.hour}")
805
+ continue
806
+ err = self._validate_scheduled_action(sa)
807
+ if err:
808
+ errors.append(f"hour {sa.hour}: {err}")
809
+ continue
810
+ schedule[sa.hour] = sa
811
+
812
+ daily_engagement = 0.0
813
+ daily_reward = 0.0
814
+ daily_posts = 0
815
+ energy_min = self._energy
816
+ burned_out = False
817
+ daily_signals = EngagementSignals()
818
+
819
+ for hour in range(24):
820
+ if burned_out:
821
+ break
822
+ self._hour = hour
823
+
824
+ if hour in schedule:
825
+ sa = schedule[hour]
826
+ hourly_eng, hourly_reward, hourly_signals = self._process_hour_action(sa)
827
+ else:
828
+ hourly_eng, hourly_reward = self._process_hour_rest()
829
+ hourly_signals = None
830
+
831
+ daily_engagement += hourly_eng
832
+ daily_reward += hourly_reward
833
+ if hourly_eng > 0:
834
+ daily_posts += 1
835
+ if hourly_signals:
836
+ daily_signals = EngagementSignals(
837
+ watch_time=daily_signals.watch_time + hourly_signals.watch_time,
838
+ sends_per_reach=daily_signals.sends_per_reach + hourly_signals.sends_per_reach,
839
+ saves=daily_signals.saves + hourly_signals.saves,
840
+ likes_per_reach=daily_signals.likes_per_reach + hourly_signals.likes_per_reach,
841
+ )
842
+ energy_min = min(energy_min, self._energy)
843
+ self._advance_competitors()
844
+ self._advance_time()
845
+ self._energy_history.append(self._energy)
846
+
847
+ if self._energy <= 0.0:
848
+ burned_out = True
849
+
850
+ # Process replies
851
+ for reply in action.replies:
852
+ if 0 <= reply.reply_hour < 24 and 0 <= reply.post_hour < 24:
853
+ diff_minutes = abs(reply.reply_hour - reply.post_hour) * 60
854
+ if diff_minutes <= REPLY_WINDOW_MINUTES:
855
+ daily_engagement *= REPLY_REACH_BONUS
856
+ daily_signals = EngagementSignals(
857
+ watch_time=daily_signals.watch_time * REPLY_REACH_BONUS,
858
+ sends_per_reach=daily_signals.sends_per_reach * REPLY_REACH_BONUS,
859
+ saves=daily_signals.saves * REPLY_REACH_BONUS,
860
+ likes_per_reach=daily_signals.likes_per_reach * REPLY_REACH_BONUS,
861
+ )
862
+
863
+ # Weekly tracking
864
+ self._total_posts_this_week += daily_posts
865
+ if self._day % 7 == 0 and self._day > 0:
866
+ self._total_posts_this_week = 0
867
+
868
+ # Burnout risk tracking
869
+ if energy_min < 0.2:
870
+ self._low_energy_days += 1
871
+ else:
872
+ self._low_energy_days = max(0, self._low_energy_days - 1)
873
+
874
+ prev_day = max(0, self._day - 1)
875
+ if 1 <= self._posts_per_day.get(prev_day, 0) <= 2:
876
+ self._days_with_good_posts.add(prev_day)
877
+
878
+ avg_reward = daily_reward / 24.0
879
+ error_str = "; ".join(errors) if errors else None
880
+
881
+ done = self._state.step_count >= TASK_HORIZON or self._energy <= 0.0
882
+ coach = self._compute_coach_feedback(daily_engagement)
883
+ judge = self._compute_judge_report(action, daily_engagement, daily_posts, energy_min, errors)
884
+
885
+ if done:
886
+ self._episode_done = True
887
+ grader_score = self._run_grader()
888
+ headline = self._compute_headline_metrics(grader_score)
889
+
890
+ chain_id = kwargs.get("episode_chain_id")
891
+ if chain_id:
892
+ top_tags = sorted(self._unique_tags_used, key=lambda t: self._tag_performance_avg(t), reverse=True)[:3]
893
+ _BRAND_STORE[chain_id] = {
894
+ "top_tags": list(top_tags),
895
+ "dominant_types": list(self._unique_content_types),
896
+ "collab_history": self._collab_history[-3:],
897
+ "followers": self._followers,
898
+ }
899
+
900
+ self._final_observation = self._build_observation(
901
+ reward=round(avg_reward, 4), error=error_str, done=True,
902
+ grader_score=grader_score, daily_total_engagement=daily_engagement,
903
+ daily_posts_made=daily_posts, daily_energy_min=energy_min,
904
+ tool_results=tool_results, engagement_signals=daily_signals,
905
+ coach_feedback=coach, judge_report=judge, headline_metrics=headline,
906
+ )
907
+ return self._final_observation
908
+
909
+ return self._build_observation(
910
+ reward=round(avg_reward, 4), error=error_str,
911
+ daily_total_engagement=daily_engagement,
912
+ daily_posts_made=daily_posts, daily_energy_min=energy_min,
913
+ tool_results=tool_results, engagement_signals=daily_signals,
914
+ coach_feedback=coach, judge_report=judge,
915
+ )
916
+
917
+ def _process_hour_action(self, sa: ScheduledAction) -> Tuple[float, float, Optional[EngagementSignals]]:
918
+ engagement = 0.0
919
+ signals = None
920
+
921
+ collab_growth_mult = 1.0
922
+
923
+ if sa.action_type == "post":
924
+ cost = CONTENT_ENERGY_COST.get(sa.content_type, 0.1)
925
+ if self._content_queue > 0:
926
+ cost *= 0.5
927
+ self._content_queue -= 1
928
+ if len(self._last_post_types) >= 3 and all(t == sa.content_type for t in self._last_post_types[-3:]):
929
+ cost += REPETITION_ENERGY_PENALTY
930
+ self._energy = max(0.0, self._energy - cost)
931
+ self._unique_content_types.add(sa.content_type)
932
+
933
+ if self._energy <= 0.0:
934
+ engagement = 0.0
935
+ else:
936
+ base = BASE_ENGAGEMENT.get(sa.content_type, 0.3)
937
+ reach = REACH_MULT.get(sa.content_type, 1.0)
938
+ hour_mult = self._get_hour_multiplier()
939
+ quality = self._get_quality_modifier()
940
+ tag_boost = self._calc_tag_boost(sa.tags)
941
+ trending_bonus = 1.5 if self._is_topic_trending(sa.topic) else 1.0
942
+ comp_diff = self._calc_competitor_diff(sa.topic)
943
+ fatigue = self._get_fatigue_multiplier()
944
+ niche_mult = self._get_niche_multiplier(sa.topic)
945
+
946
+ n_comp_same_hour = self._count_competitors_same_hour()
947
+ saturation_factor = 1.0 / (1.0 + SATURATION_PENALTY_K * n_comp_same_hour)
948
+
949
+ algo_mult = 1.0
950
+ if self._algorithm_penalty_remaining > 0:
951
+ algo_mult = ALGORITHM_PENALTY_MULT
952
+ self._algorithm_penalty_remaining -= 1
953
+
954
+ engagement = (
955
+ base * reach * hour_mult * quality * tag_boost
956
+ * trending_bonus * comp_diff * fatigue * algo_mult
957
+ * niche_mult * saturation_factor
958
+ )
959
+
960
+ if self._active_collab is not None and self._active_collab.hour == sa.hour:
961
+ eng_m, growth_m = self._collab_multipliers(self._active_collab.partner_id)
962
+ engagement *= eng_m
963
+ collab_growth_mult = growth_m
964
+
965
+ engagement = min(engagement, 5.0)
966
+
967
+ signals = self._compute_engagement_signals(sa.content_type, engagement, sa.intent)
968
+
969
+ self._last_topic = sa.topic
970
+
971
+ if sa.tags and engagement > 0:
972
+ signal_dict = signals.model_dump() if signals else {"total": engagement}
973
+ signal_dict["total"] = engagement
974
+ for tag in sa.tags:
975
+ tag_lower = tag.lower()
976
+ self._tag_history[tag_lower].append(signal_dict)
977
+ self._unique_tags_used.add(tag_lower)
978
+
979
+ self._engagement_history.append(engagement)
980
+ self._total_engagement += engagement
981
+ self._posting_steps += 1
982
+
983
+ if self._calc_competitor_diff(sa.topic) >= 1.3:
984
+ self._unique_topic_steps += 1
985
+
986
+ self._last_post_types.append(sa.content_type)
987
+ if len(self._last_post_types) > 3:
988
+ self._last_post_types = self._last_post_types[-3:]
989
+ self._posts_today += 1
990
+ self._posts_per_day[self._day] += 1
991
+ self._time_since_last_post = 0
992
+
993
+ if engagement > 0:
994
+ self._followers += int(engagement * 100 * collab_growth_mult)
995
+
996
+ elif sa.action_type == "create_content":
997
+ self._energy = max(0.0, self._energy - CREATE_CONTENT_COST)
998
+ self._content_queue += 1
999
+ self._time_since_last_post += 1
1000
+
1001
+ if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
1002
+ self._followers = max(0, self._followers - int(self._followers * 0.005))
1003
+ if self._algorithm_penalty_remaining == 0:
1004
+ gap_days = self._time_since_last_post // 24
1005
+ self._algorithm_penalty_remaining = ALGORITHM_PENALTY_BASE_DURATION + gap_days
1006
+
1007
+ reward = 0.0 if self._energy <= 0.0 else self._compute_hourly_reward(sa, engagement)
1008
+ return engagement, reward, signals
1009
+
1010
+ def _process_hour_rest(self) -> Tuple[float, float]:
1011
+ self._energy = min(1.0, self._energy + REST_RECOVERY)
1012
+ self._hours_since_sleep = max(0, self._hours_since_sleep - SLEEP_RECOVERY_PER_REST)
1013
+ self._sleep_debt = max(0.0, self._sleep_debt - 0.1)
1014
+ self._time_since_last_post += 1
1015
+
1016
+ if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
1017
+ self._followers = max(0, self._followers - int(self._followers * 0.005))
1018
+ if self._algorithm_penalty_remaining == 0:
1019
+ gap_days = self._time_since_last_post // 24
1020
+ self._algorithm_penalty_remaining = ALGORITHM_PENALTY_BASE_DURATION + gap_days
1021
+
1022
+ reward = 0.0 if self._energy <= 0.0 else self._compute_rest_reward()
1023
+ return 0.0, reward
1024
+
1025
+ @property
1026
+ def state(self) -> State:
1027
+ return self._state
1028
+
1029
+ def _validate_scheduled_action(self, sa: ScheduledAction) -> Optional[str]:
1030
+ if sa.action_type not in ("post", "create_content"):
1031
+ return f"Invalid action_type: {sa.action_type}"
1032
+ if sa.action_type == "post":
1033
+ if not sa.content_type:
1034
+ return "content_type is required when posting"
1035
+ if sa.content_type not in CONTENT_ENERGY_COST:
1036
+ return f"Invalid content_type: {sa.content_type}"
1037
+ if not sa.topic or not sa.topic.strip():
1038
+ return "topic is required when posting"
1039
+ if len(sa.topic) > 200:
1040
+ return "topic must be <= 200 characters"
1041
+ if sa.tags:
1042
+ valid = [t for t in sa.tags if t.lower() in [tp.lower() for tp in TAG_POOL]]
1043
+ sa.tags = valid if valid else None
1044
+ return None
1045
+
1046
+ def _is_topic_trending(self, topic: Optional[str]) -> bool:
1047
+ if not topic:
1048
+ return False
1049
+ topic_lower = topic.lower()
1050
+ return any(t.lower() in topic_lower for t in self._trending_topics)
1051
+
1052
+ # ----- reward -----
1053
+
1054
+ def _compute_hourly_reward(self, sa: ScheduledAction, engagement: float) -> float:
1055
+ eng_component = min(1.0, engagement / 2.0) * 0.3
1056
+
1057
+ prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
1058
+ energy_delta = self._energy - prev_energy
1059
+ energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
1060
+
1061
+ day_posts = self._posts_per_day.get(self._day, 0)
1062
+ if 1 <= day_posts <= 2:
1063
+ consistency = 1.0
1064
+ elif day_posts == 0 or day_posts == 3:
1065
+ consistency = 0.5
1066
+ else:
1067
+ consistency = 0.0
1068
+ consistency_component = consistency * 0.15
1069
+
1070
+ tag_component = 0.0
1071
+ if sa.action_type == "post" and sa.tags:
1072
+ trending_match = sum(1 for t in sa.tags if t.lower() in self._trending_tags) / 5.0
1073
+ tag_component = min(1.0, trending_match + 0.3) * 0.15
1074
+
1075
+ comp_component = 0.0
1076
+ if sa.action_type == "post":
1077
+ diff = self._calc_competitor_diff(sa.topic)
1078
+ comp_component = min(1.0, diff / 1.3) * 0.15
1079
+
1080
+ burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
1081
+ raw = eng_component + energy_component + consistency_component + tag_component + comp_component - burnout_penalty
1082
+ return max(0.0, min(1.0, raw))
1083
+
1084
+ def _compute_rest_reward(self) -> float:
1085
+ prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
1086
+ energy_delta = self._energy - prev_energy
1087
+ energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
1088
+
1089
+ day_posts = self._posts_per_day.get(self._day, 0)
1090
+ if 1 <= day_posts <= 2:
1091
+ consistency = 1.0
1092
+ elif day_posts == 0 or day_posts == 3:
1093
+ consistency = 0.5
1094
+ else:
1095
+ consistency = 0.0
1096
+ consistency_component = consistency * 0.15
1097
+
1098
+ burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
1099
+ raw = energy_component + consistency_component - burnout_penalty
1100
+ return max(0.0, min(1.0, raw))
1101
+
1102
+ def _advance_time(self) -> None:
1103
+ self._hour += 1
1104
+ self._hours_since_sleep += 1
1105
+
1106
+ if self._hours_since_sleep > SLEEP_ENERGY_DRAIN_START:
1107
+ hours_over = self._hours_since_sleep - SLEEP_ENERGY_DRAIN_START
1108
+ drain = SLEEP_ENERGY_DRAIN_RATE * (1 + hours_over * 0.1)
1109
+ self._energy = max(0.0, self._energy - drain)
1110
+
1111
+ if self._hours_since_sleep > SLEEP_OPTIMAL_AWAKE:
1112
+ hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
1113
+ debt_rate = 0.01 * (1 + hours_over * 0.05)
1114
+ self._sleep_debt = min(1.0, self._sleep_debt + debt_rate)
1115
+
1116
+ if self._hour >= 24:
1117
+ self._hour = 0
1118
+ self._day += 1
1119
+ self._posts_today = 0
1120
+ self._rotate_trends()
1121
+
1122
+ def _build_observation(
1123
+ self, reward: float, error: Optional[str], done: bool = False,
1124
+ grader_score: Optional[float] = None,
1125
+ daily_total_engagement: float = 0.0, daily_posts_made: int = 0,
1126
+ daily_energy_min: float = 1.0,
1127
+ tool_results: Optional[List[ToolResult]] = None,
1128
+ engagement_signals: Optional[EngagementSignals] = None,
1129
+ coach_feedback: Optional[Dict[str, Any]] = None,
1130
+ judge_report: Optional[JudgeReport] = None,
1131
+ headline_metrics: Optional[HeadlineMetrics] = None,
1132
+ ) -> ViraltestObservation:
1133
+ recent_eng = self._engagement_history[-10:] if self._engagement_history else []
1134
+ eng_rate = sum(recent_eng) / len(recent_eng) if recent_eng else 0.0
1135
+
1136
+ meta: Dict[str, Any] = {"step": self._state.step_count, "task": self._task}
1137
+ if grader_score is not None:
1138
+ meta["grader_score"] = round(grader_score, 4)
1139
+
1140
+ burnout_risk = min(1.0, self._low_energy_days / 5.0)
1141
+
1142
+ return ViraltestObservation(
1143
+ current_hour=self._hour,
1144
+ day_of_week=self._day % 7,
1145
+ days_elapsed=self._day,
1146
+ creator_energy=round(self._energy, 3),
1147
+ hours_since_sleep=self._hours_since_sleep,
1148
+ sleep_debt=round(self._sleep_debt, 3),
1149
+ follower_count=self._followers,
1150
+ engagement_rate=round(eng_rate, 4),
1151
+ posts_today=self._posts_today,
1152
+ time_since_last_post=self._time_since_last_post,
1153
+ content_queue_size=self._content_queue,
1154
+ last_post_type=self._last_post_types[-1] if self._last_post_types else "none",
1155
+ burnout_risk=round(burnout_risk, 3),
1156
+ daily_total_engagement=round(daily_total_engagement, 4),
1157
+ daily_posts_made=daily_posts_made,
1158
+ daily_energy_min=round(daily_energy_min, 3),
1159
+ engagement_signals=engagement_signals,
1160
+ coach_feedback=coach_feedback,
1161
+ judge_report=judge_report,
1162
+ headline_metrics=headline_metrics,
1163
+ tool_results=tool_results or [],
1164
+ agent_notes=self._agent_notes,
1165
+ api_budget_remaining=self._api_budget,
1166
+ grader_score=round(grader_score, 4) if grader_score is not None else None,
1167
+ error=error,
1168
+ done=done,
1169
+ reward=round(reward, 4),
1170
+ metadata=meta,
1171
+ )
1172
+
1173
+ # ----- graders (monthly) -----
1174
+
1175
+ def _run_grader(self) -> float:
1176
+ if self._task == "monthly_engage":
1177
+ return self._grade_monthly_engage()
1178
+ elif self._task == "monthly_strategic":
1179
+ return self._grade_monthly_strategic()
1180
+ elif self._task == "monthly_competitive":
1181
+ return self._grade_monthly_competitive()
1182
+ return 0.0
1183
+
1184
+ def _theoretical_max_engagement(self) -> float:
1185
+ # Buffer 2.1M (RESEARCH.md): 3–5 posts/week doubles follower growth vs 1–2,
1186
+ # diminishing returns above 5/week, 20–35% engagement drop per post above 7/week.
1187
+ # Cap at 5 posts/week × 4 weeks = 20 posts/month (sweet-spot, no fatigue penalty).
1188
+ best_base = max(BASE_ENGAGEMENT.values())
1189
+ best_reach = max(REACH_MULT.values())
1190
+ best_niche = max(_NICHE_MULTIPLIERS.values()) if _NICHE_MULTIPLIERS else 1.0
1191
+
1192
+ posts_per_week = 5
1193
+ weeks_in_horizon = TASK_HORIZON / 7.0
1194
+ total_posts = int(round(posts_per_week * weeks_in_horizon))
1195
+
1196
+ avg_heatmap_peak = 1.0
1197
+ if _HEATMAP_GRID:
1198
+ day_peaks = [
1199
+ max(row) if row else 1.0
1200
+ for row in _HEATMAP_GRID.values()
1201
+ ]
1202
+ avg_heatmap_peak = sum(day_peaks) / len(day_peaks) if day_peaks else 1.0
1203
+
1204
+ # Trending + tag uplifts: tier-1 industry data shows ~1.2-1.3x for trending topics
1205
+ # and ~1.05-1.15x for high-performance tags. Mid-range used to avoid headroom inflation.
1206
+ trending_bonus = 1.25
1207
+ tag_boost = 1.1
1208
+
1209
+ per_post = (
1210
+ best_base * best_reach * best_niche
1211
+ * avg_heatmap_peak * trending_bonus * tag_boost
1212
+ )
1213
+ return per_post * total_posts
1214
+
1215
+ def _grade_monthly_engage(self) -> float:
1216
+ theoretical_max = self._theoretical_max_engagement()
1217
+ if theoretical_max <= 0:
1218
+ return 0.0
1219
+ raw = min(1.0, self._total_engagement / theoretical_max)
1220
+ if self._energy <= 0.0:
1221
+ raw *= 0.3
1222
+ return raw
1223
+
1224
+ def _grade_monthly_strategic(self) -> float:
1225
+ if self._energy <= 0.0:
1226
+ return max(0.0, min(0.15, self._total_engagement * 0.01))
1227
+
1228
+ theoretical_max = self._theoretical_max_engagement()
1229
+ norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
1230
+
1231
+ positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
1232
+ tag_discovery = min(1.0, positive_tags / 30.0)
1233
+ top_perfs = sorted([self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True)[:3]
1234
+ tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
1235
+ tag_exploitation = min(1.0, tag_exploitation / 2.0)
1236
+ tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
1237
+
1238
+ avg_energy = sum(self._energy_history) / len(self._energy_history) if self._energy_history else 0.0
1239
+ consistency = len(self._days_with_good_posts) / 30.0
1240
+
1241
+ raw = 0.35 * norm_eng + 0.25 * tag_score + 0.25 * avg_energy + 0.15 * consistency
1242
+
1243
+ min_energy = min(self._energy_history) if self._energy_history else 0.0
1244
+ if min_energy < 0.2:
1245
+ raw *= 0.4
1246
+ elif min_energy < 0.3:
1247
+ raw = min(raw, 0.45)
1248
+ if len(self._unique_tags_used) < 5:
1249
+ raw *= 0.7
1250
+
1251
+ return max(0.0, min(1.0, raw))
1252
+
1253
+ def _grade_monthly_competitive(self) -> float:
1254
+ if self._energy <= 0.0:
1255
+ return 0.0
1256
+
1257
+ theoretical_max = self._theoretical_max_engagement()
1258
+ norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
1259
+
1260
+ positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
1261
+ tag_discovery = min(1.0, positive_tags / 30.0)
1262
+ top_perfs = sorted([self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True)[:3]
1263
+ tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
1264
+ tag_exploitation = min(1.0, tag_exploitation / 2.0)
1265
+ tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
1266
+
1267
+ growth = (self._followers - self._initial_followers) / self._initial_followers if self._initial_followers > 0 else 0.0
1268
+ target_growth = 0.04
1269
+ norm_growth = min(1.0, max(0.0, growth / target_growth))
1270
+
1271
+ comp_avg = self._get_competitor_avg_engagement()
1272
+ my_avg = self._total_engagement / self._posting_steps if self._posting_steps > 0 else 0.0
1273
+ outperformance = my_avg / comp_avg if comp_avg > 0 else 1.0
1274
+ norm_outperformance = min(1.0, outperformance / 1.5)
1275
+
1276
+ differentiation = self._unique_topic_steps / self._posting_steps if self._posting_steps > 0 else 0.0
1277
+
1278
+ min_energy = min(self._energy_history) if self._energy_history else 0.0
1279
+ energy_floor = min(1.0, max(0.0, min_energy))
1280
+
1281
+ raw = (
1282
+ 0.25 * norm_eng + 0.20 * tag_score + 0.20 * norm_growth
1283
+ + 0.15 * norm_outperformance + 0.10 * differentiation + 0.10 * energy_floor
1284
+ )
1285
+
1286
+ if len(self._unique_content_types) < 3:
1287
+ raw *= 0.5
1288
+ if len(self._unique_tags_used) < 8:
1289
+ raw *= 0.7
1290
+
1291
+ return max(0.0, min(1.0, raw))
1292
+
1293
+
1294
+ def _topic_overlap(topic_a: str, topic_b: str) -> bool:
1295
+ words_a = set(topic_a.split())
1296
+ words_b = set(topic_b.split())
1297
+ if not words_a or not words_b:
1298
+ return False
1299
+ common = words_a & words_b
1300
+ return len(common) / min(len(words_a), len(words_b)) >= 0.5
1301
+
1302
+
1303
+ def _avg_signal_dicts(dicts: List[Dict[str, float]]) -> Dict[str, float]:
1304
+ if not dicts:
1305
+ return {}
1306
+ keys = set()
1307
+ for d in dicts:
1308
+ keys.update(d.keys())
1309
+ result = {}
1310
+ for k in keys:
1311
+ vals = [d.get(k, 0.0) for d in dicts]
1312
+ result[k] = round(sum(vals) / len(vals), 4)
1313
+ return result
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 = ["monthly_engage", "monthly_strategic", "monthly_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, 31):
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, 31):
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.")
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