Spaces:
Sleeping
Sleeping
Commit ·
4abeb9a
0
Parent(s):
Viraltest OpenEnv: deploy to HF Space
Browse filesSingle-commit history without PNG binaries (Hub pre-receive rejects them).
Made-with: Cursor
- .agents/skills/openenv-cli/SKILL.md +18 -0
- .codex/skills/openenv-cli +1 -0
- .dockerignore +15 -0
- .env.example +6 -0
- .gitignore +13 -0
- DESIGN.md +792 -0
- Dockerfile +82 -0
- README.md +273 -0
- SIMULATION_REPORT.md +276 -0
- __init__.py +17 -0
- client.py +91 -0
- inference.py +304 -0
- models.py +87 -0
- openenv.yaml +7 -0
- pyproject.toml +48 -0
- server/__init__.py +11 -0
- server/app.py +510 -0
- server/dashboard.html +1306 -0
- server/requirements.txt +6 -0
- server/simulation_history.json +1802 -0
- server/viraltest_environment.py +844 -0
- test_scenarios.py +219 -0
- uv.lock +0 -0
- validate-submission.sh +355 -0
- visualize_optimal.py +732 -0
.agents/skills/openenv-cli/SKILL.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: openenv-cli
|
| 3 |
+
description: "OpenEnv CLI (`openenv`) for scaffolding, validating, building, and pushing OpenEnv environments."
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
Install: `pip install openenv-core`
|
| 7 |
+
|
| 8 |
+
The OpenEnv CLI command `openenv` is available.
|
| 9 |
+
Use `openenv --help` to view available commands.
|
| 10 |
+
|
| 11 |
+
Generated with `openenv-core v0.2.3`. Run `openenv skills add --force` to regenerate.
|
| 12 |
+
|
| 13 |
+
## Tips
|
| 14 |
+
|
| 15 |
+
- Start with `openenv init <env_name>` to scaffold a new environment
|
| 16 |
+
- Validate projects with `openenv validate`
|
| 17 |
+
- Build and deploy with `openenv build` and `openenv push`
|
| 18 |
+
- Use `openenv <command> --help` for command-specific options
|
.codex/skills/openenv-cli
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
../../.agents/skills/openenv-cli
|
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.env
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
*.pyw
|
| 10 |
+
*.pyz
|
| 11 |
+
*.pywz
|
| 12 |
+
*.pyzw
|
| 13 |
+
*.pyzwz
|
| 14 |
+
|
| 15 |
+
|
.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and set values ( .env is gitignored )
|
| 2 |
+
HF_TOKEN=hf_your_token_here
|
| 3 |
+
|
| 4 |
+
# Optional overrides for Step 5 / inference (defaults match inference.py):
|
| 5 |
+
# MODEL_NAME=gemma-4-E4B-it-IQ4_XS
|
| 6 |
+
# API_BASE_URL=https://router.huggingface.co/v1
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local secrets (HF_TOKEN, etc.) — never commit
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
|
| 6 |
+
# Generated visualization outputs (regenerate: python visualize_optimal.py)
|
| 7 |
+
# Hugging Face Spaces rejects plain-git binary files; keep charts local or use Git LFS elsewhere.
|
| 8 |
+
*.png
|
| 9 |
+
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.py[cod]
|
| 12 |
+
*.egg-info/
|
| 13 |
+
.mplconfig/
|
DESIGN.md
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Viraltest — RL-Based Creator Optimization Agent
|
| 2 |
+
|
| 3 |
+
## Problem
|
| 4 |
+
|
| 5 |
+
Content creators on platforms like Meta (Instagram, Facebook) face:
|
| 6 |
+
|
| 7 |
+
- Unpredictable engagement
|
| 8 |
+
- No clear posting strategy
|
| 9 |
+
- Pressure to post frequently
|
| 10 |
+
- Burnout due to over-posting
|
| 11 |
+
- Drop in content quality over time
|
| 12 |
+
|
| 13 |
+
Existing tools show analytics (likes, reach) and past performance but don't **actively guide creators on optimal behavior over time**.
|
| 14 |
+
|
| 15 |
+
**Core problem**: No intelligent system continuously learns and adapts a creator's posting strategy to balance growth and burnout.
|
| 16 |
+
|
| 17 |
+
## Solution
|
| 18 |
+
|
| 19 |
+
An RL agent that learns **when to post**, **what type to post**, **which tags to use**, and **how to differentiate from competitors** — maximizing engagement while minimizing burnout over a weekly cycle.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## Architecture
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
┌─────────────────────────────────────────────────────────────────────┐
|
| 27 |
+
│ INFERENCE SCRIPT (inference.py) │
|
| 28 |
+
│ │
|
| 29 |
+
│ env = ViraltestEnv(base_url="https://...") │
|
| 30 |
+
│ result = env.reset(task="weekly_strategic") ← picks task │
|
| 31 |
+
│ result = env.step(action) ← type-safe! │
|
| 32 |
+
│ │
|
| 33 |
+
│ ┌───────────────────────────────────────────────────────────┐ │
|
| 34 |
+
│ │ LLM Agent (OpenAI Client) │ │
|
| 35 |
+
│ │ Reads: observation → Decides: action │ │
|
| 36 |
+
│ │ Model: Qwen/Qwen2.5-72B-Instruct │ │
|
| 37 |
+
│ └───────────────────────────────────────────────────────────┘ │
|
| 38 |
+
│ │
|
| 39 |
+
│ Logs: [START] [STEP] [END] to stdout │
|
| 40 |
+
└──────────────────────────┬──────────────────────────────────────────┘
|
| 41 |
+
│
|
| 42 |
+
WebSocket /ws
|
| 43 |
+
│
|
| 44 |
+
▼
|
| 45 |
+
┌─────────────────────────────────────────────────────────────────────┐
|
| 46 |
+
│ DOCKER CONTAINER (HF Space) │
|
| 47 |
+
│ │
|
| 48 |
+
│ ┌───────────────────────────────────────────────────────────┐ │
|
| 49 |
+
│ │ FastAPI Server (server/app.py) — port 8000 │ │
|
| 50 |
+
│ │ │ │
|
| 51 |
+
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
| 52 |
+
│ │ │ ViraltestEnvironment │ │ │
|
| 53 |
+
│ │ │ │ │ │
|
| 54 |
+
│ │ │ ┌─────────────────┐ ┌──────────────────────┐ │ │ │
|
| 55 |
+
│ │ │ │ reset(task) │ │ step(action) │ │ │ │
|
| 56 |
+
│ │ │ │ • Set task │ │ 1. Validate action │ │ │ │
|
| 57 |
+
│ │ │ │ • Init state │ │ 2. Apply effects │ │ │ │
|
| 58 |
+
│ │ │ │ • energy=1.0 │ │ 3. Calc engagement │ │ │ │
|
| 59 |
+
│ │ │ │ • followers=N │ │ 4. Tag analytics │ │ │ │
|
| 60 |
+
│ │ │ │ • Init tags │ │ 5. Competitor check │ │ │ │
|
| 61 |
+
│ │ │ │ • Init rivals │ │ 6. Update followers │ │ │ │
|
| 62 |
+
│ │ │ │ • Return obs │ │ 7. Calc reward │ │ │ │
|
| 63 |
+
│ │ │ └─────────────────┘ │ 8. Check done │ │ │ │
|
| 64 |
+
│ │ │ │ 9. Return obs │ │ │ │
|
| 65 |
+
│ │ │ ┌─────────────────┐ └──────────────────────┘ │ │ │
|
| 66 |
+
│ │ │ │ state() │ │ │ │
|
| 67 |
+
│ │ │ │ • episode_id │ ┌──────────────────────┐ │ │ │
|
| 68 |
+
│ │ │ │ • step_count │ │ Grader (per task) │ │ │ │
|
| 69 |
+
│ │ │ │ • task_name │ │ • weekly_engage │ │ │ │
|
| 70 |
+
│ │ │ └─────────────────┘ │ • weekly_strategic │ │ │ │
|
| 71 |
+
│ │ │ │ • weekly_competitive │ │ │ │
|
| 72 |
+
│ │ │ └──────────────────────┘ │ │ │
|
| 73 |
+
│ │ │ │ │ │
|
| 74 |
+
│ │ │ Simulation Engine (research-backed params) │ │ │
|
| 75 |
+
│ │ │ • Hour multipliers (Buffer 9.6M study) │ │ │
|
| 76 |
+
│ │ │ • Content rates (SocialInsider 2025) │ │ │
|
| 77 |
+
│ │ │ • Burnout curve (Sozee 2026 creator study) │ │ │
|
| 78 |
+
│ │ │ • Tag engagement model │ │ │
|
| 79 |
+
│ │ │ • Competitor simulation │ │ │
|
| 80 |
+
│ │ └─────────────────────────────────────────────────────┘ │ │
|
| 81 |
+
│ └───────────────────────────────────────────────────────────┘ │
|
| 82 |
+
│ │
|
| 83 |
+
│ Isolated • Reproducible • Secure • Deterministic (seeded RNG) │
|
| 84 |
+
└─────────────────────────────────────────────────────────────────────┘
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Pydantic Models
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
+
models.py
|
| 93 |
+
├── ViraltestAction(Action)
|
| 94 |
+
│ ├── action_type: Literal["post", "rest", "create_content"]
|
| 95 |
+
│ ├── content_type: Optional[Literal["reel", "story", "carousel", "text_post"]]
|
| 96 |
+
│ ├── topic: Optional[str]
|
| 97 |
+
│ └── tags: Optional[list[str]] ← max 5 tags per post
|
| 98 |
+
│
|
| 99 |
+
└── ViraltestObservation(Observation)
|
| 100 |
+
├── current_hour: int (0–23)
|
| 101 |
+
├── day_of_week: int (0–6)
|
| 102 |
+
├── days_elapsed: int
|
| 103 |
+
├── creator_energy: float (0.0–1.0, burnout meter)
|
| 104 |
+
├── follower_count: int
|
| 105 |
+
├── engagement_rate: float (rolling avg last 10 posts)
|
| 106 |
+
├── posts_today: int
|
| 107 |
+
├── time_since_last_post: int (hours)
|
| 108 |
+
├── trending_topics: list[str]
|
| 109 |
+
├── content_queue_size: int
|
| 110 |
+
├── last_post_type: str
|
| 111 |
+
│
|
| 112 |
+
│ ── Tag Analytics ──
|
| 113 |
+
├── tag_performance: dict[str, float] (tag → avg engagement from your past posts)
|
| 114 |
+
├── trending_tags: list[str] (currently hot tags on the platform)
|
| 115 |
+
│
|
| 116 |
+
│ ── Competitor Intelligence ──
|
| 117 |
+
├── competitor_recent_posts: list[dict] (last 3 posts from similar creators)
|
| 118 |
+
│ each: {content_type, topic, tags, engagement, hours_ago}
|
| 119 |
+
├── competitor_avg_engagement: float (avg engagement of similar creators)
|
| 120 |
+
├── niche_saturation: float (0.0–1.0, how crowded your topic space is)
|
| 121 |
+
│
|
| 122 |
+
├── done: bool (inherited)
|
| 123 |
+
└── reward: float (inherited)
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Data Flow — Single Step
|
| 129 |
+
|
| 130 |
+
```
|
| 131 |
+
AGENT ENVIRONMENT
|
| 132 |
+
│ │
|
| 133 |
+
│ ── Action ───────────────────────────► │
|
| 134 |
+
│ { │
|
| 135 |
+
│ action_type: "post" │
|
| 136 |
+
│ content_type: "reel" │ 1. Validate fields
|
| 137 |
+
│ topic: "AI trends" │ 2. energy -= 0.25
|
| 138 |
+
│ tags: ["ai", "tech", "future"] │ 3. engagement = base_rate
|
| 139 |
+
│ } │ × hour_mult
|
| 140 |
+
│ │ × energy_quality
|
| 141 |
+
│ │ × tag_boost
|
| 142 |
+
│ │ × trending_bonus
|
| 143 |
+
│ │ × competitor_diff_bonus
|
| 144 |
+
│ │ × audience_fatigue
|
| 145 |
+
│ │ 4. Update tag_performance history
|
| 146 |
+
│ │ 5. Update niche_saturation
|
| 147 |
+
│ │ 6. followers += f(engagement)
|
| 148 |
+
│ │ 7. advance hour
|
| 149 |
+
│ │ 8. reward = composite score
|
| 150 |
+
│ │ 9. done? (168 steps or energy=0)
|
| 151 |
+
│ ◄── Observation ───────────────────── │
|
| 152 |
+
│ { │
|
| 153 |
+
│ current_hour: 14 │
|
| 154 |
+
│ creator_energy: 0.62 │
|
| 155 |
+
│ follower_count: 10340 │
|
| 156 |
+
│ engagement_rate: 0.048 │
|
| 157 |
+
│ tag_performance: { │
|
| 158 |
+
│ "ai": 0.72, "tech": 0.55, │
|
| 159 |
+
│ "food": 0.31, "travel": 0.44 │
|
| 160 |
+
│ } │
|
| 161 |
+
│ trending_tags: ["ai", "summer"] │
|
| 162 |
+
│ competitor_recent_posts: [ │
|
| 163 |
+
│ {type:"carousel", topic:"AI", │
|
| 164 |
+
│ tags:["ai","ml"], eng:0.61, │
|
| 165 |
+
│ hours_ago: 3}, │
|
| 166 |
+
│ ... │
|
| 167 |
+
│ ] │
|
| 168 |
+
│ niche_saturation: 0.7 │
|
| 169 |
+
│ done: false, reward: 0.67 │
|
| 170 |
+
│ } │
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## Step Processing (Server-Side)
|
| 176 |
+
|
| 177 |
+
### 1. Validate Action
|
| 178 |
+
|
| 179 |
+
- `action_type` must be one of `post`, `rest`, `create_content`
|
| 180 |
+
- If `post`: `content_type` required, `topic` non-empty ≤200 chars, `tags` max 5 items from known pool
|
| 181 |
+
- Invalid action → reward=0, error in observation
|
| 182 |
+
|
| 183 |
+
### 2. Apply Energy Cost
|
| 184 |
+
|
| 185 |
+
| Action | Energy Effect |
|
| 186 |
+
|---|---|
|
| 187 |
+
| Post (reel) | -0.25 |
|
| 188 |
+
| Post (carousel) | -0.20 |
|
| 189 |
+
| Post (story) | -0.08 |
|
| 190 |
+
| Post (text_post) | -0.06 |
|
| 191 |
+
| Rest | +0.12 (capped at 1.0) |
|
| 192 |
+
| Create content | -0.05, queue += 1 |
|
| 193 |
+
|
| 194 |
+
Repetition penalty: same content type as last 3 posts → extra -0.05.
|
| 195 |
+
If energy ≤ 0 → `done = true` (burnout).
|
| 196 |
+
|
| 197 |
+
### 3. Calculate Engagement (post only)
|
| 198 |
+
|
| 199 |
+
```
|
| 200 |
+
engagement = base_rate × hour_mult × quality × tag_boost × trending_bonus
|
| 201 |
+
× competitor_diff × fatigue_penalty
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
**Base engagement rates** (SocialInsider 2025):
|
| 205 |
+
|
| 206 |
+
| Type | Rate | Reach Mult |
|
| 207 |
+
|---|---|---|
|
| 208 |
+
| Carousel | 0.55% | 1.0x |
|
| 209 |
+
| Reel | 0.52% | 2.25x |
|
| 210 |
+
| Story | 0.30% | 0.5x |
|
| 211 |
+
| Text post | 0.37% | 0.44x |
|
| 212 |
+
|
| 213 |
+
**Hour multipliers** (Buffer 9.6M posts):
|
| 214 |
+
|
| 215 |
+
| Time Slot | Multiplier |
|
| 216 |
+
|---|---|
|
| 217 |
+
| 9AM–12PM weekdays | 1.3x |
|
| 218 |
+
| 12PM–3PM Tue-Thu | 1.4x (peak) |
|
| 219 |
+
| 6PM–8PM | 1.25x |
|
| 220 |
+
| 8PM–11PM | 1.1x |
|
| 221 |
+
| 11PM–6AM | 0.5x |
|
| 222 |
+
| Fri/Sat | 0.7x base penalty |
|
| 223 |
+
|
| 224 |
+
**Quality modifier** (Sozee burnout study: 30-52% productivity drop):
|
| 225 |
+
|
| 226 |
+
```
|
| 227 |
+
quality = 1.0 if energy > 0.5 else max(0.48, energy × 1.5)
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
**Tag boost** (see Tag Engagement section below):
|
| 231 |
+
|
| 232 |
+
```
|
| 233 |
+
tag_boost = 1.0 + 0.1 × count(tags that are in trending_tags)
|
| 234 |
+
+ 0.05 × avg(tag_performance[tag] for tag in action.tags)
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
**Competitor differentiation bonus**:
|
| 238 |
+
|
| 239 |
+
```
|
| 240 |
+
if topic NOT in competitor_recent_topics (last 12hrs):
|
| 241 |
+
competitor_diff = 1.3 (unique angle, underserved)
|
| 242 |
+
elif niche_saturation > 0.7:
|
| 243 |
+
competitor_diff = 0.6 (oversaturated, too many posting same thing)
|
| 244 |
+
else:
|
| 245 |
+
competitor_diff = 1.0 (neutral)
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
**Audience fatigue**: posts_today > 3 → ×0.5, posts_today > 5 → ×0.1
|
| 249 |
+
|
| 250 |
+
**Trending bonus**: topic matches trending → ×1.5
|
| 251 |
+
|
| 252 |
+
### 4. Update Tag Performance
|
| 253 |
+
|
| 254 |
+
After each post, the environment records engagement per tag:
|
| 255 |
+
|
| 256 |
+
```python
|
| 257 |
+
for tag in action.tags:
|
| 258 |
+
tag_history[tag].append(this_post_engagement)
|
| 259 |
+
tag_performance[tag] = rolling_avg(tag_history[tag], window=5)
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
This gives the agent a feedback loop — it can see which tags historically work and adapt.
|
| 263 |
+
|
| 264 |
+
### 5. Update Competitor State
|
| 265 |
+
|
| 266 |
+
Each step, the simulated competitors also "post" according to a deterministic schedule (seeded RNG):
|
| 267 |
+
|
| 268 |
+
```python
|
| 269 |
+
for competitor in competitors:
|
| 270 |
+
if should_post(competitor, current_hour): # seeded probability
|
| 271 |
+
competitor.recent_posts.append({
|
| 272 |
+
content_type: random.choice(types),
|
| 273 |
+
topic: random.choice(competitor.niche_topics),
|
| 274 |
+
tags: random.sample(tag_pool, 3),
|
| 275 |
+
engagement: base + noise,
|
| 276 |
+
hours_ago: 0
|
| 277 |
+
})
|
| 278 |
+
# Age out old posts
|
| 279 |
+
competitor.recent_posts = [p for p in competitor.recent_posts if p.hours_ago < 48]
|
| 280 |
+
|
| 281 |
+
niche_saturation = count(competitor posts with overlapping topic in last 12hrs) / max_posts
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
### 6. Update Followers
|
| 285 |
+
|
| 286 |
+
- Posted: `followers += int(engagement × 100)`
|
| 287 |
+
- No post for 48+ hrs: followers decay (algorithm deprioritization)
|
| 288 |
+
|
| 289 |
+
### 7. Advance Time
|
| 290 |
+
|
| 291 |
+
- hour += 1
|
| 292 |
+
- If hour ≥ 24: day advances, posts_today resets, trending topics/tags rotate (seeded)
|
| 293 |
+
|
| 294 |
+
### 8. Compute Reward
|
| 295 |
+
|
| 296 |
+
```
|
| 297 |
+
reward = clamp(0, 1,
|
| 298 |
+
engagement_gained × 0.3
|
| 299 |
+
+ energy_delta × 0.15
|
| 300 |
+
+ consistency_bonus × 0.15
|
| 301 |
+
+ tag_optimization_score × 0.15
|
| 302 |
+
+ competitor_diff_score × 0.15
|
| 303 |
+
- burnout_penalty × 0.1
|
| 304 |
+
)
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
- `consistency_bonus`: 1.0 if 1-2 posts/day, 0.5 if 0 or 3, 0.0 if 4+
|
| 308 |
+
- `tag_optimization_score`: how well agent's chosen tags match high-performing + trending tags
|
| 309 |
+
- `competitor_diff_score`: 1.0 if posting unique angle, 0.0 if fully overlapping
|
| 310 |
+
- `burnout_penalty`: 1.0 if energy < 0.2
|
| 311 |
+
|
| 312 |
+
### 9. Check Done
|
| 313 |
+
|
| 314 |
+
Episode ends when:
|
| 315 |
+
- `step_count >= 168` (1 week = 7 days × 24 hours)
|
| 316 |
+
- `energy <= 0` (burned out)
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## Tag Engagement System
|
| 321 |
+
|
| 322 |
+
### How Tags Work
|
| 323 |
+
|
| 324 |
+
The environment maintains a **tag pool** of ~30 tags across categories:
|
| 325 |
+
|
| 326 |
+
| Category | Example Tags |
|
| 327 |
+
|---|---|
|
| 328 |
+
| Tech | `ai`, `ml`, `coding`, `startup`, `saas` |
|
| 329 |
+
| Lifestyle | `fitness`, `travel`, `food`, `wellness`, `fashion` |
|
| 330 |
+
| Trending | `summer`, `worldcup`, `election` (rotate daily) |
|
| 331 |
+
| Niche | `productivity`, `minimalism`, `stoic`, `web3` |
|
| 332 |
+
| Broad | `motivation`, `tips`, `howto`, `viral` |
|
| 333 |
+
|
| 334 |
+
### Tag Performance Tracking
|
| 335 |
+
|
| 336 |
+
Each tag accumulates engagement history from the agent's own posts:
|
| 337 |
+
|
| 338 |
+
```
|
| 339 |
+
tag_performance = {
|
| 340 |
+
"ai": 0.72, ← avg engagement when you used this tag
|
| 341 |
+
"fitness": 0.31, ← this tag isn't working for your audience
|
| 342 |
+
"motivation": 0.55,
|
| 343 |
+
...
|
| 344 |
+
}
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
Initially all tags start at 0.0 (unknown). As the agent posts with different tags, it builds this signal.
|
| 348 |
+
|
| 349 |
+
### Tag Dynamics
|
| 350 |
+
|
| 351 |
+
- **Trending tags** change every 24 simulated hours (seeded, deterministic)
|
| 352 |
+
- Using a trending tag gives +10% engagement per trending tag matched
|
| 353 |
+
- Using a high-performing tag (from your history) gives +5% per tag
|
| 354 |
+
- Using an **oversaturated tag** (competitors using it heavily) gives -10%
|
| 355 |
+
- Max 5 tags per post — agent must choose wisely
|
| 356 |
+
|
| 357 |
+
### What the Agent Must Learn
|
| 358 |
+
|
| 359 |
+
1. **Discover** which tags work for its audience (explore early, exploit later)
|
| 360 |
+
2. **Ride trends** — use trending tags when they align with its niche
|
| 361 |
+
3. **Avoid saturation** — if competitors are all using `#ai`, pivot to `#ml` or `#coding`
|
| 362 |
+
4. **Combine** high-performing niche tags with 1-2 trending tags for optimal reach+engagement
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
## Competitor Intelligence System
|
| 367 |
+
|
| 368 |
+
### Simulated Competitors
|
| 369 |
+
|
| 370 |
+
The environment simulates **3 competing creators** in the same niche. Each has:
|
| 371 |
+
|
| 372 |
+
```python
|
| 373 |
+
competitor = {
|
| 374 |
+
"name": "creator_A",
|
| 375 |
+
"niche_topics": ["AI", "tech", "startups"], # their focus
|
| 376 |
+
"preferred_types": ["reel", "carousel"], # what they mostly post
|
| 377 |
+
"posting_frequency": 2.5, # avg posts/day
|
| 378 |
+
"base_engagement": 0.45, # their avg engagement
|
| 379 |
+
"tag_preferences": ["ai", "startup", "coding"],
|
| 380 |
+
}
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
### What the Agent Sees
|
| 384 |
+
|
| 385 |
+
Each step, the observation includes:
|
| 386 |
+
|
| 387 |
+
```python
|
| 388 |
+
competitor_recent_posts: [
|
| 389 |
+
{"content_type": "reel", "topic": "AI tools", "tags": ["ai", "tools"],
|
| 390 |
+
"engagement": 0.61, "hours_ago": 3},
|
| 391 |
+
{"content_type": "carousel", "topic": "startup tips", "tags": ["startup"],
|
| 392 |
+
"engagement": 0.48, "hours_ago": 8},
|
| 393 |
+
{"content_type": "reel", "topic": "AI news", "tags": ["ai", "news"],
|
| 394 |
+
"engagement": 0.52, "hours_ago": 14},
|
| 395 |
+
]
|
| 396 |
+
competitor_avg_engagement: 0.54
|
| 397 |
+
niche_saturation: 0.7 # 0.0=empty, 1.0=everyone posting same stuff
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
### How Competitors Affect Your Engagement
|
| 401 |
+
|
| 402 |
+
```
|
| 403 |
+
if your topic overlaps with ≥2 competitor posts in last 12hrs:
|
| 404 |
+
niche_saturation → high (0.7+)
|
| 405 |
+
your engagement × 0.6 (audience already saw similar content)
|
| 406 |
+
|
| 407 |
+
if your topic is unique (no overlap in 12hrs):
|
| 408 |
+
competitor_diff_bonus = 1.3x (fresh angle, algorithm favors)
|
| 409 |
+
|
| 410 |
+
if competitor engagement is HIGH on a topic:
|
| 411 |
+
that topic has proven demand, but also competition
|
| 412 |
+
→ agent must decide: follow the proven topic (safe) or differentiate (risky but higher upside)
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
### What the Agent Must Learn
|
| 416 |
+
|
| 417 |
+
1. **Monitor** competitor posting patterns and timing
|
| 418 |
+
2. **Differentiate** — find underserved time slots and topics
|
| 419 |
+
3. **Counter-program** — post different content type when competitors flood reels
|
| 420 |
+
4. **Learn from competitor success** — if competitor's carousel on "AI" got 0.8 engagement, the topic has demand, but post at a different time or with different tags
|
| 421 |
+
|
| 422 |
+
---
|
| 423 |
+
|
| 424 |
+
## Tasks & Graders (All Weekly — 168 steps)
|
| 425 |
+
|
| 426 |
+
All three tasks run for exactly **1 week (168 hourly steps)**. The difficulty increases through what dimensions are graded and what constraints apply.
|
| 427 |
+
|
| 428 |
+
### Task 1: weekly_engage (Easy)
|
| 429 |
+
|
| 430 |
+
**Focus**: Pure engagement maximization.
|
| 431 |
+
|
| 432 |
+
**What's active**: Basic mechanics only — time of day, content type, energy, audience fatigue.
|
| 433 |
+
|
| 434 |
+
**What's NOT graded**: Tags, competitors (still simulated but don't affect score).
|
| 435 |
+
|
| 436 |
+
**Grader formula**:
|
| 437 |
+
|
| 438 |
+
```
|
| 439 |
+
score = total_engagement / theoretical_max_engagement
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
**Theoretical max**: Calculated as if agent posted at every peak hour with best content type at full energy. Roughly ~14 optimal posts over 7 days.
|
| 443 |
+
|
| 444 |
+
**How it's computed**:
|
| 445 |
+
1. Sum all engagement values from every post the agent made
|
| 446 |
+
2. Divide by the theoretical max (computed from: 2 posts/day × 7 days × peak_hour_mult × best_content_rate × quality=1.0)
|
| 447 |
+
3. Clamp to [0.0, 1.0]
|
| 448 |
+
|
| 449 |
+
**What a smart agent does**: Posts 1-2x/day at peak hours (12-3PM), uses high-engagement content types (carousel/reel), rests to keep energy above 0.5.
|
| 450 |
+
|
| 451 |
+
**What a dumb agent scores**: Random ≈ 0.08–0.12. Spam-every-hour ≈ 0.15–0.25 (audience fatigue kills it).
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
### Task 2: weekly_strategic (Medium)
|
| 456 |
+
|
| 457 |
+
**Focus**: Engagement + energy management + tag optimization.
|
| 458 |
+
|
| 459 |
+
**What's active**: Everything from Task 1, PLUS tag engagement system.
|
| 460 |
+
|
| 461 |
+
**Grader formula**:
|
| 462 |
+
|
| 463 |
+
```
|
| 464 |
+
tag_discovery = unique_tags_used_with_positive_engagement / total_tag_pool_size
|
| 465 |
+
tag_exploitation = avg(top_3_tag_performances) / max_possible_tag_performance
|
| 466 |
+
|
| 467 |
+
tag_score = 0.4 × tag_discovery + 0.6 × tag_exploitation
|
| 468 |
+
|
| 469 |
+
score = (0.35 × normalized_engagement)
|
| 470 |
+
+ (0.25 × tag_score)
|
| 471 |
+
+ (0.25 × avg_energy)
|
| 472 |
+
+ (0.15 × consistency_score)
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
**Constraints**:
|
| 476 |
+
- If energy ever drops below 0.3 → score capped at 0.5
|
| 477 |
+
- If fewer than 5 unique tags used across the week → score × 0.7
|
| 478 |
+
|
| 479 |
+
**How each component works**:
|
| 480 |
+
|
| 481 |
+
| Component | What it measures | How it's normalized |
|
| 482 |
+
|---|---|---|
|
| 483 |
+
| `normalized_engagement` | Total engagement across all posts | `sum(engagement) / theoretical_max` |
|
| 484 |
+
| `tag_discovery` | Did the agent explore different tags? | `unique_positive_tags / 30 (pool size)` |
|
| 485 |
+
| `tag_exploitation` | Did the agent learn which tags work and reuse them? | `avg(best 3 tags) / 1.0` |
|
| 486 |
+
| `avg_energy` | Did the agent maintain sustainable energy? | `mean(energy at each step) / 1.0` |
|
| 487 |
+
| `consistency_score` | Regular posting rhythm | `days_with_1_or_2_posts / 7` |
|
| 488 |
+
|
| 489 |
+
**What a smart agent does**: Explores different tags in days 1-2, identifies top performers by day 3, then exploits them while riding trending tags. Balances rest to keep energy > 0.5.
|
| 490 |
+
|
| 491 |
+
**What a dumb agent scores**: Random ≈ 0.10–0.15 (random tags, no learning). Always-same-tags ≈ 0.20 (no discovery).
|
| 492 |
+
|
| 493 |
+
---
|
| 494 |
+
|
| 495 |
+
### Task 3: weekly_competitive (Hard)
|
| 496 |
+
|
| 497 |
+
**Focus**: Everything + competitor awareness + follower growth.
|
| 498 |
+
|
| 499 |
+
**What's active**: Full simulation — engagement, tags, competitors, niche saturation.
|
| 500 |
+
|
| 501 |
+
**Grader formula**:
|
| 502 |
+
|
| 503 |
+
```
|
| 504 |
+
follower_growth = (final_followers - initial_followers) / initial_followers
|
| 505 |
+
normalized_growth = min(1.0, follower_growth / target_growth_rate)
|
| 506 |
+
|
| 507 |
+
competitor_outperformance = your_avg_engagement / competitor_avg_engagement
|
| 508 |
+
normalized_outperformance = min(1.0, competitor_outperformance / 1.5)
|
| 509 |
+
|
| 510 |
+
differentiation = steps_where_topic_was_unique / total_posting_steps
|
| 511 |
+
|
| 512 |
+
score = (0.25 × normalized_engagement)
|
| 513 |
+
+ (0.20 × tag_score) ← same formula as Task 2
|
| 514 |
+
+ (0.20 × normalized_growth)
|
| 515 |
+
+ (0.15 × normalized_outperformance)
|
| 516 |
+
+ (0.10 × differentiation)
|
| 517 |
+
+ (0.10 × min_energy_floor)
|
| 518 |
+
```
|
| 519 |
+
|
| 520 |
+
**Constraints**:
|
| 521 |
+
- Energy hits 0 → score = 0.0 (total fail, burned out)
|
| 522 |
+
- Fewer than 3 content types used → score × 0.5
|
| 523 |
+
- Fewer than 8 unique tags used → score × 0.7
|
| 524 |
+
- If agent never checks competitor patterns (always overlaps) → differentiation = 0
|
| 525 |
+
|
| 526 |
+
**How each component works**:
|
| 527 |
+
|
| 528 |
+
| Component | Weight | What it measures | Detail |
|
| 529 |
+
|---|---|---|---|
|
| 530 |
+
| `normalized_engagement` | 25% | Raw engagement quality | Same as Task 1 |
|
| 531 |
+
| `tag_score` | 20% | Tag strategy quality | Discovery + exploitation (Task 2 formula) |
|
| 532 |
+
| `normalized_growth` | 20% | Follower growth over the week | `target_growth_rate` = 5% (500 new followers on 10K base) |
|
| 533 |
+
| `normalized_outperformance` | 15% | Beat your competitors | Your avg engagement / competitor avg. Capped at 1.0 when you're 1.5x better |
|
| 534 |
+
| `differentiation` | 10% | Posting unique angles | % of your posts where topic wasn't posted by competitors in last 12hrs |
|
| 535 |
+
| `min_energy_floor` | 10% | Never crashed | `min(energy_history)` — lowest energy point. Rewards agents that never dipped dangerously low |
|
| 536 |
+
|
| 537 |
+
**What a smart agent does**:
|
| 538 |
+
1. Days 1-2: Explore tags, observe competitor patterns
|
| 539 |
+
2. Days 3-4: Exploit best tags, counter-program competitors (post when they rest, pick gaps)
|
| 540 |
+
3. Days 5-7: Maximize engagement with learned strategy, maintain energy, diversify content types
|
| 541 |
+
|
| 542 |
+
**What a dumb agent scores**: Random ≈ 0.08. Copy-competitor-strategy ≈ 0.20 (no differentiation). Smart ≈ 0.50–0.75.
|
| 543 |
+
|
| 544 |
+
---
|
| 545 |
+
|
| 546 |
+
## Grading Strategy — In Depth
|
| 547 |
+
|
| 548 |
+
### Why Weekly for All Tasks
|
| 549 |
+
|
| 550 |
+
- **Consistency**: Same horizon (168 steps) makes graders comparable
|
| 551 |
+
- **Runtime**: 168 steps × 3 tasks = 504 total LLM calls. At ~2s per call = ~17 minutes. Under the 20-minute limit
|
| 552 |
+
- **Meaningful cycle**: A week is the natural content planning cycle for creators. Days are too short to show learning. Months are too long for inference budget
|
| 553 |
+
|
| 554 |
+
### Grading Philosophy
|
| 555 |
+
|
| 556 |
+
The grading is designed so that **each task requires mastering the previous task's skills plus new ones**:
|
| 557 |
+
|
| 558 |
+
```
|
| 559 |
+
Task 1 (Easy) → Can you post well?
|
| 560 |
+
(timing + content type + energy)
|
| 561 |
+
|
| 562 |
+
Task 2 (Medium) → Can you post SMART?
|
| 563 |
+
(Task 1 + tag discovery + tag exploitation)
|
| 564 |
+
|
| 565 |
+
Task 3 (Hard) → Can you OUTCOMPETE?
|
| 566 |
+
(Task 2 + competitor awareness + differentiation + growth)
|
| 567 |
+
```
|
| 568 |
+
|
| 569 |
+
### Why These Weights
|
| 570 |
+
|
| 571 |
+
**Task 1** — Engagement is everything (100% engagement-derived). Pure skill test.
|
| 572 |
+
|
| 573 |
+
**Task 2** — Split focus:
|
| 574 |
+
- 35% engagement (still important, but not enough alone)
|
| 575 |
+
- 25% tags (new skill: must explore AND exploit)
|
| 576 |
+
- 25% energy (sustainability matters now)
|
| 577 |
+
- 15% consistency (rhythm matters)
|
| 578 |
+
|
| 579 |
+
**Task 3** — Multi-dimensional:
|
| 580 |
+
- No single component dominates (max 25%)
|
| 581 |
+
- Agent must be good at everything, great at nothing is fine
|
| 582 |
+
- `differentiation` (10%) is small but acts as tiebreaker between otherwise similar agents
|
| 583 |
+
- `min_energy_floor` (10%) punishes agents that nearly crashed even if they recovered
|
| 584 |
+
|
| 585 |
+
### Anti-Gaming Properties
|
| 586 |
+
|
| 587 |
+
| Potential Exploit | Why it fails |
|
| 588 |
+
|---|---|
|
| 589 |
+
| Post every hour | Audience fatigue kills engagement → low `normalized_engagement` |
|
| 590 |
+
| Always rest | Zero engagement, zero tag score, zero growth → score ≈ 0.05 |
|
| 591 |
+
| Use same 2 tags always | `tag_discovery` tanks in Task 2/3. Score × 0.7 penalty if < 5/8 tags |
|
| 592 |
+
| Copy competitor topics | `differentiation` = 0, `niche_saturation` high → engagement × 0.6 |
|
| 593 |
+
| Post only reels | Score × 0.5 in Task 3 (need ≥ 3 types) |
|
| 594 |
+
| Ignore competitors entirely | Random overlap → sometimes lucky, but `differentiation` averages low |
|
| 595 |
+
| Post gibberish topics | Topic validation + no trending match → low engagement |
|
| 596 |
+
|
| 597 |
+
### Score Distribution (Expected)
|
| 598 |
+
|
| 599 |
+
| Agent Type | Task 1 | Task 2 | Task 3 |
|
| 600 |
+
|---|---|---|---|
|
| 601 |
+
| Random | 0.08–0.12 | 0.10–0.15 | 0.06–0.10 |
|
| 602 |
+
| Always rest | 0.02 | 0.05 | 0.02 |
|
| 603 |
+
| Spam (post every step) | 0.15–0.25 | 0.12–0.18 | 0.08–0.15 |
|
| 604 |
+
| Fixed strategy (no learning) | 0.30–0.40 | 0.25–0.35 | 0.20–0.30 |
|
| 605 |
+
| Smart LLM agent | 0.55–0.80 | 0.45–0.70 | 0.40–0.65 |
|
| 606 |
+
|
| 607 |
+
Task 3 is intentionally hardest — even a good agent won't ace it because competitor dynamics add noise and require adaptation.
|
| 608 |
+
|
| 609 |
+
---
|
| 610 |
+
|
| 611 |
+
## Anti-Exploit Guards
|
| 612 |
+
|
| 613 |
+
| Exploit | Guard |
|
| 614 |
+
|---|---|
|
| 615 |
+
| Reward hacking (long gibberish) | Cap reward per step at 1.0, validate topic, max 200 chars |
|
| 616 |
+
| Grader gaming | Random agent must score < 0.15, spam agent < 0.30 |
|
| 617 |
+
| State reset abuse | Reset only works between tasks, mid-episode reset ignored |
|
| 618 |
+
| Invalid actions | Strict field validation, invalid → 0 reward + error |
|
| 619 |
+
| Rest farming | Rest → reward ≈ 0, energy is a resource not a goal |
|
| 620 |
+
| Repetitive posting | Same type 3x → engagement -20% + energy penalty |
|
| 621 |
+
| Tag spamming | Max 5 tags per post, must be from known pool |
|
| 622 |
+
| Competitor copying | Niche saturation penalty, differentiation score = 0 |
|
| 623 |
+
|
| 624 |
+
### Sanity Test Agents
|
| 625 |
+
|
| 626 |
+
Run before submitting:
|
| 627 |
+
|
| 628 |
+
| Agent | Expected Score (Task 3) | Red Flag If |
|
| 629 |
+
|---|---|---|
|
| 630 |
+
| Random agent | < 0.10 | Reward too easy |
|
| 631 |
+
| Always-rest | < 0.05 | Resting rewarded |
|
| 632 |
+
| Spam (post every step, same type) | < 0.15 | No fatigue working |
|
| 633 |
+
| Fixed (same action every time) | < 0.30 | Environment too simple |
|
| 634 |
+
| Smart (LLM-driven) | 0.40–0.65 | This is the real range |
|
| 635 |
+
|
| 636 |
+
---
|
| 637 |
+
|
| 638 |
+
## Simulation Mechanics
|
| 639 |
+
|
| 640 |
+
### Energy Dynamics (research-backed)
|
| 641 |
+
|
| 642 |
+
```python
|
| 643 |
+
energy -= content_cost[action.content_type]
|
| 644 |
+
|
| 645 |
+
# Repetition fatigue (creative fatigue = 40% of burnout)
|
| 646 |
+
if action.content_type == last_3_posts_type:
|
| 647 |
+
energy -= 0.05
|
| 648 |
+
|
| 649 |
+
# Recovery: slow, not instant
|
| 650 |
+
if action.action_type == "rest":
|
| 651 |
+
energy = min(1.0, energy + 0.12)
|
| 652 |
+
|
| 653 |
+
# Quality modifier (30-52% productivity drop at burnout)
|
| 654 |
+
quality = 1.0 if energy > 0.5 else max(0.48, energy * 1.5)
|
| 655 |
+
```
|
| 656 |
+
|
| 657 |
+
### Extended Features
|
| 658 |
+
|
| 659 |
+
#### A. Content Repetition Fatigue
|
| 660 |
+
Same content type 3x in a row → engagement drops 20%. Based on creative fatigue being #1 burnout cause (40%).
|
| 661 |
+
|
| 662 |
+
#### B. Platform Activity / Competition Window
|
| 663 |
+
`niche_saturation` (0.0–1.0) in observation. When many competitors post same topic → per-post engagement drops. From the broadcast scheduling paper (Preprints.org 2025).
|
| 664 |
+
|
| 665 |
+
#### C. Follower Tier Response
|
| 666 |
+
Small accounts (<10K) get more from reels (reach). Large accounts (>50K) benefit from carousels (depth). From CreatorsJet 10K post study.
|
| 667 |
+
|
| 668 |
+
#### D. Trending Topic & Tag Bonus
|
| 669 |
+
If topic or tags match trending → 1.5x and +10% respectively. Topics and tags rotate daily (seeded). Forces adaptive behavior.
|
| 670 |
+
|
| 671 |
+
#### E. Algorithm Penalty for Inconsistency
|
| 672 |
+
No post for 48+ hours → next 2 posts get 0.6x engagement. Based on algorithmic content selection research (arxiv:2410.13108).
|
| 673 |
+
|
| 674 |
+
#### F. Tag Engagement Tracking
|
| 675 |
+
Full per-tag engagement history. Agent sees which tags produce results and must balance exploration (try new tags) vs exploitation (reuse winners). See Tag Engagement System section.
|
| 676 |
+
|
| 677 |
+
#### G. Competitor Awareness
|
| 678 |
+
3 simulated rival creators with deterministic posting schedules. Agent sees their recent posts, topics, tags, and engagement. Must differentiate to avoid saturation. See Competitor Intelligence System section.
|
| 679 |
+
|
| 680 |
+
---
|
| 681 |
+
|
| 682 |
+
## Research Backing
|
| 683 |
+
|
| 684 |
+
### Engagement Data
|
| 685 |
+
|
| 686 |
+
- **Buffer 2026**: 9.6M posts analyzed — peak posting times, day-of-week effects
|
| 687 |
+
- **SocialInsider 2025**: Engagement rates by content type (carousel 0.55%, reel 0.52%, image 0.37%)
|
| 688 |
+
- **CreatorsJet 10K post study**: Reels give 2.25x reach vs images, carousels give depth
|
| 689 |
+
|
| 690 |
+
### Burnout Data
|
| 691 |
+
|
| 692 |
+
- **Sozee 2026**: 90% creators experience burnout, 30-52% productivity drop
|
| 693 |
+
- **TastyEdits Creator Study**: 57% spend 4+ hrs/day, 79% have experienced burnout
|
| 694 |
+
- **Creative fatigue**: #1 cause at 40%, algorithm pressure at 38%
|
| 695 |
+
|
| 696 |
+
### Academic Papers
|
| 697 |
+
|
| 698 |
+
| Paper | Relevance |
|
| 699 |
+
|---|---|
|
| 700 |
+
| "Review Old Strategies, New Environments: RL on Social Media" (ScienceDirect 2024) | RL framework for social media — validates env design |
|
| 701 |
+
| arxiv:2410.13108 "Algorithmic Content Selection and User Disengagement" | Over-optimizing immediate engagement causes churn — justifies burnout mechanic |
|
| 702 |
+
| arxiv:2211.13585 "Learning Optimal Break Policies" | Strategic breaks sustain engagement — supports "rest" action |
|
| 703 |
+
| "Optimizing Broadcast Scheduling" (Preprints.org 2025) | Low-competition windows > frequency — competition variable |
|
| 704 |
+
| RLNVR arxiv:2508.12165 | RL from noisy social media signals — proves this is active research |
|
| 705 |
+
|
| 706 |
+
### Data Sources
|
| 707 |
+
|
| 708 |
+
- **Meta Content Library**: Real engagement data for public Instagram/Facebook posts ([docs](https://developers.facebook.com/docs/content-library-and-api))
|
| 709 |
+
- **Meta Graph API — Creator Marketplace Insights**: Real creator metrics ([docs](https://developers.facebook.com/docs/graph-api/reference/creator-marketplace-content/insights/))
|
| 710 |
+
|
| 711 |
+
---
|
| 712 |
+
|
| 713 |
+
## Inference Script Structure
|
| 714 |
+
|
| 715 |
+
```python
|
| 716 |
+
import os
|
| 717 |
+
from openai import OpenAI
|
| 718 |
+
from viraltest import ViraltestEnv, ViraltestAction
|
| 719 |
+
|
| 720 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 721 |
+
API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
|
| 722 |
+
MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
|
| 723 |
+
TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
|
| 724 |
+
MAX_STEPS = 168 # 7 days × 24 hours (same for all tasks)
|
| 725 |
+
|
| 726 |
+
client = OpenAI(api_key=API_KEY, base_url=API_BASE_URL)
|
| 727 |
+
|
| 728 |
+
for task in TASKS:
|
| 729 |
+
log_start(task, "viraltest", MODEL_NAME)
|
| 730 |
+
env = ViraltestEnv(base_url="http://localhost:8000")
|
| 731 |
+
result = env.reset(task=task)
|
| 732 |
+
rewards = []
|
| 733 |
+
|
| 734 |
+
for step in range(MAX_STEPS):
|
| 735 |
+
obs = result.observation
|
| 736 |
+
user_msg = format_observation(obs)
|
| 737 |
+
response = client.chat.completions.create(
|
| 738 |
+
model=MODEL_NAME,
|
| 739 |
+
messages=[
|
| 740 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 741 |
+
{"role": "user", "content": user_msg}
|
| 742 |
+
],
|
| 743 |
+
temperature=0.7, max_tokens=150
|
| 744 |
+
)
|
| 745 |
+
action = parse_action(response.choices[0].message.content)
|
| 746 |
+
result = env.step(action)
|
| 747 |
+
rewards.append(result.reward)
|
| 748 |
+
log_step(step+1, str(action), result.reward, result.done, None)
|
| 749 |
+
if result.done:
|
| 750 |
+
break
|
| 751 |
+
|
| 752 |
+
score = grader_score(task, rewards, obs)
|
| 753 |
+
log_end(score > 0.1, len(rewards), score, rewards)
|
| 754 |
+
env.close()
|
| 755 |
+
```
|
| 756 |
+
|
| 757 |
+
Log format:
|
| 758 |
+
|
| 759 |
+
```
|
| 760 |
+
[START] task=weekly_competitive env=viraltest model=Qwen/Qwen2.5-72B-Instruct
|
| 761 |
+
[STEP] step=1 action=post(reel,"AI trends",["ai","tech"]) reward=0.67 done=false error=null
|
| 762 |
+
[STEP] step=2 action=rest() reward=0.05 done=false error=null
|
| 763 |
+
...
|
| 764 |
+
[END] success=true steps=168 score=0.624 rewards=0.67,0.05,...,0.55
|
| 765 |
+
```
|
| 766 |
+
|
| 767 |
+
---
|
| 768 |
+
|
| 769 |
+
## Judging Alignment
|
| 770 |
+
|
| 771 |
+
| Criteria | Weight | What backs us |
|
| 772 |
+
|---|---|---|
|
| 773 |
+
| Real-world utility | 30% | Meta Content Library, Buffer study, creator burnout stats, tag analytics, competitor analysis |
|
| 774 |
+
| Task & grader quality | 25% | 3 weekly tasks with progressive difficulty, multi-component graders, deterministic |
|
| 775 |
+
| Environment design | 20% | Energy from burnout studies, engagement from SocialInsider, tag + competitor systems |
|
| 776 |
+
| Code quality & spec | 15% | OpenEnv compliant, typed models, Dockerfile works |
|
| 777 |
+
| Creativity & novelty | 10% | Multi-objective (engagement vs burnout vs tags vs competition), backed by 5+ papers |
|
| 778 |
+
|
| 779 |
+
---
|
| 780 |
+
|
| 781 |
+
## File Map
|
| 782 |
+
|
| 783 |
+
| File | Purpose |
|
| 784 |
+
|---|---|
|
| 785 |
+
| `models.py` | `ViraltestAction` and `ViraltestObservation` Pydantic models |
|
| 786 |
+
| `server/viraltest_environment.py` | Simulation logic, task switching, graders, reward calc, tag + competitor systems |
|
| 787 |
+
| `client.py` | `ViraltestEnv` client — `_step_payload`, `_parse_result`, `_parse_state` |
|
| 788 |
+
| `inference.py` | LLM-driven agent with `[START]`/`[STEP]`/`[END]` logging |
|
| 789 |
+
| `openenv.yaml` | Environment metadata |
|
| 790 |
+
| `Dockerfile` | Container build |
|
| 791 |
+
| `README.md` | User-facing docs |
|
| 792 |
+
| `DESIGN.md` | This file |
|
Dockerfile
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
# Multi-stage build using openenv-base
|
| 8 |
+
# This Dockerfile is flexible and works for both:
|
| 9 |
+
# - In-repo environments (with local OpenEnv sources)
|
| 10 |
+
# - Standalone environments (with openenv from PyPI/Git)
|
| 11 |
+
# The build script (openenv build) handles context detection and sets appropriate build args.
|
| 12 |
+
|
| 13 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 14 |
+
FROM ${BASE_IMAGE} AS builder
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Ensure git is available (required for installing dependencies from VCS)
|
| 19 |
+
RUN apt-get update && \
|
| 20 |
+
apt-get install -y --no-install-recommends git && \
|
| 21 |
+
rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Build argument to control whether we're building standalone or in-repo
|
| 24 |
+
ARG BUILD_MODE=in-repo
|
| 25 |
+
ARG ENV_NAME=viraltest
|
| 26 |
+
|
| 27 |
+
# Copy environment code (always at root of build context)
|
| 28 |
+
COPY . /app/env
|
| 29 |
+
|
| 30 |
+
# For in-repo builds, openenv is already vendored in the build context
|
| 31 |
+
# For standalone builds, openenv will be installed via pyproject.toml
|
| 32 |
+
WORKDIR /app/env
|
| 33 |
+
|
| 34 |
+
# Ensure uv is available (for local builds where base image lacks it)
|
| 35 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 36 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 37 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 38 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
# Install dependencies using uv sync
|
| 42 |
+
# If uv.lock exists, use it; otherwise resolve on the fly
|
| 43 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 44 |
+
if [ -f uv.lock ]; then \
|
| 45 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 46 |
+
else \
|
| 47 |
+
uv sync --no-install-project --no-editable; \
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 51 |
+
if [ -f uv.lock ]; then \
|
| 52 |
+
uv sync --frozen --no-editable; \
|
| 53 |
+
else \
|
| 54 |
+
uv sync --no-editable; \
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
# Final runtime stage
|
| 58 |
+
FROM ${BASE_IMAGE}
|
| 59 |
+
|
| 60 |
+
WORKDIR /app
|
| 61 |
+
|
| 62 |
+
# Copy the virtual environment from builder
|
| 63 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 64 |
+
|
| 65 |
+
# Copy the environment code
|
| 66 |
+
COPY --from=builder /app/env /app/env
|
| 67 |
+
|
| 68 |
+
# Set PATH to use the virtual environment
|
| 69 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 70 |
+
|
| 71 |
+
# Set PYTHONPATH so imports work correctly
|
| 72 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 73 |
+
|
| 74 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 75 |
+
|
| 76 |
+
# Health check
|
| 77 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 78 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 79 |
+
|
| 80 |
+
# Run the FastAPI server
|
| 81 |
+
# The module path is constructed to work with the /app/env structure
|
| 82 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn viraltest.server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Viraltest — Creator Optimization Agent
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Viraltest — RL-Based Creator Optimization Environment
|
| 15 |
+
|
| 16 |
+
An [OpenEnv](https://github.com/meta-pytorch/OpenEnv) environment that simulates a social media creator’s weekly posting lifecycle. An AI agent learns **when to post**, **what format**, **which tags**, and **how to differentiate from competitors** — maximizing engagement while managing burnout and sleep.
|
| 17 |
+
|
| 18 |
+
## Submission requirements — how this repo maps
|
| 19 |
+
|
| 20 |
+
Use this table to confirm Phase 1 (automated) gates before you submit.
|
| 21 |
+
|
| 22 |
+
| Requirement | Status in this repo | Where to verify |
|
| 23 |
+
|---------------|---------------------|-----------------|
|
| 24 |
+
| Real-world task (not a toy/game) | **Met** — creator scheduling, energy, trends, competitors | `server/viraltest_environment.py`, `DESIGN.md` |
|
| 25 |
+
| Full OpenEnv spec: `openenv.yaml`, typed models, HTTP API | **Met** | `openenv.yaml`, `models.py`, `server/app.py` (`create_app`) |
|
| 26 |
+
| `step()` / `reset()` / `state()` | **Met** — standard OpenEnv HTTP endpoints | Run `openenv validate` |
|
| 27 |
+
| ≥3 tasks with graders (easy → hard), scores in **0.0–1.0** | **Met** — `weekly_engage`, `weekly_strategic`, `weekly_competitive` | `_run_grader()` in `server/viraltest_environment.py` |
|
| 28 |
+
| Meaningful reward + partial progress | **Met** — per-step `_compute_reward()` | `_compute_reward()` |
|
| 29 |
+
| Baseline inference script, reproducible | **Met** — root `inference.py` | See **Baseline inference** below |
|
| 30 |
+
| `Dockerfile` builds | **Expected** — root `Dockerfile` | `docker build -t viraltest .` (run locally) |
|
| 31 |
+
| HF Space deploys; `POST /reset` returns **200** | **You must configure** | See **Hugging Face Spaces** — ping **Space root**, not only `/web` |
|
| 32 |
+
| `openenv validate` passes | **Met** in dev (`.venv/bin/openenv validate`) | CI / local |
|
| 33 |
+
| Env vars: `API_BASE_URL`, `MODEL_NAME`, `HF_TOKEN` | **Documented** — `inference.py` reads them (see **Environment variables**) | HF Space **Settings → Secrets** |
|
| 34 |
+
| `inference.py` at repo root; OpenAI client for LLM calls | **Met** | `inference.py` |
|
| 35 |
+
| Structured stdout: `[START]`, `[STEP]`, `[END]` | **Met** — match field order in `log_*` helpers | `inference.py` |
|
| 36 |
+
| Inference under 20 minutes; 2 vCPU / 8 GB | **Check** — 3 tasks × up to 168 steps each = many LLM calls; use a fast endpoint and sensible `MAX_TOKENS` | `inference.py` |
|
| 37 |
+
|
| 38 |
+
### Minor items to double-check before judging
|
| 39 |
+
|
| 40 |
+
1. **`[STEP]` `error=` field** — The spec asks for the raw `last_action_error` or `null`. This repo logs errors with spaces replaced by underscores so each line stays a single token after `error=`. If the organizer’s parser expects literal spaces inside unquoted messages, align with their sample; otherwise this is fine for one-line logs.
|
| 41 |
+
2. **Default `API_BASE_URL` in `inference.py`** — Defaults are for local dev. On Hugging Face, set **`API_BASE_URL`** (e.g. `https://router.huggingface.co/v1`) and **`MODEL_NAME`** in Secrets so evaluation matches your setup.
|
| 42 |
+
3. **Space URL for the validator** — The official script POSTs to `{your_space_url}/reset` with body `{}`. That must be the **root** of the Space (e.g. `https://YOURNAME-spacename.hf.space`), not the Gradio path under `base_path: /web`. Confirm with curl (see **Pre-submission validation**).
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## Why this matters
|
| 47 |
+
|
| 48 |
+
Many creators burn out while optimizing posting times and formats. This environment turns that tradeoff into a reproducible simulation so agents can be trained and compared on the same weekly horizon (**168** hourly steps).
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Quick Start (Python)
|
| 53 |
+
|
| 54 |
+
The HTTP client is **async** (same pattern as root `inference.py`):
|
| 55 |
+
|
| 56 |
+
```python
|
| 57 |
+
import asyncio
|
| 58 |
+
from viraltest import ViraltestAction, ViraltestEnv
|
| 59 |
+
|
| 60 |
+
async def main():
|
| 61 |
+
env = ViraltestEnv(base_url="http://localhost:8000")
|
| 62 |
+
try:
|
| 63 |
+
result = await env.reset(task="weekly_engage")
|
| 64 |
+
action = ViraltestAction(
|
| 65 |
+
action_type="post",
|
| 66 |
+
content_type="reel",
|
| 67 |
+
topic="AI trends",
|
| 68 |
+
tags=["ai", "coding", "devtools"],
|
| 69 |
+
)
|
| 70 |
+
result = await env.step(action)
|
| 71 |
+
print(result.observation.engagement_rate, result.observation.creator_energy)
|
| 72 |
+
finally:
|
| 73 |
+
await env.close()
|
| 74 |
+
|
| 75 |
+
asyncio.run(main())
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Action space
|
| 81 |
+
|
| 82 |
+
| Field | Type | Description |
|
| 83 |
+
|-------|------|-------------|
|
| 84 |
+
| `action_type` | `"post" \| "rest" \| "create_content"` | What the agent does this hour |
|
| 85 |
+
| `content_type` | `"reel" \| "story" \| "carousel" \| "text_post"` | Required when posting |
|
| 86 |
+
| `topic` | `str` (≤200 chars) | Post topic |
|
| 87 |
+
| `tags` | `list[str]` (≤5) | Tags from the environment tag pool |
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## Observation space (high level)
|
| 92 |
+
|
| 93 |
+
| Field | Description |
|
| 94 |
+
|-------|-------------|
|
| 95 |
+
| `current_hour`, `day_of_week`, `days_elapsed` | Simulated calendar |
|
| 96 |
+
| `creator_energy`, `hours_since_sleep`, `sleep_debt` | Burnout and sleep |
|
| 97 |
+
| `follower_count`, `engagement_rate` | Growth and rolling engagement |
|
| 98 |
+
| `trending_topics`, `trending_tags`, `tag_performance` | Trends and learned tag quality |
|
| 99 |
+
| `competitor_recent_posts`, `competitor_avg_engagement`, `niche_saturation` | Competition |
|
| 100 |
+
| `error`, `reward`, `done`, `metadata` | Errors, shaping reward, termination, **`metadata["grader_score"]` at episode end** |
|
| 101 |
+
|
| 102 |
+
Full schema: `GET /schema` when the server is running.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Tasks and graders (168 steps each)
|
| 107 |
+
|
| 108 |
+
| Task | Difficulty | Grader focus |
|
| 109 |
+
|------|------------|--------------|
|
| 110 |
+
| `weekly_engage` | Easier | Total engagement vs theoretical max; burnout penalty |
|
| 111 |
+
| `weekly_strategic` | Medium | Engagement + tag discovery/exploitation + energy + consistency |
|
| 112 |
+
| `weekly_competitive` | Hard | Adds growth vs competitors, differentiation, diversity constraints |
|
| 113 |
+
|
| 114 |
+
Episode ends after **168** steps or if **energy ≤ 0**. Final normalized score is in **`observation.metadata["grader_score"]`** in **\[0, 1\]**.
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Reward shaping
|
| 119 |
+
|
| 120 |
+
Per-step reward in **`[0, 1]`** combines engagement, energy change, posting consistency, tags, and competitor differentiation (`_compute_reward` in `server/viraltest_environment.py`). It is dense enough for learning signals before the terminal grader runs.
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## Local development
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
git clone <your-repo-url>
|
| 128 |
+
cd viral-posts-env # or your fork name
|
| 129 |
+
|
| 130 |
+
# Install (uv recommended; pip works too)
|
| 131 |
+
uv sync
|
| 132 |
+
# source .venv/bin/activate # optional
|
| 133 |
+
|
| 134 |
+
# Terminal 1 — API server
|
| 135 |
+
uvicorn viraltest.server.app:app --host 0.0.0.0 --port 8000
|
| 136 |
+
|
| 137 |
+
# Terminal 2 — optional UI
|
| 138 |
+
# Open http://localhost:8000/dashboard (see server routes in server/app.py)
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Validate the OpenEnv layout:
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
.venv/bin/openenv validate
|
| 145 |
+
# Expect: [OK] ... Ready for multi-mode deployment
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Docker
|
| 151 |
+
|
| 152 |
+
From the repository root (same directory as `Dockerfile`):
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
docker build -t viraltest-env:latest .
|
| 156 |
+
docker run --rm -p 8000:8000 viraltest-env:latest
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
Smoke test:
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{}' http://localhost:8000/reset
|
| 163 |
+
# Expect: 200
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
---
|
| 167 |
+
|
| 168 |
+
## Hugging Face Spaces — deploy
|
| 169 |
+
|
| 170 |
+
1. **Create a Space** with **Docker** SDK (this repo’s README frontmatter uses `sdk: docker`).
|
| 171 |
+
2. **Push this repository** (or connect GitHub) so the Space builds from the root `Dockerfile`.
|
| 172 |
+
3. **Settings → Variables and secrets** — add at least:
|
| 173 |
+
- **`HF_TOKEN`** — Hugging Face API token for inference (and Space pull if private).
|
| 174 |
+
- **`API_BASE_URL`** — OpenAI-compatible base URL (e.g. `https://router.huggingface.co/v1`).
|
| 175 |
+
- **`MODEL_NAME`** — Model id for that router (e.g. `Qwen/Qwen2.5-72B-Instruct`).
|
| 176 |
+
4. **App port** — `8000` (see frontmatter `app_port: 8000`).
|
| 177 |
+
5. **`base_path: /web`** — Used for the bundled web UI; the **REST** endpoints (`/reset`, `/step`, `/state`) remain on the **Space root host** as required by the submission validator. **Always test** `https://<your-space>.hf.space/reset` (not only `/web/...`).
|
| 178 |
+
|
| 179 |
+
Optional CLI (if you use OpenEnv’s tooling):
|
| 180 |
+
|
| 181 |
+
```bash
|
| 182 |
+
pip install openenv-core
|
| 183 |
+
openenv push # follow OpenEnv docs for auth and target Space
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Baseline inference (`inference.py`)
|
| 189 |
+
|
| 190 |
+
**Location:** repository root — **`inference.py`** (required by the hackathon).
|
| 191 |
+
|
| 192 |
+
**LLM client:** OpenAI-compatible client (`from openai import OpenAI`) using:
|
| 193 |
+
|
| 194 |
+
| Variable | Role |
|
| 195 |
+
|----------|------|
|
| 196 |
+
| `API_BASE_URL` | OpenAI-compatible API base |
|
| 197 |
+
| `MODEL_NAME` | Model name for `chat.completions` |
|
| 198 |
+
| `HF_TOKEN` | Preferred API key (fallbacks: `OPENAI_API_KEY`, `API_KEY`) |
|
| 199 |
+
| `IMAGE_NAME` / `LOCAL_IMAGE_NAME` | If using `ViraltestEnv.from_docker_image(...)` instead of HTTP |
|
| 200 |
+
| `ENV_BASE_URL` | HTTP server URL (default `http://localhost:8000`) |
|
| 201 |
+
|
| 202 |
+
**Stdout format (must not change field names or order):**
|
| 203 |
+
|
| 204 |
+
```text
|
| 205 |
+
[START] task=<name> env=<benchmark> model=<model>
|
| 206 |
+
[STEP] step=<n> action=<str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 207 |
+
[END] success=<true|false> steps=<n> score=<0.00> rewards=<r1,r2,...>
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
Run locally (server on port 8000):
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
export HF_TOKEN=hf_...
|
| 214 |
+
export API_BASE_URL=https://router.huggingface.co/v1
|
| 215 |
+
export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
|
| 216 |
+
uv sync && .venv/bin/python inference.py
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
**Short episodes for debugging** — `ALLOW_SHORT_EPISODE=1` and `MAX_STEPS` can shorten runs; full weekly tasks still use **168** steps unless you override (see comments in `inference.py`).
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## Pre-submission validation
|
| 224 |
+
|
| 225 |
+
Use the provided script (same checks as the official template: ping Space, Docker build, `openenv validate`):
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
chmod +x validate-submission.sh
|
| 229 |
+
./validate-submission.sh https://YOUR-SPACE.hf.space /path/to/viral-posts-env
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
Or download the organizer’s script from their repo and pass your Space URL.
|
| 233 |
+
|
| 234 |
+
**Manual ping (required to pass automated gate):**
|
| 235 |
+
|
| 236 |
+
```bash
|
| 237 |
+
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
| 238 |
+
-H "Content-Type: application/json" -d '{}' \
|
| 239 |
+
https://YOUR-SPACE.hf.space/reset
|
| 240 |
+
# Must print: 200
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## Baseline scores (reference)
|
| 246 |
+
|
| 247 |
+
Deterministic dashboard agents (not the LLM) — see `README` tables in-repo history / `DESIGN.md` for methodology. Your **`inference.py`** scores will vary by model and endpoint; keep runs under the **20-minute** inference budget.
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
## Project structure
|
| 252 |
+
|
| 253 |
+
```
|
| 254 |
+
.
|
| 255 |
+
├── inference.py # Hackathon-required baseline (LLM + [START]/[STEP]/[END])
|
| 256 |
+
├── openenv.yaml # OpenEnv manifest
|
| 257 |
+
├── models.py # ViraltestAction, ViraltestObservation
|
| 258 |
+
├── client.py # ViraltestEnv client
|
| 259 |
+
├── Dockerfile
|
| 260 |
+
├── validate-submission.sh # Local preflight
|
| 261 |
+
├── test_scenarios.py # Offline env tests
|
| 262 |
+
├── DESIGN.md # Deep design / research notes
|
| 263 |
+
└── server/
|
| 264 |
+
├── app.py # FastAPI + create_app
|
| 265 |
+
├── viraltest_environment.py
|
| 266 |
+
└── dashboard.html
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
## License
|
| 272 |
+
|
| 273 |
+
See `LICENSE` in the repository root (BSD-style per upstream OpenEnv examples).
|
SIMULATION_REPORT.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Viraltest Simulation Report
|
| 2 |
+
|
| 3 |
+
**Task:** Hard — Competitive (weekly_competitive)
|
| 4 |
+
**Episode Length:** 168 steps (7 days x 24 hours)
|
| 5 |
+
**Starting Followers:** 10,000 | **Starting Energy:** 1.00
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Executive Summary
|
| 10 |
+
|
| 11 |
+
11 agent strategies were evaluated on the Hard — Competitive task. The **Balanced Creator** (0.8775) and **Smart Agent** (0.8745) achieved the highest scores by combining strategic posting, energy management, and tag diversity. Two agents (**Spam Post**, **No Rest**) burned out within 8 steps, scoring 0.0000. The **Always Rest** agent lost 45% of its followers from inactivity.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Leaderboard
|
| 16 |
+
|
| 17 |
+
| Rank | Scenario | Score | Followers | Delta | Energy | Burned Out |
|
| 18 |
+
|------|----------|-------|-----------|-------|--------|------------|
|
| 19 |
+
| 1 | Balanced Creator | **0.8775** | 12,534 | +2,534 (+25.3%) | 1.00 | No |
|
| 20 |
+
| 2 | Smart Agent | **0.8745** | 12,200 | +2,200 (+22.0%) | 1.00 | No |
|
| 21 |
+
| 3 | Tag Explorer | **0.8323** | 11,351 | +1,351 (+13.5%) | 0.94 | No |
|
| 22 |
+
| 4 | Copycat | **0.6136** | 11,589 | +1,589 (+15.9%) | 1.00 | No |
|
| 23 |
+
| 5 | Burst Poster | **0.6111** | 11,701 | +1,701 (+17.0%) | 0.44 | No |
|
| 24 |
+
| 6 | Queue Optimizer | **0.3520** | 11,215 | +1,215 (+12.2%) | 1.00 | No |
|
| 25 |
+
| 7 | Weekend Warrior | **0.1257** | 7,659 | -2,341 (-23.4%) | 1.00 | No |
|
| 26 |
+
| 8 | Night Poster | **0.0937** | 10,237 | +237 (+2.4%) | 0.59 | No |
|
| 27 |
+
| 9 | Always Rest | **0.0350** | 5,497 | -4,503 (-45.0%) | 1.00 | No |
|
| 28 |
+
| 10 | Spam Post | **0.0000** | 10,625 | +625 (+6.3%) | 0.00 | **YES** |
|
| 29 |
+
| 11 | No Rest | **0.0000** | 10,213 | +213 (+2.1%) | 0.00 | **YES** |
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Detailed Agent Analysis
|
| 34 |
+
|
| 35 |
+
### 1. Balanced Creator — Score: 0.8775 (BEST)
|
| 36 |
+
|
| 37 |
+
| Metric | Value |
|
| 38 |
+
|--------|-------|
|
| 39 |
+
| Steps Completed | 168 / 168 |
|
| 40 |
+
| Final Energy | 1.00 |
|
| 41 |
+
| Final Followers | 12,534 (+25.3%) |
|
| 42 |
+
| Engagement Rate | 0.827 |
|
| 43 |
+
| Total Posts | 28 |
|
| 44 |
+
| Total Rests | 84 |
|
| 45 |
+
| Content Created | 56 |
|
| 46 |
+
| Unique Tags | 19 |
|
| 47 |
+
| Min Energy | 0.795 (never dipped below safe zone) |
|
| 48 |
+
| Avg Reward | 0.219 |
|
| 49 |
+
| Max Reward | 0.738 |
|
| 50 |
+
|
| 51 |
+
**Strategy:** Create → Post → Rest cycle. Uses the content queue (56 items created, 28 posted from queue at 50% energy cost). Posts during peak hours with trending topics. Never risks burnout.
|
| 52 |
+
|
| 53 |
+
**Top Tags:** #food (1.32), #election (1.31), #coding (1.16), #saas (1.03), #crypto (1.02)
|
| 54 |
+
|
| 55 |
+
**Why it won:** Highest follower growth (+2,534), perfect energy management (never below 0.795), excellent tag diversity (19 unique), and consistent daily posting.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
### 2. Smart Agent — Score: 0.8745
|
| 60 |
+
|
| 61 |
+
| Metric | Value |
|
| 62 |
+
|--------|-------|
|
| 63 |
+
| Steps Completed | 168 / 168 |
|
| 64 |
+
| Final Energy | 1.00 |
|
| 65 |
+
| Final Followers | 12,200 (+22.0%) |
|
| 66 |
+
| Engagement Rate | 1.556 |
|
| 67 |
+
| Total Posts | 14 |
|
| 68 |
+
| Total Rests | 154 |
|
| 69 |
+
| Unique Tags | 19 |
|
| 70 |
+
| Min Energy | 0.55 |
|
| 71 |
+
| Avg Reward | 0.230 |
|
| 72 |
+
| Max Reward | 0.760 |
|
| 73 |
+
|
| 74 |
+
**Strategy:** Posts only during peak hours (9-20) when energy > 0.4 and posts < 2/day. Uses trending topics and tags. Rests aggressively.
|
| 75 |
+
|
| 76 |
+
**Top Tags:** #ai (3.56), #wellness (2.55), #summer (2.36), #crypto (2.18), #newyear (2.01)
|
| 77 |
+
|
| 78 |
+
**Why it's strong:** Highest individual tag performance (#ai at 3.56), highest engagement rate (1.556), but fewer posts (14 vs 28) cost it the top spot.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
### 3. Tag Explorer — Score: 0.8323
|
| 83 |
+
|
| 84 |
+
| Metric | Value |
|
| 85 |
+
|--------|-------|
|
| 86 |
+
| Steps Completed | 168 / 168 |
|
| 87 |
+
| Final Energy | 0.94 |
|
| 88 |
+
| Final Followers | 11,351 (+13.5%) |
|
| 89 |
+
| Engagement Rate | 0.774 |
|
| 90 |
+
| Total Posts | 15 |
|
| 91 |
+
| Unique Tags | **30** (highest) |
|
| 92 |
+
| Min Energy | 0.69 |
|
| 93 |
+
|
| 94 |
+
**Strategy:** New tag combination every post. Maximizes tag discovery — 30 unique tags used (the highest of all agents).
|
| 95 |
+
|
| 96 |
+
**Why it scored high:** The grading formula rewards tag diversity heavily. 30 unique tags gave a massive tag_discovery bonus.
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
### 4. Copycat — Score: 0.6136
|
| 101 |
+
|
| 102 |
+
| Metric | Value |
|
| 103 |
+
|--------|-------|
|
| 104 |
+
| Steps Completed | 168 / 168 |
|
| 105 |
+
| Final Energy | 1.00 |
|
| 106 |
+
| Final Followers | 11,589 (+15.9%) |
|
| 107 |
+
| Total Posts | 21 |
|
| 108 |
+
| Unique Tags | 8 |
|
| 109 |
+
| Min Energy | 0.10 (dangerous dip!) |
|
| 110 |
+
|
| 111 |
+
**Strategy:** Copies competitor topics and content types. Posts when competitors are active.
|
| 112 |
+
|
| 113 |
+
**Weakness:** High niche saturation from copying rivals. Only 8 unique tags (penalized). Min energy hit 0.10 — nearly burned out.
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
### 5. Burst Poster — Score: 0.6111
|
| 118 |
+
|
| 119 |
+
| Metric | Value |
|
| 120 |
+
|--------|-------|
|
| 121 |
+
| Steps Completed | 168 / 168 |
|
| 122 |
+
| Final Energy | 0.44 |
|
| 123 |
+
| Final Followers | 11,701 (+17.0%) |
|
| 124 |
+
| Total Posts | **57** (highest) |
|
| 125 |
+
| Unique Tags | 13 |
|
| 126 |
+
| Min Energy | 0.25 |
|
| 127 |
+
|
| 128 |
+
**Strategy:** 3 posts in rapid succession, then rests until recovered. Repeat.
|
| 129 |
+
|
| 130 |
+
**Weakness:** Ended with only 0.44 energy. 57 posts caused audience fatigue (posts > 3/day get heavy penalty). Low per-post engagement (0.208) despite high volume.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
### 6. Queue Optimizer — Score: 0.3520
|
| 135 |
+
|
| 136 |
+
| Metric | Value |
|
| 137 |
+
|--------|-------|
|
| 138 |
+
| Steps Completed | 168 / 168 |
|
| 139 |
+
| Final Energy | 1.00 |
|
| 140 |
+
| Final Followers | 11,215 (+12.2%) |
|
| 141 |
+
| Total Posts | 14 |
|
| 142 |
+
| Content Created | 17 |
|
| 143 |
+
| Unique Tags | 12 |
|
| 144 |
+
|
| 145 |
+
**Strategy:** Creates content first (builds queue), then posts from queue at half energy cost.
|
| 146 |
+
|
| 147 |
+
**Weakness:** Spent too long in "prep" phase creating content. Only 14 actual posts despite 17 items queued. Score penalized for under-utilizing the queue.
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
### 7. Weekend Warrior — Score: 0.1257
|
| 152 |
+
|
| 153 |
+
| Metric | Value |
|
| 154 |
+
|--------|-------|
|
| 155 |
+
| Steps Completed | 168 / 168 |
|
| 156 |
+
| Final Followers | 7,659 **(-23.4%)** |
|
| 157 |
+
| Total Posts | 6 |
|
| 158 |
+
| Unique Tags | 6 |
|
| 159 |
+
|
| 160 |
+
**Strategy:** Only posts on Saturday and Sunday. Rests Mon-Fri.
|
| 161 |
+
|
| 162 |
+
**Weakness:** 5 days of inactivity triggered follower decay (-2,341) and algorithm penalty. Only 6 posts total. Weekend posting also gets a 0.7x penalty multiplier.
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
### 8. Night Poster — Score: 0.0937
|
| 167 |
+
|
| 168 |
+
| Metric | Value |
|
| 169 |
+
|--------|-------|
|
| 170 |
+
| Steps Completed | 168 / 168 |
|
| 171 |
+
| Final Followers | 10,237 (+2.4%) |
|
| 172 |
+
| Total Posts | 49 |
|
| 173 |
+
| Unique Tags | 2 |
|
| 174 |
+
| Engagement Rate | 0.036 |
|
| 175 |
+
|
| 176 |
+
**Strategy:** Posts exclusively at night (23:00-06:00) with boring topics.
|
| 177 |
+
|
| 178 |
+
**Weakness:** Night hours get 0.5x multiplier. Only 2 unique tags (#stoic, #minimalism) — severe tag penalty. Despite 49 posts, engagement was near-zero (0.036).
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
### 9. Always Rest — Score: 0.0350
|
| 183 |
+
|
| 184 |
+
| Metric | Value |
|
| 185 |
+
|--------|-------|
|
| 186 |
+
| Steps Completed | 168 / 168 |
|
| 187 |
+
| Final Followers | 5,497 **(-45.0%)** |
|
| 188 |
+
| Total Posts | 0 |
|
| 189 |
+
| Engagement Rate | 0.000 |
|
| 190 |
+
|
| 191 |
+
**Strategy:** Never posts. Rests every step.
|
| 192 |
+
|
| 193 |
+
**Result:** Zero engagement. Lost 4,503 followers (45%) to decay. Algorithm penalty stacked from inactivity. Energy stayed at 1.00 — completely wasted.
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
### 10. Spam Post — Score: 0.0000
|
| 198 |
+
|
| 199 |
+
| Metric | Value |
|
| 200 |
+
|--------|-------|
|
| 201 |
+
| Steps Completed | **4** / 168 |
|
| 202 |
+
| Final Energy | **0.00 (BURNED OUT)** |
|
| 203 |
+
| Final Followers | 10,625 (+6.3%) |
|
| 204 |
+
|
| 205 |
+
**Strategy:** Posts the same reel with "AI tools" topic every step. No rest.
|
| 206 |
+
|
| 207 |
+
**Result:** Burned out at step 4. Each reel costs 0.25 energy. 4 reels = 1.00 energy drained. Episode ended at step 4 with score 0.0000 (burnout = automatic fail on competitive task).
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
### 11. No Rest — Score: 0.0000
|
| 212 |
+
|
| 213 |
+
| Metric | Value |
|
| 214 |
+
|--------|-------|
|
| 215 |
+
| Steps Completed | **8** / 168 |
|
| 216 |
+
| Final Energy | **0.00 (BURNED OUT)** |
|
| 217 |
+
| Final Followers | 10,213 (+2.1%) |
|
| 218 |
+
|
| 219 |
+
**Strategy:** Posts varied content types but never rests.
|
| 220 |
+
|
| 221 |
+
**Result:** Burned out at step 8. Mixed content types (reel, carousel, story, text_post) averaged ~0.125 energy cost. 8 posts without rest = burnout. Score: 0.0000.
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## Key Metrics Comparison
|
| 226 |
+
|
| 227 |
+
### Energy Management
|
| 228 |
+
| Agent | Min Energy | Final Energy | Energy Safety |
|
| 229 |
+
|-------|-----------|--------------|---------------|
|
| 230 |
+
| Always Rest | 1.000 | 1.00 | Wasted |
|
| 231 |
+
| Balanced | 0.795 | 1.00 | Excellent |
|
| 232 |
+
| Tag Explorer | 0.690 | 0.94 | Good |
|
| 233 |
+
| Queue Optimizer | 0.610 | 1.00 | Good |
|
| 234 |
+
| Smart Agent | 0.550 | 1.00 | Good |
|
| 235 |
+
| Burst Poster | 0.250 | 0.44 | Risky |
|
| 236 |
+
| Night Poster | 0.230 | 0.59 | Dangerous |
|
| 237 |
+
| Copycat | 0.100 | 1.00 | Near-fatal dip |
|
| 238 |
+
| Weekend | 0.100 | 1.00 | Near-fatal dip |
|
| 239 |
+
| No Rest | 0.000 | 0.00 | BURNED OUT |
|
| 240 |
+
| Spam Post | 0.000 | 0.00 | BURNED OUT |
|
| 241 |
+
|
| 242 |
+
### Posting Volume vs Quality
|
| 243 |
+
| Agent | Posts | Engagement Rate | Engagement per Post |
|
| 244 |
+
|-------|-------|----------------|---------------------|
|
| 245 |
+
| Burst | 57 | 0.208 | Low (fatigue) |
|
| 246 |
+
| Night Poster | 49 | 0.036 | Very low (timing) |
|
| 247 |
+
| Balanced | 28 | 0.827 | High |
|
| 248 |
+
| Copycat | 21 | 0.497 | Medium |
|
| 249 |
+
| Tag Explorer | 15 | 0.774 | High |
|
| 250 |
+
| Smart Agent | 14 | 1.556 | Very high |
|
| 251 |
+
| Queue Opt | 14 | 0.870 | High |
|
| 252 |
+
| Weekend | 6 | 0.635 | Medium |
|
| 253 |
+
| Spam | 4 | 1.567 | High (but burned out) |
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## Lessons Learned
|
| 258 |
+
|
| 259 |
+
1. **Burnout is fatal** — On the competitive task, burnout = score 0.0000. Energy management is the #1 priority.
|
| 260 |
+
|
| 261 |
+
2. **Quality > Quantity** — Smart Agent posted only 14 times but had the highest engagement rate (1.556). Burst posted 57 times but scored lower.
|
| 262 |
+
|
| 263 |
+
3. **Tag diversity matters** — Tag Explorer's 30 unique tags boosted its score to 0.8323 despite moderate engagement. Night Poster's 2 tags destroyed its score.
|
| 264 |
+
|
| 265 |
+
4. **Content queue is powerful** — Balanced Creator used create_content (56 times) to build a queue, then posted at half energy cost. This enabled 28 posts while maintaining 0.795+ energy.
|
| 266 |
+
|
| 267 |
+
5. **Timing is critical** — Night Poster proved that posting at wrong hours (0.5x multiplier) wastes energy for near-zero engagement.
|
| 268 |
+
|
| 269 |
+
6. **Copying competitors backfires** — Copycat achieved decent followers but niche saturation penalty and low tag diversity (8) capped its score at 0.6136.
|
| 270 |
+
|
| 271 |
+
7. **Consistency beats bursts** — Posting 1-2/day consistently (Balanced, Smart) scored higher than bursting 3+ posts then resting (Burst).
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
*Report generated from Viraltest Creator Intelligence Center*
|
| 276 |
+
*Task: weekly_competitive | 168 hourly steps | 3 competitor profiles*
|
__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Viraltest Environment."""
|
| 8 |
+
|
| 9 |
+
from .client import ViraltestEnv
|
| 10 |
+
from .models import ScheduledAction, ViraltestAction, ViraltestObservation
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"ScheduledAction",
|
| 14 |
+
"ViraltestAction",
|
| 15 |
+
"ViraltestObservation",
|
| 16 |
+
"ViraltestEnv",
|
| 17 |
+
]
|
client.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Viraltest Environment Client."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
from openenv.core import EnvClient
|
| 6 |
+
from openenv.core.client_types import StepResult
|
| 7 |
+
from openenv.core.env_server.types import State
|
| 8 |
+
|
| 9 |
+
from .models import ViraltestAction, ViraltestObservation
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ViraltestEnv(
|
| 13 |
+
EnvClient[ViraltestAction, ViraltestObservation, State]
|
| 14 |
+
):
|
| 15 |
+
"""
|
| 16 |
+
Client for the Viraltest Creator Optimization Environment.
|
| 17 |
+
|
| 18 |
+
Maintains a persistent WebSocket connection to the environment server.
|
| 19 |
+
|
| 20 |
+
Example:
|
| 21 |
+
>>> with ViraltestEnv(base_url="http://localhost:8000") as client:
|
| 22 |
+
... result = client.reset(task="weekly_engage")
|
| 23 |
+
... result = client.step(ViraltestAction(
|
| 24 |
+
... scheduled_actions=[
|
| 25 |
+
... {"hour": 12, "action_type": "post", "content_type": "reel",
|
| 26 |
+
... "topic": "AI trends", "tags": ["ai", "tech"]},
|
| 27 |
+
... ]
|
| 28 |
+
... ))
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def _step_payload(self, action: ViraltestAction) -> Dict[str, Any]:
|
| 32 |
+
actions_list = []
|
| 33 |
+
for sa in action.scheduled_actions:
|
| 34 |
+
item: Dict[str, Any] = {
|
| 35 |
+
"hour": sa.hour,
|
| 36 |
+
"action_type": sa.action_type,
|
| 37 |
+
}
|
| 38 |
+
if sa.content_type is not None:
|
| 39 |
+
item["content_type"] = sa.content_type
|
| 40 |
+
if sa.topic is not None:
|
| 41 |
+
item["topic"] = sa.topic
|
| 42 |
+
if sa.tags is not None:
|
| 43 |
+
item["tags"] = sa.tags
|
| 44 |
+
actions_list.append(item)
|
| 45 |
+
return {"scheduled_actions": actions_list}
|
| 46 |
+
|
| 47 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ViraltestObservation]:
|
| 48 |
+
obs_data = payload.get("observation", {})
|
| 49 |
+
grader_score = obs_data.get("grader_score")
|
| 50 |
+
meta = obs_data.get("metadata", {})
|
| 51 |
+
if grader_score is not None:
|
| 52 |
+
meta["grader_score"] = grader_score
|
| 53 |
+
observation = ViraltestObservation(
|
| 54 |
+
current_hour=obs_data.get("current_hour", 0),
|
| 55 |
+
day_of_week=obs_data.get("day_of_week", 0),
|
| 56 |
+
days_elapsed=obs_data.get("days_elapsed", 0),
|
| 57 |
+
creator_energy=obs_data.get("creator_energy", 1.0),
|
| 58 |
+
follower_count=obs_data.get("follower_count", 0),
|
| 59 |
+
engagement_rate=obs_data.get("engagement_rate", 0.0),
|
| 60 |
+
hours_since_sleep=obs_data.get("hours_since_sleep", 0),
|
| 61 |
+
posts_today=obs_data.get("posts_today", 0),
|
| 62 |
+
sleep_debt=obs_data.get("sleep_debt", 0.0),
|
| 63 |
+
time_since_last_post=obs_data.get("time_since_last_post", 0),
|
| 64 |
+
trending_topics=obs_data.get("trending_topics", []),
|
| 65 |
+
content_queue_size=obs_data.get("content_queue_size", 0),
|
| 66 |
+
last_post_type=obs_data.get("last_post_type", "none"),
|
| 67 |
+
tag_performance=obs_data.get("tag_performance", {}),
|
| 68 |
+
trending_tags=obs_data.get("trending_tags", []),
|
| 69 |
+
competitor_recent_posts=obs_data.get("competitor_recent_posts", []),
|
| 70 |
+
competitor_avg_engagement=obs_data.get("competitor_avg_engagement", 0.0),
|
| 71 |
+
niche_saturation=obs_data.get("niche_saturation", 0.0),
|
| 72 |
+
daily_total_engagement=obs_data.get("daily_total_engagement", 0.0),
|
| 73 |
+
daily_posts_made=obs_data.get("daily_posts_made", 0),
|
| 74 |
+
daily_energy_min=obs_data.get("daily_energy_min", 1.0),
|
| 75 |
+
grader_score=grader_score,
|
| 76 |
+
error=obs_data.get("error"),
|
| 77 |
+
done=payload.get("done", False),
|
| 78 |
+
reward=payload.get("reward"),
|
| 79 |
+
metadata=meta,
|
| 80 |
+
)
|
| 81 |
+
return StepResult(
|
| 82 |
+
observation=observation,
|
| 83 |
+
reward=payload.get("reward"),
|
| 84 |
+
done=payload.get("done", False),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def _parse_state(self, payload: Dict[str, Any]) -> State:
|
| 88 |
+
return State(
|
| 89 |
+
episode_id=payload.get("episode_id"),
|
| 90 |
+
step_count=payload.get("step_count", 0),
|
| 91 |
+
)
|
inference.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viraltest Inference Script — RL-Based Creator Optimization Agent
|
| 3 |
+
===================================
|
| 4 |
+
MANDATORY
|
| 5 |
+
- Before submitting, ensure the following variables are defined in your environment configuration:
|
| 6 |
+
API_BASE_URL The API endpoint for the LLM.
|
| 7 |
+
MODEL_NAME The model identifier to use for inference.
|
| 8 |
+
HF_TOKEN or OPENAI_API_KEY or API_KEY API key for the LLM client.
|
| 9 |
+
IMAGE_NAME or LOCAL_IMAGE_NAME Docker image when using ViraltestEnv.from_docker_image()
|
| 10 |
+
|
| 11 |
+
Optional:
|
| 12 |
+
ALLOW_SHORT_EPISODE=1 Allow MAX_STEPS below 7 (final grader score stays 0 if episode never ends).
|
| 13 |
+
MAX_STEPS Step cap (default 7). Without ALLOW_SHORT_EPISODE, cap is at least 7 so graders run.
|
| 14 |
+
|
| 15 |
+
Each step = one full day. The agent submits a sparse daily plan (only posts and create_content
|
| 16 |
+
actions at specific hours). Unlisted hours automatically become rest.
|
| 17 |
+
|
| 18 |
+
STDOUT FORMAT (single space after tag; score two decimals) — match hackathon sample exactly.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import textwrap
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
from openai import OpenAI
|
| 28 |
+
|
| 29 |
+
from viraltest import ViraltestAction, ViraltestEnv
|
| 30 |
+
from viraltest.server.viraltest_environment import TAG_POOL, TASK_HORIZON
|
| 31 |
+
|
| 32 |
+
DOCKER_IMAGE = os.getenv("IMAGE_NAME") or os.getenv("LOCAL_IMAGE_NAME")
|
| 33 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
|
| 34 |
+
API_BASE_URL = os.getenv("API_BASE_URL") or "http://127.0.0.1:1337/v1"
|
| 35 |
+
MODEL_NAME = os.getenv("MODEL_NAME") or "gemma-4-E4B-it-IQ4_XS"
|
| 36 |
+
BENCHMARK = os.getenv("VIRALTEST_BENCHMARK", "viraltest")
|
| 37 |
+
|
| 38 |
+
TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
|
| 39 |
+
_ALLOW_SHORT = os.getenv("ALLOW_SHORT_EPISODE", "").lower() in ("1", "true", "yes")
|
| 40 |
+
_REQUESTED_MAX = int(os.getenv("MAX_STEPS", str(TASK_HORIZON)))
|
| 41 |
+
MAX_STEPS = _REQUESTED_MAX if _ALLOW_SHORT else max(_REQUESTED_MAX, TASK_HORIZON)
|
| 42 |
+
TEMPERATURE = 0.7
|
| 43 |
+
MAX_TOKENS = 512
|
| 44 |
+
SUCCESS_SCORE_THRESHOLD = 0.1
|
| 45 |
+
|
| 46 |
+
VALID_TAGS_TEXT = ", ".join(TAG_POOL)
|
| 47 |
+
|
| 48 |
+
SYSTEM_PROMPT = textwrap.dedent(f"""\
|
| 49 |
+
You are a social media content strategy agent. Each step is one full day (24 hours).
|
| 50 |
+
You receive the current day's state and must plan your actions for the entire day.
|
| 51 |
+
|
| 52 |
+
Reply with a JSON object containing "scheduled_actions" — a list of actions at specific hours.
|
| 53 |
+
Hours you don't list will automatically be rest. Only include posts and create_content actions.
|
| 54 |
+
|
| 55 |
+
FORMAT (JSON only, no markdown, no prose):
|
| 56 |
+
{{
|
| 57 |
+
"scheduled_actions": [
|
| 58 |
+
{{"hour": 10, "action_type": "create_content"}},
|
| 59 |
+
{{"hour": 12, "action_type": "post", "content_type": "reel", "topic": "AI trends", "tags": ["ai", "coding"]}},
|
| 60 |
+
{{"hour": 18, "action_type": "post", "content_type": "carousel", "topic": "startup tips", "tags": ["startup", "growth"]}}
|
| 61 |
+
]
|
| 62 |
+
}}
|
| 63 |
+
|
| 64 |
+
RULES:
|
| 65 |
+
- hour: 0-23 (which hour of the day to perform the action)
|
| 66 |
+
- action_type: "post" or "create_content" (rest is automatic for unlisted hours)
|
| 67 |
+
- For posts: content_type (reel|story|carousel|text_post), topic, and tags are required
|
| 68 |
+
- Tags must be from this pool: {VALID_TAGS_TEXT}
|
| 69 |
+
- Max 5 tags per post
|
| 70 |
+
- Empty scheduled_actions means rest all day
|
| 71 |
+
- Peak posting hours: 9-12 (1.3x), 12-15 Tue-Thu (1.4x), 18-20 (1.25x)
|
| 72 |
+
- Posting 3+ times/day causes audience fatigue; 1-2 posts/day is optimal
|
| 73 |
+
- If energy hits 0, episode ends (burnout = game over)
|
| 74 |
+
|
| 75 |
+
Plan strategically: schedule posts at peak hours, rest during off-hours to recover energy,
|
| 76 |
+
and use create_content to build a content queue for cheaper posts later.""")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def should_force_rest_day(obs: Any) -> bool:
|
| 80 |
+
"""If energy is critically low, submit an empty schedule (all rest)."""
|
| 81 |
+
energy = float(getattr(obs, "creator_energy", 1.0))
|
| 82 |
+
return energy <= 0.15
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 86 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 90 |
+
error_val = error.replace(" ", "_") if error else "null"
|
| 91 |
+
done_val = str(done).lower()
|
| 92 |
+
print(
|
| 93 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 94 |
+
f"done={done_val} error={error_val}",
|
| 95 |
+
flush=True,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 100 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 101 |
+
print(
|
| 102 |
+
f"[END] success={str(success).lower()} steps={steps} "
|
| 103 |
+
f"score={score:.2f} rewards={rewards_str}",
|
| 104 |
+
flush=True,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def format_observation(obs: Any) -> str:
|
| 109 |
+
"""Serialize observation into a readable prompt for the LLM."""
|
| 110 |
+
tag_perf = obs.tag_performance or {}
|
| 111 |
+
top_tags = sorted(tag_perf.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 112 |
+
top_tags_str = ", ".join(f"{t}={v:.2f}" for t, v in top_tags) if top_tags else "none yet"
|
| 113 |
+
|
| 114 |
+
comp_posts = obs.competitor_recent_posts or []
|
| 115 |
+
comp_str = ""
|
| 116 |
+
for p in comp_posts[:3]:
|
| 117 |
+
comp_str += (
|
| 118 |
+
f" - {p.get('content_type','?')} on '{p.get('topic','?')}' "
|
| 119 |
+
f"tags={p.get('tags',[])} eng={p.get('engagement',0):.2f} "
|
| 120 |
+
f"({p.get('hours_ago',0)}h ago)\n"
|
| 121 |
+
)
|
| 122 |
+
if not comp_str:
|
| 123 |
+
comp_str = " none\n"
|
| 124 |
+
|
| 125 |
+
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
| 126 |
+
day_name = days[obs.day_of_week] if 0 <= obs.day_of_week < 7 else "?"
|
| 127 |
+
|
| 128 |
+
daily_eng = getattr(obs, "daily_total_engagement", 0.0)
|
| 129 |
+
daily_posts = getattr(obs, "daily_posts_made", 0)
|
| 130 |
+
daily_emin = getattr(obs, "daily_energy_min", 1.0)
|
| 131 |
+
|
| 132 |
+
return textwrap.dedent(f"""\
|
| 133 |
+
Day: {day_name} (day_of_week={obs.day_of_week}, 0=Mon) | days_elapsed={obs.days_elapsed}
|
| 134 |
+
Hours since sleep: {obs.hours_since_sleep} | Sleep debt: {obs.sleep_debt:.3f}
|
| 135 |
+
Energy: {obs.creator_energy:.2f} | Followers: {obs.follower_count} | Engagement rate: {obs.engagement_rate:.3f}
|
| 136 |
+
Hours since last post: {obs.time_since_last_post}
|
| 137 |
+
Content queue: {obs.content_queue_size} | Last post type: {obs.last_post_type}
|
| 138 |
+
Yesterday's engagement: {daily_eng:.3f} | Yesterday's posts: {daily_posts} | Yesterday's min energy: {daily_emin:.2f}
|
| 139 |
+
Trending topics: {', '.join(obs.trending_topics)}
|
| 140 |
+
Trending tags: {', '.join(obs.trending_tags)}
|
| 141 |
+
Your top tags: {top_tags_str}
|
| 142 |
+
Niche saturation: {obs.niche_saturation:.2f} | Competitor avg engagement: {obs.competitor_avg_engagement:.3f}
|
| 143 |
+
Competitor recent posts:
|
| 144 |
+
{comp_str}Plan your actions for today (list only posts and create_content at specific hours):""")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def parse_daily_plan(response_text: str) -> ViraltestAction:
|
| 148 |
+
"""Parse LLM JSON into ViraltestAction with scheduled_actions; fallback to empty (all rest)."""
|
| 149 |
+
text = response_text.strip()
|
| 150 |
+
if text.startswith("```"):
|
| 151 |
+
lines = text.split("\n")
|
| 152 |
+
lines = [l for l in lines if not l.strip().startswith("```")]
|
| 153 |
+
text = "\n".join(lines).strip()
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
data: Dict[str, Any] = json.loads(text)
|
| 157 |
+
actions_raw = data.get("scheduled_actions", [])
|
| 158 |
+
if not isinstance(actions_raw, list):
|
| 159 |
+
return ViraltestAction(scheduled_actions=[])
|
| 160 |
+
return ViraltestAction(scheduled_actions=actions_raw)
|
| 161 |
+
except (json.JSONDecodeError, Exception):
|
| 162 |
+
return ViraltestAction(scheduled_actions=[])
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def format_action_str(action: ViraltestAction) -> str:
|
| 166 |
+
"""Format daily plan for [STEP] log line."""
|
| 167 |
+
if not action.scheduled_actions:
|
| 168 |
+
return "daily_plan(rest_all)"
|
| 169 |
+
parts = []
|
| 170 |
+
for sa in action.scheduled_actions:
|
| 171 |
+
if sa.action_type == "post":
|
| 172 |
+
tags_str = ",".join(sa.tags) if sa.tags else ""
|
| 173 |
+
parts.append(f"h{sa.hour}:post({sa.content_type},\"{sa.topic}\",[{tags_str}])")
|
| 174 |
+
else:
|
| 175 |
+
parts.append(f"h{sa.hour}:{sa.action_type}()")
|
| 176 |
+
return "daily_plan(" + ";".join(parts) + ")"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
_model_exhausted = False
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def get_model_daily_plan(
|
| 183 |
+
client: OpenAI, obs: Any, history: List[Dict[str, str]]
|
| 184 |
+
) -> ViraltestAction:
|
| 185 |
+
"""Call the LLM to get a daily plan. Falls back to rest permanently after an unrecoverable error."""
|
| 186 |
+
global _model_exhausted
|
| 187 |
+
if _model_exhausted:
|
| 188 |
+
return ViraltestAction(scheduled_actions=[])
|
| 189 |
+
|
| 190 |
+
user_prompt = format_observation(obs)
|
| 191 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 192 |
+
messages.extend(history[-12:])
|
| 193 |
+
messages.append({"role": "user", "content": user_prompt})
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
completion = client.chat.completions.create(
|
| 197 |
+
model=MODEL_NAME,
|
| 198 |
+
messages=messages,
|
| 199 |
+
temperature=TEMPERATURE,
|
| 200 |
+
max_tokens=MAX_TOKENS,
|
| 201 |
+
stream=False,
|
| 202 |
+
)
|
| 203 |
+
text = (completion.choices[0].message.content or "").strip()
|
| 204 |
+
return parse_daily_plan(text) if text else ViraltestAction(scheduled_actions=[])
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
err_str = str(exc)
|
| 207 |
+
print(f"[DEBUG] Model request failed: {exc}", flush=True)
|
| 208 |
+
if "402" in err_str or "429" in err_str or "credit" in err_str.lower() or "quota" in err_str.lower():
|
| 209 |
+
_model_exhausted = True
|
| 210 |
+
print("[DEBUG] Token/credit limit reached — falling back to rest for remaining steps", flush=True)
|
| 211 |
+
return ViraltestAction(scheduled_actions=[])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def run_task(client: OpenAI, task: str) -> None:
|
| 215 |
+
"""Run a single task episode (7 daily steps)."""
|
| 216 |
+
global _model_exhausted
|
| 217 |
+
_model_exhausted = False
|
| 218 |
+
|
| 219 |
+
rewards: List[float] = []
|
| 220 |
+
steps_taken = 0
|
| 221 |
+
score = 0.0
|
| 222 |
+
success = False
|
| 223 |
+
env: Optional[ViraltestEnv] = None
|
| 224 |
+
|
| 225 |
+
log_start(task=task, env=BENCHMARK, model=MODEL_NAME)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
if DOCKER_IMAGE:
|
| 229 |
+
env = await ViraltestEnv.from_docker_image(DOCKER_IMAGE)
|
| 230 |
+
else:
|
| 231 |
+
env = ViraltestEnv(base_url=os.getenv("ENV_BASE_URL", "http://localhost:8000"))
|
| 232 |
+
|
| 233 |
+
result = await env.reset(task=task)
|
| 234 |
+
history: List[Dict[str, str]] = []
|
| 235 |
+
|
| 236 |
+
for step in range(1, MAX_STEPS + 1):
|
| 237 |
+
if result.done:
|
| 238 |
+
break
|
| 239 |
+
|
| 240 |
+
obs = result.observation
|
| 241 |
+
if should_force_rest_day(obs):
|
| 242 |
+
action = ViraltestAction(scheduled_actions=[])
|
| 243 |
+
else:
|
| 244 |
+
action = get_model_daily_plan(client, obs, history)
|
| 245 |
+
|
| 246 |
+
result = await env.step(action)
|
| 247 |
+
|
| 248 |
+
reward = result.reward or 0.0
|
| 249 |
+
done = result.done
|
| 250 |
+
error = getattr(result.observation, "error", None)
|
| 251 |
+
|
| 252 |
+
rewards.append(reward)
|
| 253 |
+
steps_taken = step
|
| 254 |
+
|
| 255 |
+
log_step(
|
| 256 |
+
step=step,
|
| 257 |
+
action=format_action_str(action),
|
| 258 |
+
reward=reward,
|
| 259 |
+
done=done,
|
| 260 |
+
error=error,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
history.append({
|
| 264 |
+
"role": "assistant",
|
| 265 |
+
"content": json.dumps({
|
| 266 |
+
"scheduled_actions": [
|
| 267 |
+
{
|
| 268 |
+
"hour": sa.hour,
|
| 269 |
+
"action_type": sa.action_type,
|
| 270 |
+
"content_type": sa.content_type,
|
| 271 |
+
"topic": sa.topic,
|
| 272 |
+
"tags": sa.tags,
|
| 273 |
+
}
|
| 274 |
+
for sa in action.scheduled_actions
|
| 275 |
+
]
|
| 276 |
+
}),
|
| 277 |
+
})
|
| 278 |
+
|
| 279 |
+
if done:
|
| 280 |
+
score = float(getattr(result.observation, "grader_score", 0) or 0)
|
| 281 |
+
if score == 0:
|
| 282 |
+
meta = getattr(result.observation, "metadata", {}) or {}
|
| 283 |
+
score = float(meta.get("grader_score", 0.0))
|
| 284 |
+
break
|
| 285 |
+
|
| 286 |
+
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 287 |
+
|
| 288 |
+
finally:
|
| 289 |
+
if env is not None:
|
| 290 |
+
try:
|
| 291 |
+
await env.close()
|
| 292 |
+
except Exception as e:
|
| 293 |
+
print(f"[DEBUG] env.close() error: {e}", flush=True)
|
| 294 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
async def main() -> None:
|
| 298 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "not-needed")
|
| 299 |
+
for task in TASKS:
|
| 300 |
+
await run_task(client, task)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
if __name__ == "__main__":
|
| 304 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for the Viraltest Creator Optimization Environment."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 4 |
+
|
| 5 |
+
from openenv.core.env_server.types import Action, Observation
|
| 6 |
+
from pydantic import BaseModel, Field, field_validator
|
| 7 |
+
|
| 8 |
+
VALID_CONTENT_TYPES = ("reel", "story", "carousel", "text_post")
|
| 9 |
+
VALID_ACTION_TYPES = ("post", "create_content")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ScheduledAction(BaseModel):
|
| 13 |
+
"""A single non-rest action scheduled at a specific hour of the day."""
|
| 14 |
+
|
| 15 |
+
hour: int = Field(..., ge=0, le=23, description="Hour of the day (0-23)")
|
| 16 |
+
action_type: Literal["post", "create_content"] = Field(
|
| 17 |
+
..., description="What to do at this hour (unlisted hours default to rest)"
|
| 18 |
+
)
|
| 19 |
+
content_type: Optional[Literal["reel", "story", "carousel", "text_post"]] = Field(
|
| 20 |
+
default=None, description="Format of the post (required if posting)"
|
| 21 |
+
)
|
| 22 |
+
topic: Optional[str] = Field(
|
| 23 |
+
default=None, max_length=200, description="Topic of the post"
|
| 24 |
+
)
|
| 25 |
+
tags: Optional[List[str]] = Field(
|
| 26 |
+
default=None, description="Hashtags for the post (max 5)"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
@field_validator("tags")
|
| 30 |
+
@classmethod
|
| 31 |
+
def validate_tags(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
| 32 |
+
if v is not None and len(v) > 5:
|
| 33 |
+
return v[:5]
|
| 34 |
+
return v
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ViraltestAction(Action):
|
| 38 |
+
"""Sparse daily plan: only non-rest actions. Unlisted hours default to rest."""
|
| 39 |
+
|
| 40 |
+
scheduled_actions: List[ScheduledAction] = Field(
|
| 41 |
+
default_factory=list,
|
| 42 |
+
description="Actions scheduled at specific hours; unlisted hours are rest",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
@field_validator("scheduled_actions")
|
| 46 |
+
@classmethod
|
| 47 |
+
def validate_no_duplicate_hours(cls, v: List[ScheduledAction]) -> List[ScheduledAction]:
|
| 48 |
+
seen: set = set()
|
| 49 |
+
deduped: List[ScheduledAction] = []
|
| 50 |
+
for a in v:
|
| 51 |
+
if a.hour not in seen:
|
| 52 |
+
seen.add(a.hour)
|
| 53 |
+
deduped.append(a)
|
| 54 |
+
return deduped
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ViraltestObservation(Observation):
|
| 58 |
+
"""Observation the agent receives after each daily step."""
|
| 59 |
+
|
| 60 |
+
current_hour: int = Field(default=0, ge=0, le=23)
|
| 61 |
+
day_of_week: int = Field(default=0, ge=0, le=6)
|
| 62 |
+
days_elapsed: int = Field(default=0, ge=0)
|
| 63 |
+
creator_energy: float = Field(default=1.0, ge=0.0, le=1.0)
|
| 64 |
+
hours_since_sleep: int = Field(default=0, ge=0, description="Hours since last sleep period")
|
| 65 |
+
sleep_debt: float = Field(default=0.0, ge=0.0, le=1.0, description="Accumulated sleep debt (0=rested, 1=severe)")
|
| 66 |
+
follower_count: int = Field(default=0, ge=0)
|
| 67 |
+
engagement_rate: float = Field(default=0.0, ge=0.0)
|
| 68 |
+
posts_today: int = Field(default=0, ge=0)
|
| 69 |
+
time_since_last_post: int = Field(default=0, ge=0)
|
| 70 |
+
trending_topics: List[str] = Field(default_factory=list)
|
| 71 |
+
content_queue_size: int = Field(default=0, ge=0)
|
| 72 |
+
last_post_type: str = Field(default="none")
|
| 73 |
+
|
| 74 |
+
tag_performance: Dict[str, float] = Field(default_factory=dict)
|
| 75 |
+
trending_tags: List[str] = Field(default_factory=list)
|
| 76 |
+
|
| 77 |
+
competitor_recent_posts: List[Dict[str, Any]] = Field(default_factory=list)
|
| 78 |
+
competitor_avg_engagement: float = Field(default=0.0, ge=0.0)
|
| 79 |
+
niche_saturation: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 80 |
+
|
| 81 |
+
daily_total_engagement: float = Field(default=0.0, ge=0.0, description="Total engagement earned this day")
|
| 82 |
+
daily_posts_made: int = Field(default=0, ge=0, description="Number of posts made this day")
|
| 83 |
+
daily_energy_min: float = Field(default=1.0, ge=0.0, le=1.0, description="Lowest energy during this day")
|
| 84 |
+
|
| 85 |
+
grader_score: Optional[float] = Field(default=None, description="Final grader score (set on last step when done=True)")
|
| 86 |
+
|
| 87 |
+
error: Optional[str] = Field(default=None)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: viraltest
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
| 7 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
[build-system]
|
| 8 |
+
requires = ["setuptools>=45", "wheel"]
|
| 9 |
+
build-backend = "setuptools.build_meta"
|
| 10 |
+
|
| 11 |
+
[project]
|
| 12 |
+
name = "openenv-viraltest"
|
| 13 |
+
version = "0.1.0"
|
| 14 |
+
description = "Viraltest environment for OpenEnv"
|
| 15 |
+
requires-python = ">=3.10"
|
| 16 |
+
dependencies = [
|
| 17 |
+
# Core OpenEnv runtime (provides FastAPI server + HTTP client types)
|
| 18 |
+
# install from github
|
| 19 |
+
# "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
|
| 20 |
+
"openenv-core[core]>=0.2.2",
|
| 21 |
+
# Environment-specific dependencies
|
| 22 |
+
# Add all dependencies needed for your environment here
|
| 23 |
+
# Examples:
|
| 24 |
+
# "numpy>=1.19.0",
|
| 25 |
+
# "torch>=2.0.0",
|
| 26 |
+
# "gymnasium>=0.29.0",
|
| 27 |
+
# "openspiel>=1.0.0",
|
| 28 |
+
# "smolagents>=1.22.0,<2",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
[project.optional-dependencies]
|
| 32 |
+
dev = [
|
| 33 |
+
"pytest>=8.0.0",
|
| 34 |
+
"pytest-cov>=4.0.0",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
[project.scripts]
|
| 38 |
+
# Server entry point - enables running via: uv run --project . server
|
| 39 |
+
# or: python -m viraltest.server.app
|
| 40 |
+
server = "viraltest.server.app:main"
|
| 41 |
+
|
| 42 |
+
[tool.setuptools]
|
| 43 |
+
include-package-data = true
|
| 44 |
+
packages = ["viraltest", "viraltest.server"]
|
| 45 |
+
package-dir = { "viraltest" = ".", "viraltest.server" = "server" }
|
| 46 |
+
|
| 47 |
+
[tool.setuptools.package-data]
|
| 48 |
+
"viraltest.server" = ["*.html"]
|
server/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Viraltest environment server components."""
|
| 8 |
+
|
| 9 |
+
from .viraltest_environment import ViraltestEnvironment
|
| 10 |
+
|
| 11 |
+
__all__ = ["ViraltestEnvironment"]
|
server/app.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application for the Viraltest Environment.
|
| 9 |
+
|
| 10 |
+
This module creates an HTTP server that exposes the ViraltestEnvironment
|
| 11 |
+
over HTTP and WebSocket endpoints, compatible with EnvClient.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
- POST /reset: Reset the environment
|
| 15 |
+
- POST /step: Execute an action
|
| 16 |
+
- GET /state: Get current environment state
|
| 17 |
+
- GET /schema: Get action/observation schemas
|
| 18 |
+
- WS /ws: WebSocket endpoint for persistent sessions
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
# Development (with auto-reload):
|
| 22 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 23 |
+
|
| 24 |
+
# Production:
|
| 25 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
|
| 26 |
+
|
| 27 |
+
# Or run directly:
|
| 28 |
+
python -m server.app
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import random as stdlib_random
|
| 34 |
+
from datetime import datetime, timezone
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
from typing import Any, Dict, List, Optional
|
| 37 |
+
|
| 38 |
+
from fastapi import Body
|
| 39 |
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
from openenv.core.env_server.http_server import create_app
|
| 43 |
+
except Exception as e: # pragma: no cover
|
| 44 |
+
raise ImportError(
|
| 45 |
+
"openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
|
| 46 |
+
) from e
|
| 47 |
+
|
| 48 |
+
# OpenEnv Gradio UI lives at /web; Dockerfile sets this — default on for local parity with HF Spaces.
|
| 49 |
+
if "ENABLE_WEB_INTERFACE" not in os.environ:
|
| 50 |
+
os.environ["ENABLE_WEB_INTERFACE"] = "true"
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
from ..models import ScheduledAction, ViraltestAction, ViraltestObservation
|
| 54 |
+
from .viraltest_environment import ViraltestEnvironment
|
| 55 |
+
except ImportError:
|
| 56 |
+
from models import ScheduledAction, ViraltestAction, ViraltestObservation
|
| 57 |
+
from server.viraltest_environment import ViraltestEnvironment
|
| 58 |
+
|
| 59 |
+
_DASHBOARD_HTML = (Path(__file__).parent / "dashboard.html").read_text()
|
| 60 |
+
|
| 61 |
+
app = create_app(
|
| 62 |
+
ViraltestEnvironment,
|
| 63 |
+
ViraltestAction,
|
| 64 |
+
ViraltestObservation,
|
| 65 |
+
env_name="viraltest",
|
| 66 |
+
max_concurrent_envs=1,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
_gradio_web = os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
|
| 70 |
+
if not _gradio_web:
|
| 71 |
+
|
| 72 |
+
@app.get("/", include_in_schema=False)
|
| 73 |
+
async def _root_redirect():
|
| 74 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 75 |
+
|
| 76 |
+
@app.get("/web", include_in_schema=False)
|
| 77 |
+
@app.get("/web/", include_in_schema=False)
|
| 78 |
+
async def _web_disabled_redirect():
|
| 79 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 80 |
+
|
| 81 |
+
_dash_env: Optional[ViraltestEnvironment] = None
|
| 82 |
+
_HISTORY_FILE = Path(__file__).parent / "simulation_history.json"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _obs_to_dict(obs: ViraltestObservation) -> Dict[str, Any]:
|
| 86 |
+
return {
|
| 87 |
+
"observation": obs.model_dump(),
|
| 88 |
+
"reward": obs.reward,
|
| 89 |
+
"done": obs.done,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _load_history() -> List[Dict[str, Any]]:
|
| 94 |
+
if _HISTORY_FILE.exists():
|
| 95 |
+
try:
|
| 96 |
+
return json.loads(_HISTORY_FILE.read_text())
|
| 97 |
+
except (json.JSONDecodeError, OSError):
|
| 98 |
+
return []
|
| 99 |
+
return []
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _save_history_entry(entry: Dict[str, Any]) -> None:
|
| 103 |
+
history = _load_history()
|
| 104 |
+
history.append(entry)
|
| 105 |
+
if len(history) > 100:
|
| 106 |
+
history = history[-100:]
|
| 107 |
+
_HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/dashboard", response_class=HTMLResponse)
|
| 111 |
+
async def dashboard():
|
| 112 |
+
return _DASHBOARD_HTML
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.get("/dashboard/history")
|
| 116 |
+
async def dashboard_history():
|
| 117 |
+
history = _load_history()
|
| 118 |
+
out: List[Dict[str, Any]] = []
|
| 119 |
+
for row in history:
|
| 120 |
+
entry = dict(row)
|
| 121 |
+
if not entry.get("description"):
|
| 122 |
+
sid = entry.get("scenario_id")
|
| 123 |
+
if sid and sid in SCENARIOS:
|
| 124 |
+
entry["description"] = SCENARIOS[sid][1]
|
| 125 |
+
out.append(entry)
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@app.delete("/dashboard/history")
|
| 130 |
+
async def dashboard_history_clear():
|
| 131 |
+
if _HISTORY_FILE.exists():
|
| 132 |
+
_HISTORY_FILE.unlink()
|
| 133 |
+
return {"status": "cleared"}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@app.post("/dashboard/reset")
|
| 137 |
+
async def dashboard_reset(body: Dict[str, Any] = Body(default={})):
|
| 138 |
+
global _dash_env
|
| 139 |
+
_dash_env = ViraltestEnvironment()
|
| 140 |
+
task = body.get("task", "weekly_engage")
|
| 141 |
+
obs = _dash_env.reset(task=task)
|
| 142 |
+
return _obs_to_dict(obs)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.post("/dashboard/step")
|
| 146 |
+
async def dashboard_step(body: Dict[str, Any] = Body(...)):
|
| 147 |
+
global _dash_env
|
| 148 |
+
if _dash_env is None:
|
| 149 |
+
_dash_env = ViraltestEnvironment()
|
| 150 |
+
_dash_env.reset()
|
| 151 |
+
action_data = body.get("action", body)
|
| 152 |
+
action = ViraltestAction(**action_data)
|
| 153 |
+
obs = _dash_env.step(action)
|
| 154 |
+
return _obs_to_dict(obs)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
from .viraltest_environment import TAG_POOL
|
| 159 |
+
except ImportError:
|
| 160 |
+
from server.viraltest_environment import TAG_POOL
|
| 161 |
+
|
| 162 |
+
_SIM_RNG = stdlib_random.Random(99)
|
| 163 |
+
_CONTENT_TYPES = ["reel", "carousel", "story", "text_post"]
|
| 164 |
+
_TOPICS = ["AI tools", "fitness routine", "growth hacks", "travel guide", "food recipe", "wellness tips"]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _make_daily_plan(actions: list) -> ViraltestAction:
|
| 168 |
+
"""Helper: build a ViraltestAction from a list of ScheduledAction-like dicts."""
|
| 169 |
+
return ViraltestAction(scheduled_actions=[ScheduledAction(**a) for a in actions])
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _plan_always_rest(obs: dict, day: int) -> ViraltestAction:
|
| 173 |
+
return _make_daily_plan([])
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _plan_spam(obs: dict, day: int) -> ViraltestAction:
|
| 177 |
+
actions = [{"hour": h, "action_type": "post", "content_type": "reel",
|
| 178 |
+
"topic": "AI tools", "tags": ["ai"]} for h in range(24)]
|
| 179 |
+
return _make_daily_plan(actions)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _plan_smart(obs: dict, day: int) -> ViraltestAction:
|
| 183 |
+
trending = (obs.get("trending_topics") or ["AI tools"])[0]
|
| 184 |
+
t_tags = list((obs.get("trending_tags") or [])[:2])
|
| 185 |
+
pool_tag = TAG_POOL[(day * 2) % len(TAG_POOL)]
|
| 186 |
+
pool_tag2 = TAG_POOL[(day * 2 + 1) % len(TAG_POOL)]
|
| 187 |
+
ct1 = _CONTENT_TYPES[(day * 2) % 4]
|
| 188 |
+
ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
|
| 189 |
+
actions = [
|
| 190 |
+
{"hour": 8, "action_type": "create_content"},
|
| 191 |
+
{"hour": 12, "action_type": "post", "content_type": ct1, "topic": trending, "tags": t_tags + [pool_tag]},
|
| 192 |
+
{"hour": 19, "action_type": "post", "content_type": ct2, "topic": trending, "tags": t_tags + [pool_tag2]},
|
| 193 |
+
]
|
| 194 |
+
return _make_daily_plan(actions)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _plan_no_rest(obs: dict, day: int) -> ViraltestAction:
|
| 198 |
+
actions = []
|
| 199 |
+
for h in range(24):
|
| 200 |
+
ct = _CONTENT_TYPES[h % 4]
|
| 201 |
+
topic = _SIM_RNG.choice(_TOPICS)
|
| 202 |
+
tags = _SIM_RNG.sample(TAG_POOL, 3)
|
| 203 |
+
actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
|
| 204 |
+
return _make_daily_plan(actions)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _plan_minimal(obs: dict, day: int) -> ViraltestAction:
|
| 208 |
+
trending = (obs.get("trending_topics") or ["minimalism"])[0]
|
| 209 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 210 |
+
return _make_daily_plan([
|
| 211 |
+
{"hour": 12, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
|
| 212 |
+
])
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _plan_reel_max(obs: dict, day: int) -> ViraltestAction:
|
| 216 |
+
trending = (obs.get("trending_topics") or ["viral content"])[0]
|
| 217 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 218 |
+
return _make_daily_plan([
|
| 219 |
+
{"hour": 12, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 220 |
+
{"hour": 14, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 221 |
+
])
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _plan_split_schedule(obs: dict, day: int) -> ViraltestAction:
|
| 225 |
+
trending = (obs.get("trending_topics") or ["daily content"])[0]
|
| 226 |
+
tags = list((obs.get("trending_tags") or [])[:2]) + ["tips"]
|
| 227 |
+
return _make_daily_plan([
|
| 228 |
+
{"hour": 9, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
|
| 229 |
+
{"hour": 19, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 230 |
+
])
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _plan_double_peak(obs: dict, day: int) -> ViraltestAction:
|
| 234 |
+
trending = (obs.get("trending_topics") or ["peak time content"])[0]
|
| 235 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 236 |
+
return _make_daily_plan([
|
| 237 |
+
{"hour": 9, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 238 |
+
{"hour": 15, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
|
| 239 |
+
])
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _plan_tag_explorer(obs: dict, day: int) -> ViraltestAction:
|
| 243 |
+
trending = (obs.get("trending_topics") or ["devtools"])[0]
|
| 244 |
+
start = (day * 6) % len(TAG_POOL)
|
| 245 |
+
tags1 = [TAG_POOL[(start + i) % len(TAG_POOL)] for i in range(3)]
|
| 246 |
+
tags2 = [TAG_POOL[(start + 3 + i) % len(TAG_POOL)] for i in range(3)]
|
| 247 |
+
ct1 = _CONTENT_TYPES[(day * 2) % 4]
|
| 248 |
+
ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
|
| 249 |
+
return _make_daily_plan([
|
| 250 |
+
{"hour": 10, "action_type": "post", "content_type": ct1, "topic": trending, "tags": tags1},
|
| 251 |
+
{"hour": 18, "action_type": "post", "content_type": ct2, "topic": trending, "tags": tags2},
|
| 252 |
+
])
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _plan_queue_optimizer(obs: dict, day: int) -> ViraltestAction:
|
| 256 |
+
trending = (obs.get("trending_topics") or ["productivity"])[0]
|
| 257 |
+
tags = list((obs.get("trending_tags") or [])[:2]) + ["growth"]
|
| 258 |
+
queue = obs.get("content_queue_size", 0)
|
| 259 |
+
if day < 2 or queue < 2:
|
| 260 |
+
return _make_daily_plan([
|
| 261 |
+
{"hour": 8, "action_type": "create_content"},
|
| 262 |
+
{"hour": 10, "action_type": "create_content"},
|
| 263 |
+
{"hour": 14, "action_type": "create_content"},
|
| 264 |
+
])
|
| 265 |
+
ct = _CONTENT_TYPES[day % 4]
|
| 266 |
+
return _make_daily_plan([
|
| 267 |
+
{"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
|
| 268 |
+
{"hour": 19, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": trending, "tags": tags},
|
| 269 |
+
])
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _plan_weekend(obs: dict, day: int) -> ViraltestAction:
|
| 273 |
+
dow = obs.get("day_of_week", 0)
|
| 274 |
+
if dow not in (5, 6):
|
| 275 |
+
return _make_daily_plan([])
|
| 276 |
+
trending = (obs.get("trending_topics") or ["travel"])[0]
|
| 277 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 278 |
+
return _make_daily_plan([
|
| 279 |
+
{"hour": 11, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 280 |
+
{"hour": 17, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 281 |
+
])
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _plan_weekday_only(obs: dict, day: int) -> ViraltestAction:
|
| 285 |
+
dow = obs.get("day_of_week", 0)
|
| 286 |
+
if dow >= 5:
|
| 287 |
+
return _make_daily_plan([])
|
| 288 |
+
trending = (obs.get("trending_topics") or ["weekday content"])[0]
|
| 289 |
+
tags = list((obs.get("trending_tags") or [])[:2]) + ["productivity"]
|
| 290 |
+
ct = _CONTENT_TYPES[day % 4]
|
| 291 |
+
return _make_daily_plan([
|
| 292 |
+
{"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
|
| 293 |
+
])
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _plan_random(obs: dict, day: int) -> ViraltestAction:
|
| 297 |
+
actions = []
|
| 298 |
+
for h in range(24):
|
| 299 |
+
r = _SIM_RNG.random()
|
| 300 |
+
if r < 0.1:
|
| 301 |
+
ct = _SIM_RNG.choice(_CONTENT_TYPES)
|
| 302 |
+
topic = _SIM_RNG.choice(["random topic", "AI tools", "fitness", "travel"])
|
| 303 |
+
tags = _SIM_RNG.sample(TAG_POOL, 2)
|
| 304 |
+
actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
|
| 305 |
+
elif r < 0.15:
|
| 306 |
+
actions.append({"hour": h, "action_type": "create_content"})
|
| 307 |
+
return _make_daily_plan(actions)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _plan_sleep_conscious(obs: dict, day: int) -> ViraltestAction:
|
| 311 |
+
trending = (obs.get("trending_topics") or ["wellness"])[0]
|
| 312 |
+
tags = list((obs.get("trending_tags") or [])[:2]) + ["productivity"]
|
| 313 |
+
ct = _CONTENT_TYPES[day % 4]
|
| 314 |
+
return _make_daily_plan([
|
| 315 |
+
{"hour": 10, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
|
| 316 |
+
{"hour": 16, "action_type": "create_content"},
|
| 317 |
+
])
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _plan_sleep_deprived(obs: dict, day: int) -> ViraltestAction:
|
| 321 |
+
trending = (obs.get("trending_topics") or ["coding"])[0]
|
| 322 |
+
tags = list((obs.get("trending_tags") or [])[:2])
|
| 323 |
+
actions = []
|
| 324 |
+
for h in range(24):
|
| 325 |
+
if 9 <= h <= 20 and len([a for a in actions if a["action_type"] == "post"]) < 2:
|
| 326 |
+
ct = _CONTENT_TYPES[h % 4]
|
| 327 |
+
actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags})
|
| 328 |
+
else:
|
| 329 |
+
actions.append({"hour": h, "action_type": "create_content"})
|
| 330 |
+
return _make_daily_plan(actions)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _plan_growth_focus(obs: dict, day: int) -> ViraltestAction:
|
| 334 |
+
trending = (obs.get("trending_topics") or ["growth hacks"])[0]
|
| 335 |
+
return _make_daily_plan([
|
| 336 |
+
{"hour": 13, "action_type": "post", "content_type": "reel", "topic": trending, "tags": ["viral", "growth", "trending"]},
|
| 337 |
+
])
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _plan_tech_niche(obs: dict, day: int) -> ViraltestAction:
|
| 341 |
+
ct = _CONTENT_TYPES[day % 4]
|
| 342 |
+
return _make_daily_plan([
|
| 343 |
+
{"hour": 12, "action_type": "post", "content_type": ct, "topic": "AI tools and coding tips", "tags": ["ai", "coding", "devtools"]},
|
| 344 |
+
{"hour": 18, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": "AI tools and coding tips", "tags": ["ai", "ml", "startup"]},
|
| 345 |
+
])
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _plan_conservative(obs: dict, day: int) -> ViraltestAction:
|
| 349 |
+
trending = (obs.get("trending_topics") or ["quick tip"])[0]
|
| 350 |
+
tags = list((obs.get("trending_tags") or [])[:2])
|
| 351 |
+
return _make_daily_plan([
|
| 352 |
+
{"hour": 13, "action_type": "post", "content_type": "text_post", "topic": trending, "tags": tags},
|
| 353 |
+
])
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
SCENARIOS = {
|
| 357 |
+
"always_rest": ("Always Rest", "Never posts. Tests follower decay + zero engagement.", _plan_always_rest),
|
| 358 |
+
"spam": ("Spam Post", "Same reel every hour. Burns out fast.", _plan_spam),
|
| 359 |
+
"no_rest": ("No Rest", "Posts every hour, never rests. Burns out fast.", _plan_no_rest),
|
| 360 |
+
"smart": ("Smart Agent", "Optimal: peak hours, trending, varied types, rests.", _plan_smart),
|
| 361 |
+
"queue_optimizer": ("Queue Optimizer", "Creates content first, posts from queue.", _plan_queue_optimizer),
|
| 362 |
+
"weekend": ("Weekend Warrior", "Only posts on Sat/Sun.", _plan_weekend),
|
| 363 |
+
"tag_explorer": ("Tag Explorer", "New tag combo every post. Max discovery.", _plan_tag_explorer),
|
| 364 |
+
"sleep_deprived": ("Sleep Deprived", "Never rests. Tests sleep deprivation.", _plan_sleep_deprived),
|
| 365 |
+
"sleep_conscious": ("Sleep Conscious", "Proper sleep schedule.", _plan_sleep_conscious),
|
| 366 |
+
"minimal": ("Minimal Poster", "1 post per day at noon.", _plan_minimal),
|
| 367 |
+
"reel_max": ("Reel Maximizer", "Reels at peak hours for max reach.", _plan_reel_max),
|
| 368 |
+
"split_schedule": ("Split Schedule", "Morning and evening posts.", _plan_split_schedule),
|
| 369 |
+
"double_peak": ("Double Peak", "Posts at 9am and 3pm.", _plan_double_peak),
|
| 370 |
+
"growth_focus": ("Growth Focus", "Maximizes follower growth.", _plan_growth_focus),
|
| 371 |
+
"weekday_only": ("Weekday Only", "No weekend posting.", _plan_weekday_only),
|
| 372 |
+
"tech_niche": ("Tech Niche", "AI/coding content focus.", _plan_tech_niche),
|
| 373 |
+
"conservative": ("Conservative", "One text post at 1pm.", _plan_conservative),
|
| 374 |
+
"random": ("Random Actor", "Random actions. Baseline test.", _plan_random),
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@app.get("/dashboard/scenarios")
|
| 379 |
+
async def dashboard_scenarios():
|
| 380 |
+
"""List all simulation strategies for the dashboard UI."""
|
| 381 |
+
items = [{"id": k, "label": v[0], "description": v[1]} for k, v in SCENARIOS.items()]
|
| 382 |
+
items.sort(key=lambda x: (x["label"].lower()))
|
| 383 |
+
return JSONResponse(
|
| 384 |
+
content={"count": len(items), "scenarios": items},
|
| 385 |
+
headers={"Cache-Control": "no-store, max-age=0, must-revalidate"},
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
@app.post("/dashboard/simulate")
|
| 390 |
+
async def dashboard_simulate(body: Dict[str, Any] = Body(...)):
|
| 391 |
+
global _SIM_RNG
|
| 392 |
+
_SIM_RNG = stdlib_random.Random(99)
|
| 393 |
+
|
| 394 |
+
scenario_id = body.get("scenario", "smart")
|
| 395 |
+
task = body.get("task", "weekly_competitive")
|
| 396 |
+
if scenario_id not in SCENARIOS:
|
| 397 |
+
return {"error": f"Unknown scenario: {scenario_id}"}
|
| 398 |
+
|
| 399 |
+
label, desc, plan_fn = SCENARIOS[scenario_id]
|
| 400 |
+
env = ViraltestEnvironment()
|
| 401 |
+
obs = env.reset(task=task, seed=42)
|
| 402 |
+
obs_dict = obs.model_dump()
|
| 403 |
+
|
| 404 |
+
steps: List[Dict[str, Any]] = []
|
| 405 |
+
for day in range(1, 8):
|
| 406 |
+
action = plan_fn(obs_dict, day)
|
| 407 |
+
obs = env.step(action)
|
| 408 |
+
obs_dict = obs.model_dump()
|
| 409 |
+
r = obs.reward if obs.reward is not None else 0.0
|
| 410 |
+
|
| 411 |
+
n_posts = len([sa for sa in action.scheduled_actions if sa.action_type == "post"])
|
| 412 |
+
n_create = len([sa for sa in action.scheduled_actions if sa.action_type == "create_content"])
|
| 413 |
+
action_str = f"day{day}(posts={n_posts},creates={n_create})"
|
| 414 |
+
|
| 415 |
+
steps.append({
|
| 416 |
+
"step": day,
|
| 417 |
+
"action": action_str,
|
| 418 |
+
"reward": round(r, 4),
|
| 419 |
+
"done": obs.done,
|
| 420 |
+
"error": obs.error,
|
| 421 |
+
"energy": round(obs.creator_energy, 3),
|
| 422 |
+
"hours_since_sleep": obs.hours_since_sleep,
|
| 423 |
+
"sleep_debt": round(obs.sleep_debt, 3),
|
| 424 |
+
"followers": obs.follower_count,
|
| 425 |
+
"engagement_rate": round(obs.engagement_rate, 4),
|
| 426 |
+
"niche_saturation": round(obs.niche_saturation, 3),
|
| 427 |
+
"posts_today": obs.posts_today,
|
| 428 |
+
"hour": obs.current_hour,
|
| 429 |
+
"day": obs.day_of_week,
|
| 430 |
+
"days_elapsed": obs.days_elapsed,
|
| 431 |
+
"queue": obs.content_queue_size,
|
| 432 |
+
"tag_performance": obs.tag_performance,
|
| 433 |
+
"trending_topics": obs.trending_topics,
|
| 434 |
+
"trending_tags": obs.trending_tags,
|
| 435 |
+
"competitor_avg_engagement": round(obs.competitor_avg_engagement, 4),
|
| 436 |
+
"daily_total_engagement": round(obs.daily_total_engagement, 4),
|
| 437 |
+
"daily_posts_made": obs.daily_posts_made,
|
| 438 |
+
"daily_energy_min": round(obs.daily_energy_min, 3),
|
| 439 |
+
})
|
| 440 |
+
if obs.done:
|
| 441 |
+
break
|
| 442 |
+
|
| 443 |
+
score = (obs.metadata or {}).get("grader_score", 0.0)
|
| 444 |
+
result = {
|
| 445 |
+
"scenario": label,
|
| 446 |
+
"description": desc,
|
| 447 |
+
"task": task,
|
| 448 |
+
"steps": steps,
|
| 449 |
+
"total_steps": len(steps),
|
| 450 |
+
"score": round(score, 4),
|
| 451 |
+
"final": {
|
| 452 |
+
"energy": round(obs.creator_energy, 3),
|
| 453 |
+
"hours_since_sleep": obs.hours_since_sleep,
|
| 454 |
+
"sleep_debt": round(obs.sleep_debt, 3),
|
| 455 |
+
"followers": obs.follower_count,
|
| 456 |
+
"engagement_rate": round(obs.engagement_rate, 4),
|
| 457 |
+
"burned_out": obs.creator_energy <= 0,
|
| 458 |
+
},
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
rewards = [s["reward"] for s in steps]
|
| 462 |
+
total_posts = sum(s.get("daily_posts_made", 0) for s in steps)
|
| 463 |
+
_save_history_entry({
|
| 464 |
+
"id": datetime.now(timezone.utc).isoformat(),
|
| 465 |
+
"scenario": label,
|
| 466 |
+
"scenario_id": scenario_id,
|
| 467 |
+
"description": desc,
|
| 468 |
+
"task": task,
|
| 469 |
+
"score": round(score, 4),
|
| 470 |
+
"total_steps": len(steps),
|
| 471 |
+
"total_posts": total_posts,
|
| 472 |
+
"avg_reward": round(sum(rewards) / len(rewards), 4) if rewards else 0,
|
| 473 |
+
"final": result["final"],
|
| 474 |
+
})
|
| 475 |
+
|
| 476 |
+
return result
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 480 |
+
"""
|
| 481 |
+
Entry point for direct execution via uv run or python -m.
|
| 482 |
+
|
| 483 |
+
This function enables running the server without Docker:
|
| 484 |
+
uv run --project . server
|
| 485 |
+
uv run --project . server --port 8001
|
| 486 |
+
python -m viraltest.server.app
|
| 487 |
+
|
| 488 |
+
Args:
|
| 489 |
+
host: Host address to bind to (default: "0.0.0.0")
|
| 490 |
+
port: Port number to listen on (default: 8000)
|
| 491 |
+
|
| 492 |
+
For production deployments, consider using uvicorn directly with
|
| 493 |
+
multiple workers:
|
| 494 |
+
uvicorn viraltest.server.app:app --workers 4
|
| 495 |
+
"""
|
| 496 |
+
import uvicorn
|
| 497 |
+
|
| 498 |
+
uvicorn.run(app, host=host, port=port)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
if __name__ == "__main__":
|
| 502 |
+
import argparse
|
| 503 |
+
|
| 504 |
+
parser = argparse.ArgumentParser()
|
| 505 |
+
parser.add_argument("--port", type=int, default=None)
|
| 506 |
+
args = parser.parse_args()
|
| 507 |
+
if args.port is not None:
|
| 508 |
+
main(port=args.port)
|
| 509 |
+
else:
|
| 510 |
+
main()
|
server/dashboard.html
ADDED
|
@@ -0,0 +1,1306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html class="dark" lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta content="width=device-width,initial-scale=1.0" name="viewport"/>
|
| 6 |
+
<title>Growth Copilot — Simulation</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet"/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
| 10 |
+
<script>
|
| 11 |
+
tailwind.config={darkMode:"class",theme:{extend:{colors:{"surface":"#0b1326","surface-low":"#131b2e","surface-high":"#222a3d","surface-top":"#2d3449","surface-lowest":"#060e20","on-surface":"#dae2fd","on-surface-dim":"#cbc3d7","primary":"#d0bcff","primary-ctr":"#a078ff","secondary":"#7bd0ff","secondary-ctr":"#00a6e0","tertiary":"#ffb2b9","tertiary-ctr":"#ea6479","outline":"#494454","error":"#ffb4ab"},fontFamily:{headline:["Inter"],body:["Inter"],label:["Space Grotesk"]}}}}
|
| 12 |
+
</script>
|
| 13 |
+
<style>
|
| 14 |
+
body{background:#0b1326;color:#dae2fd;font-family:'Inter',sans-serif}
|
| 15 |
+
.material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24}
|
| 16 |
+
.glass{background:rgba(34,42,61,.6);backdrop-filter:blur(24px);border:1px solid rgba(73,68,84,.2)}
|
| 17 |
+
.glass-solid{background:#131b2e;border:1px solid rgba(73,68,84,.15)}
|
| 18 |
+
.energy-bar{transition:width .6s ease}
|
| 19 |
+
.fade-in{animation:fadeIn .3s ease}
|
| 20 |
+
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
| 21 |
+
@keyframes pulse-glow{0%,100%{box-shadow:0 0 8px rgba(208,188,255,.2)}50%{box-shadow:0 0 20px rgba(208,188,255,.4)}}
|
| 22 |
+
.pulse-glow{animation:pulse-glow 2s ease-in-out infinite}
|
| 23 |
+
::-webkit-scrollbar{width:6px}
|
| 24 |
+
::-webkit-scrollbar-track{background:transparent}
|
| 25 |
+
::-webkit-scrollbar-thumb{background:rgba(73,68,84,.4);border-radius:3px}
|
| 26 |
+
.sim-btn{transition:all .2s ease}
|
| 27 |
+
.sim-btn:hover{transform:translateY(-1px)}
|
| 28 |
+
.action-btn{transition:all .15s ease}
|
| 29 |
+
.action-btn:active{transform:scale(.97)}
|
| 30 |
+
</style>
|
| 31 |
+
</head>
|
| 32 |
+
<body class="min-h-screen flex">
|
| 33 |
+
|
| 34 |
+
<!-- Sidebar -->
|
| 35 |
+
<aside class="flex flex-col sticky top-0 h-screen w-64 border-r border-white/5 bg-surface-lowest shadow-2xl shadow-slate-950/50 shrink-0 z-50">
|
| 36 |
+
<div class="p-6 pb-4">
|
| 37 |
+
<div class="text-xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-br from-primary to-primary-ctr mb-1">Growth Copilot</div>
|
| 38 |
+
<div class="text-[9px] font-label uppercase tracking-[.2em] text-on-surface-dim/50">Weekly growth simulation</div>
|
| 39 |
+
</div>
|
| 40 |
+
<nav class="flex-1 px-3 space-y-1">
|
| 41 |
+
<a href="/dashboard" class="flex items-center gap-3 px-4 py-2.5 rounded-lg text-primary font-bold border-r-2 border-primary bg-gradient-to-r from-primary/10 to-transparent transition-all">
|
| 42 |
+
<span class="material-symbols-outlined text-[20px]">dashboard</span><span class="font-label text-sm">Dashboard</span>
|
| 43 |
+
</a>
|
| 44 |
+
<a href="/web/" class="flex items-center gap-3 px-4 py-2.5 rounded-lg text-slate-400 font-medium hover:text-slate-200 hover:bg-white/5 transition-all">
|
| 45 |
+
<span class="material-symbols-outlined text-[20px]">web</span><span class="font-label text-sm">OpenEnv UI</span>
|
| 46 |
+
</a>
|
| 47 |
+
</nav>
|
| 48 |
+
<!-- Task Selector in Sidebar -->
|
| 49 |
+
<div class="p-4 border-t border-white/5 space-y-3">
|
| 50 |
+
<div class="text-[9px] font-label uppercase tracking-widest text-on-surface-dim/60 mb-1">Task</div>
|
| 51 |
+
<select id="taskSelect" onchange="refreshTaskScoreBlurb()" class="w-full bg-surface border border-outline/30 rounded-lg px-3 py-2 text-sm font-label focus:ring-1 focus:ring-primary focus:outline-none">
|
| 52 |
+
<option value="weekly_engage">Easy — Engage</option>
|
| 53 |
+
<option value="weekly_strategic">Medium — Strategic</option>
|
| 54 |
+
<option value="weekly_competitive" selected>Hard — Competitive</option>
|
| 55 |
+
</select>
|
| 56 |
+
<button onclick="doReset()" class="w-full py-3 rounded-lg bg-gradient-to-br from-primary to-primary-ctr text-[#23005c] font-bold text-sm hover:opacity-90 transition active:scale-[.97]">
|
| 57 |
+
<span class="material-symbols-outlined text-[16px] align-middle mr-1">restart_alt</span>Reset
|
| 58 |
+
</button>
|
| 59 |
+
</div>
|
| 60 |
+
</aside>
|
| 61 |
+
|
| 62 |
+
<!-- Main -->
|
| 63 |
+
<div class="flex-1 flex flex-col min-w-0">
|
| 64 |
+
|
| 65 |
+
<!-- Top Bar -->
|
| 66 |
+
<header class="flex justify-between items-center px-6 h-14 border-b border-white/5 bg-surface/60 backdrop-blur-xl sticky top-0 z-40">
|
| 67 |
+
<div class="flex items-center gap-5">
|
| 68 |
+
<span id="statusDot" class="flex items-center gap-2 text-xs font-label text-secondary"><span class="w-2 h-2 rounded-full bg-secondary"></span>Ready</span>
|
| 69 |
+
<span class="text-xs font-label text-on-surface-dim">Step <span id="stepNum" class="text-on-surface font-bold">0</span> / 168</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="flex items-center gap-3">
|
| 72 |
+
<span id="rewardBadge" class="text-xs font-label text-on-surface-dim">Last reward: —</span>
|
| 73 |
+
<span class="text-xs font-label text-on-surface-dim/40">|</span>
|
| 74 |
+
<span id="timeBadge" class="text-xs font-label text-on-surface-dim"><span class="material-symbols-outlined text-[14px] align-middle">schedule</span> <span id="timeVal">9:00</span> <span id="dayVal" class="text-on-surface-dim/60">Mon</span></span>
|
| 75 |
+
</div>
|
| 76 |
+
</header>
|
| 77 |
+
|
| 78 |
+
<main class="flex-1 p-6 space-y-5 overflow-y-auto">
|
| 79 |
+
|
| 80 |
+
<!-- Hero Stat Cards -->
|
| 81 |
+
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
| 82 |
+
|
| 83 |
+
<!-- Energy -->
|
| 84 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 85 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">bolt</span></div>
|
| 86 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Energy</div>
|
| 87 |
+
<div id="energyVal" class="text-3xl font-black tracking-tight">1.00</div>
|
| 88 |
+
<div class="mt-3 h-2 bg-surface-top rounded-full overflow-hidden">
|
| 89 |
+
<div id="energyBar" class="h-full bg-gradient-to-r from-tertiary-ctr to-tertiary energy-bar rounded-full" style="width:100%"></div>
|
| 90 |
+
</div>
|
| 91 |
+
<div id="energyHint" class="mt-1.5 text-[9px] font-label text-tertiary">FULL</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- Followers -->
|
| 95 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 96 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">group</span></div>
|
| 97 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Followers</div>
|
| 98 |
+
<div id="followersVal" class="text-3xl font-black tracking-tight">10,000</div>
|
| 99 |
+
<div id="followersDelta" class="mt-1.5 text-[9px] font-label text-on-surface-dim">+0 since start</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<!-- Engagement -->
|
| 103 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 104 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">trending_up</span></div>
|
| 105 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Engagement</div>
|
| 106 |
+
<div id="engVal" class="text-3xl font-black tracking-tight text-secondary">0.000</div>
|
| 107 |
+
<div id="engVsComp" class="mt-1.5 text-[9px] font-label text-on-surface-dim">vs competitors: —</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<!-- Posts Today -->
|
| 111 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 112 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">send</span></div>
|
| 113 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Posts Today</div>
|
| 114 |
+
<div id="postsVal" class="text-3xl font-black tracking-tight">0</div>
|
| 115 |
+
<div class="mt-1.5 text-[9px] font-label text-on-surface-dim">max 2-3 optimal</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- Queue -->
|
| 119 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 120 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">inventory_2</span></div>
|
| 121 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Content Queue</div>
|
| 122 |
+
<div id="queueVal" class="text-3xl font-black tracking-tight text-secondary">0</div>
|
| 123 |
+
<div class="mt-1.5 text-[9px] font-label text-on-surface-dim">posts cost 50% less</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- Saturation -->
|
| 127 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 128 |
+
<div class="absolute top-3 right-3 opacity-10"><span class="material-symbols-outlined text-4xl">layers</span></div>
|
| 129 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Niche Saturation</div>
|
| 130 |
+
<div id="satVal" class="text-3xl font-black tracking-tight text-primary">0.00</div>
|
| 131 |
+
<div id="satHint" class="mt-1.5 text-[9px] font-label text-primary">LOW — post unique topics</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div class="glass-solid border border-outline/20 rounded-xl px-4 py-3 space-y-3">
|
| 136 |
+
<div class="flex gap-3 items-start">
|
| 137 |
+
<span class="material-symbols-outlined text-secondary text-lg shrink-0">info</span>
|
| 138 |
+
<p class="text-[11px] font-label text-on-surface-dim leading-relaxed flex-1 min-w-0">
|
| 139 |
+
<span class="text-on-surface font-semibold">Simulation only</span> — not live social data. Each <span class="text-on-surface">step</span> is ~1 hour; <span class="text-on-surface">Post</span> drives engagement and tags; <span class="text-on-surface">Rest</span> restores energy while rivals keep posting.
|
| 140 |
+
</p>
|
| 141 |
+
</div>
|
| 142 |
+
<div class="border-t border-white/5 pt-3 space-y-2">
|
| 143 |
+
<div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Niche saturation</div>
|
| 144 |
+
<p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
|
| 145 |
+
Shown after each step for your <span class="text-on-surface">last post topic</span>. The sim collects competitor posts from the last <span class="text-on-surface">12 simulated hours</span>, counts how many topics overlap yours (≥50% shared words), and divides by the number of those recent competitor posts. Result is capped at 1.0. High saturation usually means more crowd overlap; the environment can lower engagement when you post into a crowded topic.
|
| 146 |
+
</p>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="border-t border-white/5 pt-3 space-y-2">
|
| 149 |
+
<div class="text-[10px] font-bold text-on-surface uppercase tracking-widest">Final score & viral meter</div>
|
| 150 |
+
<p id="taskScoreBlurb" class="text-[10px] font-label text-on-surface-dim leading-relaxed"></p>
|
| 151 |
+
<p class="text-[10px] font-label text-on-surface-dim leading-relaxed">
|
| 152 |
+
<span class="text-on-surface font-semibold">Viral probability</span> (dashboard only): <code class="text-on-surface/90">min(100, round(engagement_rate × 1000))</code> with LOW / MEDIUM / HIGH labels at 40% and 70%. It is not the grader and not a forecast of real-world reach.
|
| 153 |
+
</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<!-- Charts Row -->
|
| 158 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
| 159 |
+
<!-- Reward history chart -->
|
| 160 |
+
<div class="lg:col-span-2 glass-solid p-5 rounded-xl overflow-hidden">
|
| 161 |
+
<div class="flex justify-between items-center mb-2">
|
| 162 |
+
<div>
|
| 163 |
+
<h3 class="text-sm font-bold">Reward history</h3>
|
| 164 |
+
<p class="text-[10px] text-on-surface-dim mt-0.5">Per-step RL reward after each action (axes: step index × reward)</p>
|
| 165 |
+
</div>
|
| 166 |
+
<span class="flex items-center gap-1.5 text-[10px] font-label text-on-surface-dim"><span class="w-2 h-2 rounded-full bg-secondary"></span>Reward</span>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="h-52 relative">
|
| 169 |
+
<svg id="engagementChart" class="w-full h-full" viewBox="0 0 760 208" preserveAspectRatio="xMidYMid meet"></svg>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<!-- Burnout Meter -->
|
| 174 |
+
<div class="glass-solid p-5 rounded-xl flex flex-col items-center overflow-hidden">
|
| 175 |
+
<div class="flex justify-between items-center w-full mb-3">
|
| 176 |
+
<h3 class="text-sm font-bold">Burnout Meter</h3>
|
| 177 |
+
<span class="material-symbols-outlined text-tertiary text-lg">monitor_heart</span>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="relative w-40 h-40 mb-3">
|
| 180 |
+
<svg viewBox="0 0 120 120" class="w-full h-full -rotate-90">
|
| 181 |
+
<circle cx="60" cy="60" r="50" fill="none" stroke="#222a3d" stroke-width="10"/>
|
| 182 |
+
<circle id="burnoutArc" cx="60" cy="60" r="50" fill="none" stroke="url(#burnoutGrad)" stroke-width="10" stroke-linecap="round" stroke-dasharray="0 314" style="transition:stroke-dasharray .6s ease"/>
|
| 183 |
+
<defs><linearGradient id="burnoutGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#ffb2b9"/><stop offset="100%" style="stop-color:#ea6479"/></linearGradient></defs>
|
| 184 |
+
</svg>
|
| 185 |
+
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
| 186 |
+
<span id="burnoutPct" class="text-4xl font-black tracking-tight">0%</span>
|
| 187 |
+
<span class="text-[8px] font-label text-tertiary uppercase tracking-widest mt-0.5">Cortisol Level</span>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
<div id="burnoutRec" class="p-3 rounded-lg bg-surface border border-outline/15 text-[10px] font-label text-on-surface-dim text-center leading-relaxed w-full">
|
| 191 |
+
Recommendation: Start with a balanced create-rest cycle.
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<!-- Second Charts Row -->
|
| 197 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
| 198 |
+
<!-- Follower Growth -->
|
| 199 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 200 |
+
<h3 class="text-sm font-bold mb-3">Follower Growth</h3>
|
| 201 |
+
<div class="h-32 relative">
|
| 202 |
+
<svg id="followerChart" class="w-full h-full" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet"></svg>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="flex items-baseline gap-3 mt-2">
|
| 205 |
+
<span id="followerTotal" class="text-2xl font-black tracking-tight text-secondary">+0</span>
|
| 206 |
+
<span id="followerDeltaPct" class="text-xs font-label text-secondary/60">+0% vs start</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<!-- Top Performing Tags -->
|
| 211 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 212 |
+
<h3 class="text-sm font-bold mb-3">Top Performing Tags</h3>
|
| 213 |
+
<div id="topTagsList" class="space-y-3">
|
| 214 |
+
<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<!-- Recent RL Actions -->
|
| 219 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 220 |
+
<h3 class="text-sm font-bold mb-3">Recent RL Actions</h3>
|
| 221 |
+
<div id="recentActions" class="space-y-3 max-h-44 overflow-y-auto">
|
| 222 |
+
<div class="text-on-surface-dim italic text-[10px]">No actions yet</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- Step & hour analytics -->
|
| 228 |
+
<div class="space-y-3">
|
| 229 |
+
<div class="flex items-center gap-2 px-1">
|
| 230 |
+
<span class="material-symbols-outlined text-secondary text-lg">show_chart</span>
|
| 231 |
+
<h2 class="text-sm font-bold">Step & hour analytics</h2>
|
| 232 |
+
<span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">X = simulation step (~1h); posts histogram = clock hour (0–23)</span>
|
| 233 |
+
</div>
|
| 234 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-3">
|
| 235 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 236 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Energy / step</div>
|
| 237 |
+
<svg id="tsEnergy" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 238 |
+
</div>
|
| 239 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 240 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Followers / step</div>
|
| 241 |
+
<svg id="tsFollowers" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 242 |
+
</div>
|
| 243 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 244 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Follower Δ / step</div>
|
| 245 |
+
<svg id="tsFollowDelta" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 248 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Engagement rate / step</div>
|
| 249 |
+
<svg id="tsEngagement" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 250 |
+
</div>
|
| 251 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 252 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Reward / step</div>
|
| 253 |
+
<svg id="tsReward" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 254 |
+
</div>
|
| 255 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 256 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Niche saturation / step</div>
|
| 257 |
+
<svg id="tsSat" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 258 |
+
</div>
|
| 259 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 260 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Content queue / step</div>
|
| 261 |
+
<svg id="tsQueue" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 262 |
+
</div>
|
| 263 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 264 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Competitor avg engagement / step</div>
|
| 265 |
+
<svg id="tsComp" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 266 |
+
</div>
|
| 267 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 268 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Sleep debt / step</div>
|
| 269 |
+
<svg id="tsSleep" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 270 |
+
</div>
|
| 271 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 272 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Hours since sleep / step</div>
|
| 273 |
+
<svg id="tsAwake" class="w-full h-24" viewBox="0 0 360 112" preserveAspectRatio="xMidYMid meet"></svg>
|
| 274 |
+
</div>
|
| 275 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 276 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mb-1">Posts by clock hour (0–23)</div>
|
| 277 |
+
<svg id="tsPostsHour" class="w-full h-20" viewBox="0 0 320 72" preserveAspectRatio="xMidYMid meet"></svg>
|
| 278 |
+
<div class="text-[10px] font-bold text-on-surface-dim uppercase tracking-widest mt-2 mb-0.5">Action counts (run)</div>
|
| 279 |
+
<svg id="tsActionMix" class="w-full h-14" viewBox="0 0 320 52" preserveAspectRatio="xMidYMid meet"></svg>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
<!-- Bottom Stats -->
|
| 285 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 286 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 287 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Avg Reward</div>
|
| 288 |
+
<div id="bottomAvgReward" class="text-3xl font-black tracking-tight">0.00</div>
|
| 289 |
+
<div id="bottomAvgDelta" class="text-[10px] font-label text-on-surface-dim mt-1">—</div>
|
| 290 |
+
</div>
|
| 291 |
+
<div class="glass-solid p-4 rounded-xl overflow-hidden">
|
| 292 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Total Posts</div>
|
| 293 |
+
<div id="bottomTotalPosts" class="text-3xl font-black tracking-tight">0</div>
|
| 294 |
+
<div class="text-[10px] font-label text-on-surface-dim mt-1">across episode</div>
|
| 295 |
+
</div>
|
| 296 |
+
<div class="glass-solid relative p-4 rounded-xl overflow-hidden">
|
| 297 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1">Viral Probability</div>
|
| 298 |
+
<div id="bottomViralProb" class="text-3xl font-black tracking-tight">LOW (0%)</div>
|
| 299 |
+
<p id="viralFormulaNote" class="text-[9px] font-label text-on-surface-dim/90 leading-snug mt-2">From current engagement rate only (UI heuristic).</p>
|
| 300 |
+
<div class="absolute bottom-0 right-0 w-2/3 h-10 opacity-30 pointer-events-none">
|
| 301 |
+
<svg viewBox="0 0 200 30" class="w-full h-full" preserveAspectRatio="none">
|
| 302 |
+
<defs><linearGradient id="viralGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" style="stop-color:#d0bcff;stop-opacity:.5"/><stop offset="50%" style="stop-color:#ea6479;stop-opacity:.5"/><stop offset="100%" style="stop-color:#7bd0ff;stop-opacity:.5"/></linearGradient></defs>
|
| 303 |
+
<path d="M0,25 Q30,5 60,20 Q90,30 120,10 Q150,0 180,15 Q200,25 200,30 L0,30Z" fill="url(#viralGrad)"/>
|
| 304 |
+
</svg>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<!-- Main Grid: Actions / History / Intelligence -->
|
| 310 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
| 311 |
+
|
| 312 |
+
<!-- Left: Actions + History -->
|
| 313 |
+
<div class="lg:col-span-8 space-y-5">
|
| 314 |
+
|
| 315 |
+
<!-- Action Panel -->
|
| 316 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 317 |
+
<h3 class="text-sm font-bold mb-4 flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">gamepad</span>Send Action</h3>
|
| 318 |
+
<div class="grid grid-cols-3 gap-3 mb-3">
|
| 319 |
+
<button type="button" title="Advance one hour, recover energy, reduce burnout. Competitors still simulate." onclick="doAction('rest')" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-tertiary/5 to-tertiary/10 border border-tertiary/15 hover:border-tertiary/40 hover:from-tertiary/10 hover:to-tertiary/20 text-center">
|
| 320 |
+
<span class="material-symbols-outlined text-tertiary text-3xl group-hover:scale-110 transition-transform">hotel</span>
|
| 321 |
+
<div class="text-sm font-bold text-tertiary mt-1">Rest</div>
|
| 322 |
+
<div class="text-[9px] text-on-surface-dim mt-0.5">+0.12 energy recovery</div>
|
| 323 |
+
</button>
|
| 324 |
+
<button type="button" title="Add one item to the queue. Costs a little energy; use before Post for cheaper publishes." onclick="doAction('create_content')" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-secondary/5 to-secondary/10 border border-secondary/15 hover:border-secondary/40 hover:from-secondary/10 hover:to-secondary/20 text-center">
|
| 325 |
+
<span class="material-symbols-outlined text-secondary text-3xl group-hover:scale-110 transition-transform">edit_note</span>
|
| 326 |
+
<div class="text-sm font-bold text-secondary mt-1">Create</div>
|
| 327 |
+
<div class="text-[9px] text-on-surface-dim mt-0.5">-0.05 energy, +1 queue</div>
|
| 328 |
+
</button>
|
| 329 |
+
<button type="button" title="Publish to the feed: choose format, topic, and tags. Drives engagement and tag stats." onclick="showPostForm()" id="postBtn" class="action-btn group p-4 rounded-xl bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/15 hover:border-primary/40 hover:from-primary/10 hover:to-primary/20 text-center">
|
| 330 |
+
<span class="material-symbols-outlined text-primary text-3xl group-hover:scale-110 transition-transform">send</span>
|
| 331 |
+
<div class="text-sm font-bold text-primary mt-1">Post</div>
|
| 332 |
+
<div class="text-[9px] text-on-surface-dim mt-0.5">type + topic + tags</div>
|
| 333 |
+
</button>
|
| 334 |
+
</div>
|
| 335 |
+
<!-- Post Form -->
|
| 336 |
+
<div id="postForm" class="hidden fade-in space-y-2.5 p-4 rounded-xl bg-surface border border-outline/30">
|
| 337 |
+
<div class="grid grid-cols-2 gap-2.5">
|
| 338 |
+
<select id="contentType" class="bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm font-label focus:ring-1 focus:ring-primary focus:outline-none">
|
| 339 |
+
<option value="reel">Reel (-0.25 energy)</option>
|
| 340 |
+
<option value="carousel">Carousel (-0.20)</option>
|
| 341 |
+
<option value="story">Story (-0.08)</option>
|
| 342 |
+
<option value="text_post">Text Post (-0.06)</option>
|
| 343 |
+
</select>
|
| 344 |
+
<input id="topicInput" class="bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none" placeholder="Topic (e.g. AI trends)"/>
|
| 345 |
+
</div>
|
| 346 |
+
<input id="tagsInput" class="w-full bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none" placeholder="Tags comma-separated (ai, ml, coding)"/>
|
| 347 |
+
<div class="flex gap-2">
|
| 348 |
+
<button type="button" onclick="doPost()" class="px-5 py-2 rounded-lg bg-primary text-[#23005c] font-bold text-sm hover:opacity-90 transition">Send Post</button>
|
| 349 |
+
<button type="button" onclick="hidePostForm()" class="px-5 py-2 rounded-lg border border-outline/30 text-sm text-on-surface-dim hover:bg-white/5 transition">Cancel</button>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<!-- Simulate Scenarios (loaded from /dashboard/scenarios) -->
|
| 355 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 356 |
+
<div class="flex flex-wrap justify-between items-center gap-2 mb-3">
|
| 357 |
+
<h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-secondary text-lg">science</span>Simulate Scenarios</h3>
|
| 358 |
+
<div class="flex flex-col items-end gap-0.5">
|
| 359 |
+
<div class="flex items-center gap-2">
|
| 360 |
+
<span id="scenarioCount" class="text-[9px] font-label text-primary font-bold">…</span>
|
| 361 |
+
<span class="text-[9px] font-label text-on-surface-dim">168-step episode</span>
|
| 362 |
+
</div>
|
| 363 |
+
<span class="text-[8px] font-label text-on-surface-dim/70 max-w-[16rem] text-right leading-tight">All strategies below — scroll the grid or search. Count updates after load.</span>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
<div class="mb-3 space-y-2">
|
| 367 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Easy</div>
|
| 368 |
+
<div class="flex flex-wrap gap-2">
|
| 369 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_morning_story')">Morning story</button>
|
| 370 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_one_a_day')">One text @ 1pm</button>
|
| 371 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-tertiary/10 border border-tertiary/25 text-[10px] font-label text-tertiary hover:bg-tertiary/20" onclick="runSim('easy_relaxed')">Afternoon story</button>
|
| 372 |
+
</div>
|
| 373 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Suggested — Medium</div>
|
| 374 |
+
<div class="flex flex-wrap gap-2">
|
| 375 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_queue_cycle')">Create → post</button>
|
| 376 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_trend_rotate')">Trend + formats</button>
|
| 377 |
+
<button type="button" class="sim-btn px-2.5 py-1.5 rounded-lg bg-secondary/10 border border-secondary/25 text-[10px] font-label text-secondary hover:bg-secondary/20" onclick="runSim('medium_two_format')">Reel + carousel</button>
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
<input type="search" id="scenarioFilter" autocomplete="off" placeholder="Search strategies by name or description…" class="w-full mb-2 bg-surface-low border border-outline/30 rounded-lg px-3 py-2 text-sm focus:ring-1 focus:ring-primary focus:outline-none"/>
|
| 381 |
+
<div id="scenarioGrid" tabindex="0" role="region" aria-label="Strategy list, scroll for all scenarios" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2 mb-3 max-h-[min(52vh,36rem)] min-h-[14rem] overflow-y-auto overscroll-y-contain pr-1 py-1 rounded-lg border border-outline/15 bg-surface-low/40 scrollbar-thin shadow-inner">
|
| 382 |
+
<div class="col-span-full text-on-surface-dim text-[10px] italic py-4 text-center">Loading strategies…</div>
|
| 383 |
+
</div>
|
| 384 |
+
<!-- Sim Progress -->
|
| 385 |
+
<div id="simProgress" class="hidden">
|
| 386 |
+
<div class="flex items-center gap-3 mb-2">
|
| 387 |
+
<div class="h-2 flex-1 bg-surface-top rounded-full overflow-hidden"><div id="simBar" class="h-full bg-gradient-to-r from-primary to-secondary transition-all duration-100 rounded-full" style="width:0%"></div></div>
|
| 388 |
+
<span id="simPct" class="text-[10px] font-label text-on-surface-dim w-8 text-right">0%</span>
|
| 389 |
+
</div>
|
| 390 |
+
<div id="simResult" class="hidden"></div>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
<!-- Step History -->
|
| 395 |
+
<div class="glass-solid rounded-xl overflow-hidden">
|
| 396 |
+
<div class="p-4 border-b border-white/5 flex justify-between items-center">
|
| 397 |
+
<h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-on-surface-dim text-lg">history</span>Step History</h3>
|
| 398 |
+
</div>
|
| 399 |
+
<div id="historyLog" class="p-4 space-y-1.5 max-h-72 overflow-y-auto text-[11px] font-mono leading-relaxed">
|
| 400 |
+
<div class="text-on-surface-dim italic">Reset the environment to begin...</div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
|
| 405 |
+
<!-- Right: Intelligence Panels -->
|
| 406 |
+
<div class="lg:col-span-4 space-y-5">
|
| 407 |
+
|
| 408 |
+
<!-- Grader Score (shown when done) -->
|
| 409 |
+
<div id="graderCard" class="hidden glass-solid p-5 rounded-xl border-2 border-primary pulse-glow overflow-hidden">
|
| 410 |
+
<div class="flex justify-between items-start">
|
| 411 |
+
<div>
|
| 412 |
+
<div class="text-[9px] font-label text-primary uppercase tracking-widest">Final Score</div>
|
| 413 |
+
<div id="graderScore" class="text-5xl font-black text-primary tracking-tighter mt-1">—</div>
|
| 414 |
+
</div>
|
| 415 |
+
<span class="material-symbols-outlined text-primary/20 text-5xl">emoji_events</span>
|
| 416 |
+
</div>
|
| 417 |
+
<div id="graderLabel" class="mt-2 text-xs font-label text-on-surface-dim">Episode complete</div>
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
<!-- Trending -->
|
| 421 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 422 |
+
<h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-secondary text-lg">trending_up</span>Trending Now</h3>
|
| 423 |
+
<div class="mb-3">
|
| 424 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Topics</div>
|
| 425 |
+
<div id="trendTopics" class="flex flex-wrap gap-1.5"></div>
|
| 426 |
+
</div>
|
| 427 |
+
<div>
|
| 428 |
+
<div class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest mb-1.5">Tags</div>
|
| 429 |
+
<div id="trendTags" class="flex flex-wrap gap-1.5"></div>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
|
| 433 |
+
<!-- Tag Performance -->
|
| 434 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 435 |
+
<h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">science</span>Tag Performance</h3>
|
| 436 |
+
<div id="tagPerf" class="space-y-2.5 text-xs">
|
| 437 |
+
<div class="text-on-surface-dim italic">No data yet</div>
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
|
| 441 |
+
<!-- Competitors -->
|
| 442 |
+
<div class="glass-solid p-5 rounded-xl overflow-hidden">
|
| 443 |
+
<h3 class="text-sm font-bold mb-3 flex items-center gap-2"><span class="material-symbols-outlined text-tertiary text-lg">groups</span>Competitors</h3>
|
| 444 |
+
<div class="mb-3 flex justify-between items-center">
|
| 445 |
+
<span class="text-[9px] font-label text-on-surface-dim uppercase tracking-widest">Avg Engagement</span>
|
| 446 |
+
<span id="compEng" class="text-sm font-bold text-tertiary">0.000</span>
|
| 447 |
+
</div>
|
| 448 |
+
<div id="compPosts" class="space-y-2 text-xs">
|
| 449 |
+
<div class="text-on-surface-dim italic">No competitor posts yet</div>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
|
| 455 |
+
<!-- Simulation History -->
|
| 456 |
+
<div class="glass-solid rounded-xl overflow-hidden">
|
| 457 |
+
<div class="p-4 border-b border-white/5 flex justify-between items-center">
|
| 458 |
+
<h3 class="text-sm font-bold flex items-center gap-2"><span class="material-symbols-outlined text-primary text-lg">history</span>Simulation History</h3>
|
| 459 |
+
<div class="flex items-center gap-2">
|
| 460 |
+
<button onclick="loadHistory()" class="text-[9px] font-label text-secondary hover:text-secondary/80 transition">Refresh</button>
|
| 461 |
+
<button onclick="clearHistory()" class="text-[9px] font-label text-on-surface-dim/50 hover:text-tertiary transition">Clear</button>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
<div class="overflow-x-auto">
|
| 465 |
+
<table class="w-full text-[11px] font-label">
|
| 466 |
+
<thead>
|
| 467 |
+
<tr class="text-on-surface-dim/60 uppercase tracking-wider border-b border-white/5">
|
| 468 |
+
<th class="text-left px-4 py-2.5">Time</th>
|
| 469 |
+
<th class="text-left px-4 py-2.5">Scenario</th>
|
| 470 |
+
<th class="text-left px-4 py-2.5">Task</th>
|
| 471 |
+
<th class="text-right px-4 py-2.5">Score</th>
|
| 472 |
+
<th class="text-right px-4 py-2.5">Steps</th>
|
| 473 |
+
<th class="text-right px-4 py-2.5">Posts</th>
|
| 474 |
+
<th class="text-right px-4 py-2.5">Followers</th>
|
| 475 |
+
<th class="text-right px-4 py-2.5">Delta</th>
|
| 476 |
+
<th class="text-right px-4 py-2.5">Energy</th>
|
| 477 |
+
<th class="text-center px-4 py-2.5">Status</th>
|
| 478 |
+
</tr>
|
| 479 |
+
</thead>
|
| 480 |
+
<tbody id="historyTable">
|
| 481 |
+
<tr><td colspan="10" class="px-4 py-6 text-center text-on-surface-dim italic">No history yet — run a simulation</td></tr>
|
| 482 |
+
</tbody>
|
| 483 |
+
</table>
|
| 484 |
+
</div>
|
| 485 |
+
</div>
|
| 486 |
+
|
| 487 |
+
</main>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<script>
|
| 491 |
+
const API=window.location.origin;
|
| 492 |
+
const DAYS=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
|
| 493 |
+
function fmtAxisNum(v){
|
| 494 |
+
const a=Math.abs(v);
|
| 495 |
+
if(a>=1e6)return (v/1e6).toFixed(1)+"M";
|
| 496 |
+
if(a>=1e3)return (v/1e3).toFixed(1)+"k";
|
| 497 |
+
if(a>=100)return v.toFixed(0);
|
| 498 |
+
if(a>=10)return v.toFixed(1);
|
| 499 |
+
return v.toFixed(2);
|
| 500 |
+
}
|
| 501 |
+
function refreshTaskScoreBlurb(){
|
| 502 |
+
const el=document.getElementById("taskScoreBlurb");
|
| 503 |
+
if(!el)return;
|
| 504 |
+
const t=document.getElementById("taskSelect").value;
|
| 505 |
+
if(t==="weekly_engage"){
|
| 506 |
+
el.innerHTML="<span class=\"text-on-surface font-semibold\">Easy (Engage):</span> final score = min(1, total episode engagement ÷ theoretical maximum). If energy hits 0 at the end, the score is multiplied by 0.3.";
|
| 507 |
+
}else if(t==="weekly_strategic"){
|
| 508 |
+
el.innerHTML="<span class=\"text-on-surface font-semibold\">Medium (Strategic):</span> 35% normalized engagement + 25% tag mix (discovery + top-tag performance) + 25% average energy + 15% days with solid posts. Penalties if energy ever crashes low or you use fewer than 5 unique tags.";
|
| 509 |
+
}else{
|
| 510 |
+
el.innerHTML="<span class=\"text-on-surface font-semibold\">Hard (Competitive):</span> 25% engagement + 20% tags + 20% follower growth + 15% beating rival avg engagement + 10% differentiated topics + 10% minimum energy floor. Score is 0 if burned out; ×0.5 if fewer than 3 content types; ×0.7 if fewer than 8 unique tags.";
|
| 511 |
+
}
|
| 512 |
+
}
|
| 513 |
+
let currentObs=null;
|
| 514 |
+
const energyHistory=[];
|
| 515 |
+
const rewardHistory=[];
|
| 516 |
+
const followerHistory=[];
|
| 517 |
+
const actionLog=[];
|
| 518 |
+
const timelineHistory=[];
|
| 519 |
+
let totalPostsCount=0;
|
| 520 |
+
|
| 521 |
+
function recordTimelineFromObs(d, actionType){
|
| 522 |
+
const o=d.observation||d;
|
| 523 |
+
const step=o.metadata?.step??timelineHistory.length;
|
| 524 |
+
timelineHistory.push({
|
| 525 |
+
step,
|
| 526 |
+
simHour:(o.days_elapsed??0)*24+(o.current_hour??0),
|
| 527 |
+
hour:o.current_hour??0,
|
| 528 |
+
day:o.day_of_week??0,
|
| 529 |
+
energy:o.creator_energy??0,
|
| 530 |
+
followers:o.follower_count??0,
|
| 531 |
+
engagement:o.engagement_rate??0,
|
| 532 |
+
reward:d.reward??0,
|
| 533 |
+
sat:o.niche_saturation??0,
|
| 534 |
+
queue:o.content_queue_size??0,
|
| 535 |
+
postsToday:o.posts_today??0,
|
| 536 |
+
compAvg:o.competitor_avg_engagement??0,
|
| 537 |
+
sleepDebt:o.sleep_debt??0,
|
| 538 |
+
hoursSinceSleep:o.hours_since_sleep??0,
|
| 539 |
+
action:actionType||null,
|
| 540 |
+
});
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
function simActionType(actionStr){
|
| 544 |
+
const a=actionStr||"";
|
| 545 |
+
if(a.startsWith("post"))return "post";
|
| 546 |
+
if(a.startsWith("rest"))return "rest";
|
| 547 |
+
if(a.startsWith("create"))return "create_content";
|
| 548 |
+
return null;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
function redrawTimelineCharts(){
|
| 552 |
+
drawStepLineChart("tsEnergy","energy","#ffb2b9");
|
| 553 |
+
drawStepLineChart("tsFollowers","followers","#7bd0ff");
|
| 554 |
+
drawFollowerDeltaChart("tsFollowDelta");
|
| 555 |
+
drawStepLineChart("tsEngagement","engagement","#a078ff");
|
| 556 |
+
drawStepLineChart("tsReward","reward","#d0bcff");
|
| 557 |
+
drawStepLineChart("tsSat","sat","#ea6479");
|
| 558 |
+
drawStepLineChart("tsQueue","queue","#00a6e0");
|
| 559 |
+
drawStepLineChart("tsComp","compAvg","#7bd0ff");
|
| 560 |
+
drawStepLineChart("tsSleep","sleepDebt","#958ea0");
|
| 561 |
+
drawStepLineChart("tsAwake","hoursSinceSleep","#cbc3d7");
|
| 562 |
+
drawPostsByHour("tsPostsHour");
|
| 563 |
+
drawActionMix("tsActionMix");
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function drawStepLineChart(svgId,key,color){
|
| 567 |
+
const svg=document.getElementById(svgId);
|
| 568 |
+
const data=timelineHistory;
|
| 569 |
+
if(!svg)return;
|
| 570 |
+
const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
|
| 571 |
+
const plotW=W-pL-pR,plotH=H-pT-pB;
|
| 572 |
+
if(!data.length){
|
| 573 |
+
svg.innerHTML=`<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No steps yet</text>`;
|
| 574 |
+
return;
|
| 575 |
+
}
|
| 576 |
+
const vals=data.map(d=>Number(d[key]??0));
|
| 577 |
+
let minV=Math.min(...vals),maxV=Math.max(...vals);
|
| 578 |
+
if(maxV-minV<1e-9){minV-=0.5;maxV+=0.5;}
|
| 579 |
+
const n=data.length;
|
| 580 |
+
const pts=data.map((d,i)=>{
|
| 581 |
+
const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
|
| 582 |
+
const v=Number(d[key]??0);
|
| 583 |
+
const y=pT+(1-(v-minV)/(maxV-minV))*plotH;
|
| 584 |
+
return {x,y};
|
| 585 |
+
});
|
| 586 |
+
let lineD;
|
| 587 |
+
if(pts.length===1)lineD=`M${pts[0].x},${pts[0].y} L${(pts[0].x+1)},${pts[0].y}`;
|
| 588 |
+
else lineD=smoothPath(pts);
|
| 589 |
+
const last=pts[pts.length-1],first=pts[0];
|
| 590 |
+
const areaD=lineD+` L${last.x},${H-pB} L${first.x},${H-pB} Z`;
|
| 591 |
+
const gid="g_"+svgId.replace(/[^a-zA-Z0-9_]/g,"_");
|
| 592 |
+
let h="";
|
| 593 |
+
for(let g=0;g<=4;g++){
|
| 594 |
+
const y=pT+(g/4)*plotH;
|
| 595 |
+
const val=maxV-(g/4)*(maxV-minV);
|
| 596 |
+
h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
|
| 597 |
+
h+=`<text x="${pL-5}" y="${y+3}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${fmtAxisNum(val)}</text>`;
|
| 598 |
+
}
|
| 599 |
+
h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
|
| 600 |
+
h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
|
| 601 |
+
h+=`<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity="0.22"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>`;
|
| 602 |
+
h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="${color}" stroke-width="2"/>`;
|
| 603 |
+
const lastI=n-1;
|
| 604 |
+
h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
|
| 605 |
+
h+=`<text x="${pL+plotW/2}" y="${H-8}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${Math.floor(lastI/2)}</text>`;
|
| 606 |
+
h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
|
| 607 |
+
h+=`<text x="${pL+plotW/2}" y="${H-1}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif" opacity="0.75">step</text>`;
|
| 608 |
+
svg.innerHTML=h;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
function drawFollowerDeltaChart(svgId){
|
| 612 |
+
const svg=document.getElementById(svgId);
|
| 613 |
+
const data=timelineHistory;
|
| 614 |
+
if(!svg)return;
|
| 615 |
+
const W=360,H=112,pL=48,pR=10,pT=10,pB=28;
|
| 616 |
+
const plotW=W-pL-pR,plotH=H-pT-pB;
|
| 617 |
+
if(data.length<2){
|
| 618 |
+
svg.innerHTML=`<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">Need 2+ steps</text>`;
|
| 619 |
+
return;
|
| 620 |
+
}
|
| 621 |
+
const dlt=data.map((d,i)=>i===0?0:d.followers-data[i-1].followers);
|
| 622 |
+
const maxA=Math.max(...dlt.map(a=>Math.abs(a)),1);
|
| 623 |
+
const midY=pT+plotH/2;
|
| 624 |
+
const amp=(plotH/2-4);
|
| 625 |
+
const n=data.length;
|
| 626 |
+
const pts=dlt.map((dv,i)=>{
|
| 627 |
+
const x=pL+(n<=1?plotW/2:i/(n-1)*plotW);
|
| 628 |
+
const y=midY-(dv/maxA)*amp;
|
| 629 |
+
return {x,y};
|
| 630 |
+
});
|
| 631 |
+
const lineD=smoothPath(pts);
|
| 632 |
+
let h="";
|
| 633 |
+
h+=`<line x1="${pL}" y1="${midY}" x2="${W-pR}" y2="${midY}" stroke="#494454" stroke-width="0.6" opacity="0.45"/>`;
|
| 634 |
+
h+=`<text x="${pL-5}" y="${pT+8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">+${fmtAxisNum(maxA)}</text>`;
|
| 635 |
+
h+=`<text x="${pL-5}" y="${H-pB}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${fmtAxisNum(-maxA)}</text>`;
|
| 636 |
+
h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
|
| 637 |
+
h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="0.9"/>`;
|
| 638 |
+
h+=`<path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2"/>`;
|
| 639 |
+
const lastI=n-1;
|
| 640 |
+
h+=`<text x="${pL}" y="${H-8}" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">0</text>`;
|
| 641 |
+
h+=`<text x="${pL+plotW/2}" y="${H-8}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${Math.floor(lastI/2)}</text>`;
|
| 642 |
+
h+=`<text x="${W-pR}" y="${H-8}" text-anchor="end" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lastI}</text>`;
|
| 643 |
+
h+=`<text x="${pL+plotW/2}" y="${H-1}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif" opacity="0.75">step · Δ followers</text>`;
|
| 644 |
+
svg.innerHTML=h;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
function drawPostsByHour(svgId){
|
| 648 |
+
const svg=document.getElementById(svgId);
|
| 649 |
+
if(!svg)return;
|
| 650 |
+
const buckets=new Array(24).fill(0);
|
| 651 |
+
for(const p of timelineHistory){
|
| 652 |
+
if(p.action==="post")buckets[p.hour]++;
|
| 653 |
+
}
|
| 654 |
+
const postN=buckets.reduce((a,b)=>a+b,0);
|
| 655 |
+
if(!postN){
|
| 656 |
+
svg.innerHTML='<text x="160" y="40" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No posts yet — histogram fills when you post</text>';
|
| 657 |
+
return;
|
| 658 |
+
}
|
| 659 |
+
const max=Math.max(...buckets,1);
|
| 660 |
+
const W=320,H=64,pL=16,pR=4,pT=4,pB=16;
|
| 661 |
+
const slot=(W-pL-pR)/24;
|
| 662 |
+
const bw=slot*0.72;
|
| 663 |
+
let rects="";
|
| 664 |
+
for(let h=0;h<24;h++){
|
| 665 |
+
const bh=(buckets[h]/max)*(H-pT-pB);
|
| 666 |
+
const x=pL+h*slot+(slot-bw)/2;
|
| 667 |
+
const y=H-pB-Math.max(bh,0.5);
|
| 668 |
+
rects+=`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${bw.toFixed(2)}" height="${Math.max(bh,0.5).toFixed(2)}" fill="#d0bcff" rx="1"/>`;
|
| 669 |
+
}
|
| 670 |
+
let labels="";
|
| 671 |
+
for(let h=0;h<24;h+=6){
|
| 672 |
+
labels+=`<text x="${(pL+h*slot+bw/2).toFixed(1)}" y="${H-3}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${h}h</text>`;
|
| 673 |
+
}
|
| 674 |
+
svg.innerHTML=rects+labels;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
function drawActionMix(svgId){
|
| 678 |
+
const svg=document.getElementById(svgId);
|
| 679 |
+
if(!svg)return;
|
| 680 |
+
if(!timelineHistory.length){
|
| 681 |
+
svg.innerHTML='<text x="160" y="28" text-anchor="middle" fill="#958ea0" font-size="10" font-family="Space Grotesk,sans-serif">No steps yet</text>';
|
| 682 |
+
return;
|
| 683 |
+
}
|
| 684 |
+
let r=0,c=0,p=0;
|
| 685 |
+
for(const x of timelineHistory){
|
| 686 |
+
if(x.action==="rest")r++;
|
| 687 |
+
else if(x.action==="create_content")c++;
|
| 688 |
+
else if(x.action==="post")p++;
|
| 689 |
+
}
|
| 690 |
+
const W=320,H=44,pT=6,pB=4;
|
| 691 |
+
const labels=[["Rest",r,"#ffb2b9"],["Create",c,"#7bd0ff"],["Post",p,"#d0bcff"]];
|
| 692 |
+
const max=Math.max(r,c,p,1);
|
| 693 |
+
const bw=90;
|
| 694 |
+
let out="";
|
| 695 |
+
labels.forEach(([lab,n,col],i)=>{
|
| 696 |
+
const x=20+i*100;
|
| 697 |
+
const bh=(n/max)*(H-pT-pB);
|
| 698 |
+
const y=H-pB-bh;
|
| 699 |
+
out+=`<rect x="${x}" y="${y}" width="${bw}" height="${Math.max(bh,2)}" fill="${col}" rx="2"/>`;
|
| 700 |
+
out+=`<text x="${x+bw/2}" y="${H+2}" text-anchor="middle" fill="#958ea0" font-size="8" font-family="Space Grotesk,sans-serif">${lab} ${n}</text>`;
|
| 701 |
+
});
|
| 702 |
+
svg.innerHTML=out;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
async function doReset(){
|
| 706 |
+
setStatus("Resetting...");
|
| 707 |
+
const task=document.getElementById("taskSelect").value;
|
| 708 |
+
energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;actionLog.length=0;timelineHistory.length=0;totalPostsCount=0;
|
| 709 |
+
try{
|
| 710 |
+
const r=await fetch(API+"/dashboard/reset",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({task})});
|
| 711 |
+
const d=await r.json();
|
| 712 |
+
updateUI(d);
|
| 713 |
+
document.getElementById("historyLog").innerHTML='<div class="text-secondary font-bold">Environment reset — task: '+task+'</div>';
|
| 714 |
+
document.getElementById("graderCard").classList.add("hidden");
|
| 715 |
+
document.getElementById("engagementChart").innerHTML="";
|
| 716 |
+
document.getElementById("followerChart").innerHTML="";
|
| 717 |
+
document.getElementById("recentActions").innerHTML='<div class="text-on-surface-dim italic text-[10px]">No actions yet</div>';
|
| 718 |
+
drawBurnoutMeter(1);
|
| 719 |
+
setStatus("Running");
|
| 720 |
+
}catch(e){setStatus("Error: "+e.message)}
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
async function doAction(type){
|
| 724 |
+
setStatus("Stepping...");
|
| 725 |
+
try{
|
| 726 |
+
const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:{action_type:type}})});
|
| 727 |
+
const d=await r.json();
|
| 728 |
+
updateUI(d,{actionType:type});
|
| 729 |
+
addLog(type+"()",d.reward,d.done,d.observation?.error);
|
| 730 |
+
}catch(e){setStatus("Error: "+e.message)}
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
async function doPost(){
|
| 734 |
+
const ct=document.getElementById("contentType").value;
|
| 735 |
+
const topic=document.getElementById("topicInput").value.trim();
|
| 736 |
+
const tagsRaw=document.getElementById("tagsInput").value.trim();
|
| 737 |
+
const tags=tagsRaw?tagsRaw.split(",").map(t=>t.trim()).filter(Boolean):[];
|
| 738 |
+
if(!topic){alert("Enter a topic");return}
|
| 739 |
+
setStatus("Stepping...");
|
| 740 |
+
try{
|
| 741 |
+
const body={action:{action_type:"post",content_type:ct,topic,tags:tags.length?tags:undefined}};
|
| 742 |
+
const r=await fetch(API+"/dashboard/step",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
| 743 |
+
const d=await r.json();
|
| 744 |
+
updateUI(d,{actionType:"post"});
|
| 745 |
+
addLog(`post(${ct},"${topic}",[${tags.join(",")}])`,d.reward,d.done,d.observation?.error);
|
| 746 |
+
hidePostForm();
|
| 747 |
+
}catch(e){setStatus("Error: "+e.message)}
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
function updateUI(d, opts={}){
|
| 751 |
+
const o=d.observation||d;
|
| 752 |
+
currentObs=o;
|
| 753 |
+
recordTimelineFromObs(d, opts.actionType);
|
| 754 |
+
const energy=o.creator_energy??1;
|
| 755 |
+
const followers=o.follower_count??0;
|
| 756 |
+
const eng=o.engagement_rate??0;
|
| 757 |
+
const sat=o.niche_saturation??0;
|
| 758 |
+
const compAvg=o.competitor_avg_engagement??0;
|
| 759 |
+
const reward=d.reward??0;
|
| 760 |
+
|
| 761 |
+
document.getElementById("energyVal").textContent=energy.toFixed(2);
|
| 762 |
+
document.getElementById("energyBar").style.width=(energy*100)+"%";
|
| 763 |
+
const eHint=document.getElementById("energyHint");
|
| 764 |
+
if(energy<=0){eHint.textContent="BURNED OUT";eHint.className="mt-1.5 text-[9px] font-label text-error"}
|
| 765 |
+
else if(energy<0.3){eHint.textContent="CRITICAL";eHint.className="mt-1.5 text-[9px] font-label text-tertiary-ctr"}
|
| 766 |
+
else if(energy<0.5){eHint.textContent="LOW — REST NOW";eHint.className="mt-1.5 text-[9px] font-label text-tertiary"}
|
| 767 |
+
else if(energy<0.8){eHint.textContent="MODERATE";eHint.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
|
| 768 |
+
else{eHint.textContent="FULL";eHint.className="mt-1.5 text-[9px] font-label text-secondary"}
|
| 769 |
+
|
| 770 |
+
document.getElementById("followersVal").textContent=followers.toLocaleString();
|
| 771 |
+
const delta=followers-10000;
|
| 772 |
+
const dEl=document.getElementById("followersDelta");
|
| 773 |
+
dEl.textContent=(delta>=0?"+":"")+delta+" since start";
|
| 774 |
+
dEl.className="mt-1.5 text-[9px] font-label "+(delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim");
|
| 775 |
+
|
| 776 |
+
document.getElementById("engVal").textContent=eng.toFixed(3);
|
| 777 |
+
const diff=eng-compAvg;
|
| 778 |
+
const evc=document.getElementById("engVsComp");
|
| 779 |
+
evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
|
| 780 |
+
evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
|
| 781 |
+
|
| 782 |
+
document.getElementById("timeVal").textContent=(o.current_hour??0)+":00";
|
| 783 |
+
document.getElementById("dayVal").textContent=DAYS[o.day_of_week??0];
|
| 784 |
+
document.getElementById("postsVal").textContent=o.posts_today??0;
|
| 785 |
+
document.getElementById("queueVal").textContent=o.content_queue_size??0;
|
| 786 |
+
document.getElementById("satVal").textContent=sat.toFixed(2);
|
| 787 |
+
const sH=document.getElementById("satHint");
|
| 788 |
+
if(sat>0.7){sH.textContent="HIGH — diversify topics";sH.className="mt-1.5 text-[9px] font-label text-tertiary"}
|
| 789 |
+
else if(sat>0.4){sH.textContent="MEDIUM — some room";sH.className="mt-1.5 text-[9px] font-label text-on-surface-dim"}
|
| 790 |
+
else{sH.textContent="LOW — post unique topics";sH.className="mt-1.5 text-[9px] font-label text-primary"}
|
| 791 |
+
document.getElementById("stepNum").textContent=o.metadata?.step??0;
|
| 792 |
+
|
| 793 |
+
// Charts
|
| 794 |
+
energyHistory.push(energy);
|
| 795 |
+
rewardHistory.push(reward);
|
| 796 |
+
followerHistory.push(followers);
|
| 797 |
+
drawEngagementChart();
|
| 798 |
+
drawBurnoutMeter(energy);
|
| 799 |
+
drawFollowerBars();
|
| 800 |
+
updateBottomStats();
|
| 801 |
+
if(d.action_type||d.observation?.metadata)addRecentAction(d);
|
| 802 |
+
|
| 803 |
+
// Trending
|
| 804 |
+
const tt=document.getElementById("trendTopics");
|
| 805 |
+
tt.innerHTML=(o.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
|
| 806 |
+
const tg=document.getElementById("trendTags");
|
| 807 |
+
tg.innerHTML=(o.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
|
| 808 |
+
|
| 809 |
+
// Tag perf — sidebar panel
|
| 810 |
+
const tp=document.getElementById("tagPerf");
|
| 811 |
+
const perf=o.tag_performance||{};
|
| 812 |
+
const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
|
| 813 |
+
if(entries.length){
|
| 814 |
+
const maxV=Math.max(...entries.map(e=>e[1]),0.01);
|
| 815 |
+
tp.innerHTML=entries.slice(0,6).map(([tag,val],i)=>{
|
| 816 |
+
const w=Math.min(100,(val/maxV)*100);
|
| 817 |
+
const c=i%2===0?"primary":"secondary";
|
| 818 |
+
return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
|
| 819 |
+
}).join("");
|
| 820 |
+
}else{tp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
|
| 821 |
+
|
| 822 |
+
// Top tags styled list
|
| 823 |
+
const ttl=document.getElementById("topTagsList");
|
| 824 |
+
const colors=["secondary","primary","tertiary","on-surface-dim"];
|
| 825 |
+
if(entries.length){
|
| 826 |
+
ttl.innerHTML=entries.slice(0,4).map(([tag,val],i)=>{
|
| 827 |
+
const c=colors[i%colors.length];
|
| 828 |
+
const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
|
| 829 |
+
return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
|
| 830 |
+
}).join("");
|
| 831 |
+
}else{ttl.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No tag data yet</div>'}
|
| 832 |
+
|
| 833 |
+
// Competitors
|
| 834 |
+
document.getElementById("compEng").textContent=compAvg.toFixed(3);
|
| 835 |
+
const cp=document.getElementById("compPosts");
|
| 836 |
+
const posts=o.competitor_recent_posts||[];
|
| 837 |
+
if(posts.length){
|
| 838 |
+
const icons={reel:"movie",carousel:"view_carousel",story:"auto_stories",text_post:"article"};
|
| 839 |
+
cp.innerHTML=posts.slice(0,4).map(p=>`<div class="p-2.5 rounded-lg bg-surface border border-outline/15 flex items-start gap-2.5"><span class="material-symbols-outlined text-tertiary/40 text-lg mt-0.5">${icons[p.content_type]||"article"}</span><div class="flex-1 min-w-0"><div class="flex justify-between text-[10px]"><span class="font-bold text-on-surface truncate">${p.topic||"—"}</span><span class="text-on-surface-dim shrink-0 ml-2">${p.hours_ago}h</span></div><div class="text-[9px] text-on-surface-dim mt-0.5">${p.content_type} · eng: <span class="text-tertiary">${(p.engagement??0).toFixed(3)}</span></div></div></div>`).join("");
|
| 840 |
+
}else{cp.innerHTML='<div class="text-on-surface-dim italic text-[10px]">No competitor posts yet</div>'}
|
| 841 |
+
|
| 842 |
+
// Done state
|
| 843 |
+
if(d.done){
|
| 844 |
+
setStatus("Episode Done");
|
| 845 |
+
document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=true;b.classList.add("opacity-30","pointer-events-none")});
|
| 846 |
+
const score=o.metadata?.grader_score;
|
| 847 |
+
if(score!=null){
|
| 848 |
+
const gc=document.getElementById("graderCard");
|
| 849 |
+
gc.classList.remove("hidden");
|
| 850 |
+
document.getElementById("graderScore").textContent=score.toFixed(4);
|
| 851 |
+
const lbl=document.getElementById("graderLabel");
|
| 852 |
+
if(score>=0.7)lbl.textContent="Excellent performance!";
|
| 853 |
+
else if(score>=0.4)lbl.textContent="Decent strategy, room for improvement";
|
| 854 |
+
else lbl.textContent="Poor performance — agent needs better strategy";
|
| 855 |
+
}
|
| 856 |
+
}else{
|
| 857 |
+
document.querySelectorAll("#postBtn,.action-btn").forEach(b=>{b.disabled=false;b.classList.remove("opacity-30","pointer-events-none")});
|
| 858 |
+
setStatus("Running");
|
| 859 |
+
}
|
| 860 |
+
redrawTimelineCharts();
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
function smoothPath(pts){
|
| 864 |
+
if(pts.length<2)return pts.map((p,i)=>(i===0?"M":"L")+p.x.toFixed(1)+","+p.y.toFixed(1)).join(" ");
|
| 865 |
+
let d="M"+pts[0].x.toFixed(1)+","+pts[0].y.toFixed(1);
|
| 866 |
+
for(let i=1;i<pts.length;i++){
|
| 867 |
+
const cp=(pts[i].x-pts[i-1].x)/3;
|
| 868 |
+
d+=` C${(pts[i-1].x+cp).toFixed(1)},${pts[i-1].y.toFixed(1)} ${(pts[i].x-cp).toFixed(1)},${pts[i].y.toFixed(1)} ${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)}`;
|
| 869 |
+
}
|
| 870 |
+
return d;
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
function drawEngagementChart(){
|
| 874 |
+
const svg=document.getElementById("engagementChart");
|
| 875 |
+
const data=rewardHistory;
|
| 876 |
+
if(!svg||!data.length)return;
|
| 877 |
+
const W=760,H=200,pL=56,pR=14,pT=12,pB=40;
|
| 878 |
+
const plotW=W-pL-pR,plotH=H-pT-pB;
|
| 879 |
+
const minR=Math.min(0,Math.min(...data));
|
| 880 |
+
const maxR=Math.max(...data,0.01);
|
| 881 |
+
const span=Math.max(maxR-minR,1e-6)*1.08;
|
| 882 |
+
const y0=minR;
|
| 883 |
+
const pts=data.map((v,i)=>({
|
| 884 |
+
x:pL+(i/Math.max(data.length-1,1))*plotW,
|
| 885 |
+
y:pT+(1-(v-y0)/span)*plotH,
|
| 886 |
+
}));
|
| 887 |
+
const lineD=smoothPath(pts);
|
| 888 |
+
const areaD=lineD+` L${pts[pts.length-1].x.toFixed(1)},${(H-pB).toFixed(1)} L${pts[0].x.toFixed(1)},${(H-pB).toFixed(1)} Z`;
|
| 889 |
+
const gid="eng_reward_grad";
|
| 890 |
+
let h="";
|
| 891 |
+
for(let g=0;g<=4;g++){
|
| 892 |
+
const y=pT+(g/4)*plotH;
|
| 893 |
+
const val=y0+(1-g/4)*span;
|
| 894 |
+
h+=`<line x1="${pL}" y1="${y}" x2="${W-pR}" y2="${y}" stroke="#494454" stroke-width="0.5" opacity="0.35"/>`;
|
| 895 |
+
h+=`<text x="${pL-6}" y="${y+3}" text-anchor="end" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">${val.toFixed(2)}</text>`;
|
| 896 |
+
}
|
| 897 |
+
h+=`<line x1="${pL}" y1="${pT}" x2="${pL}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
|
| 898 |
+
h+=`<line x1="${pL}" y1="${H-pB}" x2="${W-pR}" y2="${H-pB}" stroke="#cbc3d7" stroke-width="1"/>`;
|
| 899 |
+
h+=`<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7bd0ff" stop-opacity="0.28"/><stop offset="1" stop-color="#7bd0ff" stop-opacity="0"/></linearGradient></defs>`;
|
| 900 |
+
h+=`<path d="${areaD}" fill="url(#${gid})"/><path d="${lineD}" fill="none" stroke="#7bd0ff" stroke-width="2.5"/>`;
|
| 901 |
+
const lastI=data.length-1;
|
| 902 |
+
h+=`<text x="${pL}" y="${H-18}" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step 0</text>`;
|
| 903 |
+
h+=`<text x="${pL+plotW/2}" y="${H-18}" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step ${Math.floor(lastI/2)}</text>`;
|
| 904 |
+
h+=`<text x="${W-pR}" y="${H-18}" text-anchor="end" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif">step ${lastI}</text>`;
|
| 905 |
+
h+=`<text x="${pL+plotW/2}" y="${H-4}" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif" opacity="0.85">simulation step index</text>`;
|
| 906 |
+
h+=`<text x="12" y="${pT+plotH/2}" transform="rotate(-90 12 ${pT+plotH/2})" text-anchor="middle" fill="#958ea0" font-size="9" font-family="Space Grotesk,sans-serif" opacity="0.85">reward</text>`;
|
| 907 |
+
svg.innerHTML=h;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
function drawBurnoutMeter(energy){
|
| 911 |
+
const burnout=Math.round((1-energy)*100);
|
| 912 |
+
const circ=2*Math.PI*50;
|
| 913 |
+
const fill=(burnout/100)*circ;
|
| 914 |
+
document.getElementById("burnoutArc").setAttribute("stroke-dasharray",fill.toFixed(1)+" "+circ.toFixed(1));
|
| 915 |
+
document.getElementById("burnoutPct").textContent=burnout+"%";
|
| 916 |
+
const rec=document.getElementById("burnoutRec");
|
| 917 |
+
if(burnout>=70)rec.textContent="Recommendation: Limit posting for 45 mins to prevent creative fatigue.";
|
| 918 |
+
else if(burnout>=40)rec.textContent="Recommendation: Alternate between creating and resting to maintain output quality.";
|
| 919 |
+
else rec.textContent="Recommendation: Energy levels healthy. Good window for high-effort content.";
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
function drawFollowerBars(){
|
| 923 |
+
const svg=document.getElementById("followerChart");
|
| 924 |
+
const data=followerHistory;
|
| 925 |
+
if(data.length<2){svg.innerHTML="";return}
|
| 926 |
+
const W=300,H=120,pL=40,pR=8,pT=6,pB=22,plotW=W-pL-pR,plotH=H-pT-pB;
|
| 927 |
+
const chunks=Math.min(data.length,7);
|
| 928 |
+
const chunkSize=Math.max(1,Math.floor(data.length/chunks));
|
| 929 |
+
const bars=[];
|
| 930 |
+
for(let i=0;i<chunks;i++){
|
| 931 |
+
const start=i*chunkSize;
|
| 932 |
+
const end=Math.min(start+chunkSize,data.length);
|
| 933 |
+
const avg=data.slice(start,end).reduce((a,b)=>a+b,0)/(end-start);
|
| 934 |
+
bars.push(avg);
|
| 935 |
+
}
|
| 936 |
+
const fMin=Math.min(...bars),fMax=Math.max(...bars);
|
| 937 |
+
const base=fMin*0.998;
|
| 938 |
+
const maxDelta=Math.max(...bars.map(b=>b-base),1);
|
| 939 |
+
const barW=plotW/bars.length*0.58;
|
| 940 |
+
const gap=plotW/bars.length*0.42;
|
| 941 |
+
let html="";
|
| 942 |
+
html+=`<text x="4" y="${pT+10}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMax)}</text>`;
|
| 943 |
+
html+=`<text x="4" y="${pT+plotH}" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${Math.round(fMin)}</text>`;
|
| 944 |
+
html+=`<text transform="rotate(-90 14 ${pT+plotH/2})" x="14" y="${pT+plotH/2}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">followers</text>`;
|
| 945 |
+
bars.forEach((v,i)=>{
|
| 946 |
+
const h=Math.max(4,((v-base)/maxDelta)*plotH);
|
| 947 |
+
const x=pL+i*(plotW/bars.length)+(gap/2);
|
| 948 |
+
const y=pT+plotH-h;
|
| 949 |
+
const opacity=0.5+0.5*(i/bars.length);
|
| 950 |
+
html+=`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="3" fill="#7bd0ff" opacity="${opacity.toFixed(2)}"/>`;
|
| 951 |
+
html+=`<text x="${(x+barW/2).toFixed(1)}" y="${H-4}" text-anchor="middle" fill="#958ea0" font-size="7" font-family="Space Grotesk,sans-serif">${DAYS[i%7]}</text>`;
|
| 952 |
+
});
|
| 953 |
+
svg.innerHTML=html;
|
| 954 |
+
const delta=data[data.length-1]-data[0];
|
| 955 |
+
const pct=((delta/data[0])*100);
|
| 956 |
+
document.getElementById("followerTotal").textContent=(delta>=0?"+":"")+Math.round(delta).toLocaleString();
|
| 957 |
+
document.getElementById("followerDeltaPct").textContent=(pct>=0?"+":"")+pct.toFixed(0)+"% vs start";
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
function updateBottomStats(){
|
| 961 |
+
if(rewardHistory.length){
|
| 962 |
+
const avg=rewardHistory.reduce((a,b)=>a+b,0)/rewardHistory.length;
|
| 963 |
+
document.getElementById("bottomAvgReward").textContent=avg.toFixed(2);
|
| 964 |
+
if(rewardHistory.length>10){
|
| 965 |
+
const recent=rewardHistory.slice(-10).reduce((a,b)=>a+b,0)/10;
|
| 966 |
+
const old=rewardHistory.slice(0,10).reduce((a,b)=>a+b,0)/Math.min(10,rewardHistory.length);
|
| 967 |
+
const d=((recent-old)/Math.max(Math.abs(old),0.001)*100);
|
| 968 |
+
document.getElementById("bottomAvgDelta").textContent=(d>=0?"+":"")+d.toFixed(0)+"%";
|
| 969 |
+
document.getElementById("bottomAvgDelta").className="text-[10px] font-label mt-1 "+(d>=0?"text-secondary":"text-tertiary");
|
| 970 |
+
}
|
| 971 |
+
}
|
| 972 |
+
document.getElementById("bottomTotalPosts").textContent=totalPostsCount;
|
| 973 |
+
const eng=currentObs?.engagement_rate??0;
|
| 974 |
+
const viral=Math.min(100,Math.round(eng*1000));
|
| 975 |
+
const label=viral>=70?"HIGH":viral>=40?"MEDIUM":"LOW";
|
| 976 |
+
document.getElementById("bottomViralProb").textContent=label+" ("+viral+"%)";
|
| 977 |
+
const vn=document.getElementById("viralFormulaNote");
|
| 978 |
+
if(vn)vn.textContent="min(100, round("+eng.toFixed(3)+" × 1000)) = "+viral+" — labels LOW/MED/HIGH at 40 and 70 (display only).";
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
function addRecentAction(d){
|
| 982 |
+
const el=document.getElementById("recentActions");
|
| 983 |
+
const step=currentObs?.metadata?.step??0;
|
| 984 |
+
const reward=d.reward??0;
|
| 985 |
+
const icons={rest:"hotel",create_content:"edit_note",post:"send"};
|
| 986 |
+
const colors={rest:"tertiary",create_content:"secondary",post:"primary"};
|
| 987 |
+
const action=d.action_type||d.observation?.last_action||"step";
|
| 988 |
+
const icon=icons[action]||"play_arrow";
|
| 989 |
+
const c=colors[action]||"on-surface-dim";
|
| 990 |
+
const entry=`<div class="flex items-start gap-2.5 fade-in"><span class="material-symbols-outlined text-${c} text-lg mt-0.5 shrink-0">${icon}</span><div class="flex-1 min-w-0"><div class="text-xs font-bold text-on-surface truncate">${action.replace("_"," ")}</div><div class="text-[9px] text-on-surface-dim">${step} steps ago · r=${reward.toFixed(2)}</div></div></div>`;
|
| 991 |
+
if(el.querySelector(".italic"))el.innerHTML="";
|
| 992 |
+
el.innerHTML=entry+el.innerHTML;
|
| 993 |
+
if(el.children.length>8)el.removeChild(el.lastChild);
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
function addLog(action,reward,done,error){
|
| 997 |
+
if(action.startsWith("post"))totalPostsCount++;
|
| 998 |
+
const step=currentObs?.metadata?.step??0;
|
| 999 |
+
const log=document.getElementById("historyLog");
|
| 1000 |
+
const errStr=error?` <span class="text-error">err=${error}</span>`:"";
|
| 1001 |
+
const color=reward>0.5?"text-secondary":reward>0.2?"text-primary":"text-on-surface-dim";
|
| 1002 |
+
const doneStr=done?'<span class="text-tertiary font-bold"> DONE</span>':"";
|
| 1003 |
+
log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[${step}]</span> <span class="text-on-surface">${action}</span> <span class="${color}">r=${(reward??0).toFixed(2)}</span>${doneStr}${errStr}</div>`;
|
| 1004 |
+
log.scrollTop=log.scrollHeight;
|
| 1005 |
+
document.getElementById("rewardBadge").textContent="Last reward: "+(reward??0).toFixed(2);
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
let simRunning=false;
|
| 1009 |
+
async function runSim(scenario){
|
| 1010 |
+
if(simRunning)return;
|
| 1011 |
+
simRunning=true;
|
| 1012 |
+
const task=document.getElementById("taskSelect").value;
|
| 1013 |
+
document.querySelectorAll(".sim-btn").forEach(b=>b.classList.add("opacity-30","pointer-events-none"));
|
| 1014 |
+
document.getElementById("simProgress").classList.remove("hidden");
|
| 1015 |
+
document.getElementById("simResult").classList.add("hidden");
|
| 1016 |
+
document.getElementById("simBar").style.width="0%";
|
| 1017 |
+
document.getElementById("simPct").textContent="0%";
|
| 1018 |
+
document.getElementById("graderCard").classList.add("hidden");
|
| 1019 |
+
energyHistory.length=0;rewardHistory.length=0;followerHistory.length=0;timelineHistory.length=0;totalPostsCount=0;
|
| 1020 |
+
setStatus("Simulating...");
|
| 1021 |
+
|
| 1022 |
+
try{
|
| 1023 |
+
const r=await fetch(API+"/dashboard/simulate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({scenario,task})});
|
| 1024 |
+
const d=await r.json();
|
| 1025 |
+
if(d.error){setStatus("Error: "+d.error);simRunning=false;return}
|
| 1026 |
+
|
| 1027 |
+
const log=document.getElementById("historyLog");
|
| 1028 |
+
log.innerHTML=`<div class="text-secondary font-bold mb-1">Sim: ${d.scenario} — ${task}</div><div class="text-on-surface-dim text-[9px] mb-2">${d.description}</div>`;
|
| 1029 |
+
|
| 1030 |
+
const total=d.steps.length;
|
| 1031 |
+
for(let i=0;i<total;i++){
|
| 1032 |
+
const s=d.steps[i];
|
| 1033 |
+
rewardHistory.push(s.reward);
|
| 1034 |
+
energyHistory.push(s.energy);
|
| 1035 |
+
followerHistory.push(s.followers);
|
| 1036 |
+
timelineHistory.push({
|
| 1037 |
+
step:s.step,
|
| 1038 |
+
simHour:(s.days_elapsed??0)*24+(s.hour??0),
|
| 1039 |
+
hour:s.hour??0,
|
| 1040 |
+
day:s.day??0,
|
| 1041 |
+
energy:s.energy,
|
| 1042 |
+
followers:s.followers,
|
| 1043 |
+
engagement:s.engagement_rate,
|
| 1044 |
+
reward:s.reward,
|
| 1045 |
+
sat:s.niche_saturation,
|
| 1046 |
+
queue:s.queue,
|
| 1047 |
+
postsToday:s.posts_today,
|
| 1048 |
+
compAvg:s.competitor_avg_engagement,
|
| 1049 |
+
sleepDebt:s.sleep_debt??0,
|
| 1050 |
+
hoursSinceSleep:s.hours_since_sleep??0,
|
| 1051 |
+
action:simActionType(s.action),
|
| 1052 |
+
});
|
| 1053 |
+
if(s.action.startsWith("post"))totalPostsCount++;
|
| 1054 |
+
|
| 1055 |
+
const pct=Math.round((i+1)/total*100);
|
| 1056 |
+
document.getElementById("simBar").style.width=pct+"%";
|
| 1057 |
+
document.getElementById("simPct").textContent=pct+"%";
|
| 1058 |
+
|
| 1059 |
+
// Live stat card updates every 5 steps
|
| 1060 |
+
if(i%5===0||i===total-1){
|
| 1061 |
+
document.getElementById("energyVal").textContent=s.energy.toFixed(2);
|
| 1062 |
+
document.getElementById("energyBar").style.width=(s.energy*100)+"%";
|
| 1063 |
+
document.getElementById("followersVal").textContent=s.followers.toLocaleString();
|
| 1064 |
+
document.getElementById("engVal").textContent=s.engagement_rate.toFixed(3);
|
| 1065 |
+
document.getElementById("stepNum").textContent=s.step;
|
| 1066 |
+
document.getElementById("timeVal").textContent=s.hour+":00";
|
| 1067 |
+
document.getElementById("dayVal").textContent=DAYS[s.day];
|
| 1068 |
+
document.getElementById("postsVal").textContent=s.posts_today;
|
| 1069 |
+
document.getElementById("queueVal").textContent=s.queue;
|
| 1070 |
+
document.getElementById("satVal").textContent=s.niche_saturation.toFixed(2);
|
| 1071 |
+
document.getElementById("compEng").textContent=s.competitor_avg_engagement.toFixed(3);
|
| 1072 |
+
const diff=s.engagement_rate-s.competitor_avg_engagement;
|
| 1073 |
+
const evc=document.getElementById("engVsComp");
|
| 1074 |
+
evc.textContent="vs competitors: "+(diff>=0?"+":"")+diff.toFixed(3);
|
| 1075 |
+
evc.className="mt-1.5 text-[9px] font-label "+(diff>0?"text-secondary":"text-tertiary");
|
| 1076 |
+
const fdelta=s.followers-10000;
|
| 1077 |
+
const fdEl=document.getElementById("followersDelta");
|
| 1078 |
+
fdEl.textContent=(fdelta>=0?"+":"")+fdelta+" since start";
|
| 1079 |
+
fdEl.className="mt-1.5 text-[9px] font-label "+(fdelta>0?"text-secondary":fdelta<0?"text-tertiary":"text-on-surface-dim");
|
| 1080 |
+
|
| 1081 |
+
drawEngagementChart();
|
| 1082 |
+
drawBurnoutMeter(s.energy);
|
| 1083 |
+
drawFollowerBars();
|
| 1084 |
+
updateBottomStats();
|
| 1085 |
+
redrawTimelineCharts();
|
| 1086 |
+
|
| 1087 |
+
// Update trending
|
| 1088 |
+
const tt=document.getElementById("trendTopics");
|
| 1089 |
+
tt.innerHTML=(s.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
|
| 1090 |
+
const tg=document.getElementById("trendTags");
|
| 1091 |
+
tg.innerHTML=(s.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
|
| 1092 |
+
|
| 1093 |
+
// Update tag perf
|
| 1094 |
+
const perf=s.tag_performance||{};
|
| 1095 |
+
const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
|
| 1096 |
+
const tp=document.getElementById("tagPerf");
|
| 1097 |
+
if(entries.length){
|
| 1098 |
+
const maxV=Math.max(...entries.map(e=>e[1]),0.01);
|
| 1099 |
+
tp.innerHTML=entries.slice(0,6).map(([tag,val],j)=>{
|
| 1100 |
+
const c=j%2===0?"primary":"secondary";
|
| 1101 |
+
const w=Math.min(100,(val/maxV)*100);
|
| 1102 |
+
return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
|
| 1103 |
+
}).join("");
|
| 1104 |
+
}
|
| 1105 |
+
const ttl=document.getElementById("topTagsList");
|
| 1106 |
+
const colors=["secondary","primary","tertiary","on-surface-dim"];
|
| 1107 |
+
if(entries.length){
|
| 1108 |
+
ttl.innerHTML=entries.slice(0,4).map(([tag,val],j)=>{
|
| 1109 |
+
const c=colors[j%colors.length];
|
| 1110 |
+
const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
|
| 1111 |
+
return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
|
| 1112 |
+
}).join("");
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
await new Promise(r=>setTimeout(r,12));
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
const color=s.reward>0.5?"text-secondary":s.reward>0.2?"text-primary":"text-on-surface-dim";
|
| 1119 |
+
const err=s.error?` <span class="text-error">err=${s.error}</span>`:"";
|
| 1120 |
+
const dn=s.done?'<span class="text-tertiary font-bold"> DONE</span>':"";
|
| 1121 |
+
log.innerHTML+=`<div class="fade-in py-0.5"><span class="text-on-surface-dim/50">[${s.step}]</span> <span class="text-on-surface">${s.action}</span> <span class="${color}">r=${s.reward.toFixed(2)}</span>${dn}${err}</div>`;
|
| 1122 |
+
log.scrollTop=log.scrollHeight;
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
const f=d.final;
|
| 1126 |
+
const sc=d.score;
|
| 1127 |
+
redrawTimelineCharts();
|
| 1128 |
+
|
| 1129 |
+
// Final update of all panels using last step data
|
| 1130 |
+
const lastStep=d.steps[d.steps.length-1];
|
| 1131 |
+
if(lastStep){
|
| 1132 |
+
const tt=document.getElementById("trendTopics");
|
| 1133 |
+
tt.innerHTML=(lastStep.trending_topics||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-secondary/10 border border-secondary/15 text-secondary text-[10px] font-label">${t}</span>`).join("");
|
| 1134 |
+
const tg=document.getElementById("trendTags");
|
| 1135 |
+
tg.innerHTML=(lastStep.trending_tags||[]).map(t=>`<span class="px-2 py-1 rounded-lg bg-primary/10 border border-primary/15 text-primary text-[10px] font-label">#${t}</span>`).join("");
|
| 1136 |
+
|
| 1137 |
+
const perf=lastStep.tag_performance||{};
|
| 1138 |
+
const entries=Object.entries(perf).sort((a,b)=>b[1]-a[1]);
|
| 1139 |
+
const tp=document.getElementById("tagPerf");
|
| 1140 |
+
if(entries.length){
|
| 1141 |
+
const maxV=Math.max(...entries.map(e=>e[1]),0.01);
|
| 1142 |
+
tp.innerHTML=entries.slice(0,6).map(([tag,val],j)=>{
|
| 1143 |
+
const c=j%2===0?"primary":"secondary";
|
| 1144 |
+
const w=Math.min(100,(val/maxV)*100);
|
| 1145 |
+
return `<div><div class="flex justify-between font-label text-[10px]"><span class="text-on-surface">#${tag}</span><span class="text-${c}">${val.toFixed(3)}</span></div><div class="h-1.5 bg-surface-top rounded-full mt-1 overflow-hidden"><div class="h-full bg-gradient-to-r from-${c} to-${c}-ctr rounded-full" style="width:${w}%"></div></div></div>`;
|
| 1146 |
+
}).join("");
|
| 1147 |
+
}
|
| 1148 |
+
const ttl=document.getElementById("topTagsList");
|
| 1149 |
+
const colors=["secondary","primary","tertiary","on-surface-dim"];
|
| 1150 |
+
if(entries.length){
|
| 1151 |
+
ttl.innerHTML=entries.slice(0,4).map(([tag,val],j)=>{
|
| 1152 |
+
const c=colors[j%colors.length];
|
| 1153 |
+
const fmtVal=val>=1000?(val/1000).toFixed(1)+"k":val.toFixed(1);
|
| 1154 |
+
return `<div class="flex items-center justify-between"><div class="flex items-center gap-2.5"><span class="w-2 h-2 rounded-full bg-${c}"></span><span class="text-sm font-label text-on-surface">#${tag}</span></div><span class="text-sm font-bold font-label text-${c}">${fmtVal}</span></div>`;
|
| 1155 |
+
}).join("");
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
document.getElementById("compEng").textContent=lastStep.competitor_avg_engagement.toFixed(3);
|
| 1159 |
+
currentObs={engagement_rate:lastStep.engagement_rate,metadata:{}};
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
// Show grader card
|
| 1163 |
+
const gc=document.getElementById("graderCard");
|
| 1164 |
+
gc.classList.remove("hidden");
|
| 1165 |
+
document.getElementById("graderScore").textContent=sc.toFixed(4);
|
| 1166 |
+
const lbl=document.getElementById("graderLabel");
|
| 1167 |
+
if(sc>=0.7)lbl.textContent="Excellent performance!";
|
| 1168 |
+
else if(sc>=0.4)lbl.textContent="Decent strategy, room for improvement";
|
| 1169 |
+
else lbl.textContent="Poor performance — agent needs better strategy";
|
| 1170 |
+
|
| 1171 |
+
const res=document.getElementById("simResult");
|
| 1172 |
+
res.classList.remove("hidden");
|
| 1173 |
+
const scoreColor=sc>=0.7?"text-primary":sc>=0.3?"text-secondary":"text-tertiary";
|
| 1174 |
+
const scoreBg=sc>=0.7?"border-primary/30 bg-primary/5":sc>=0.3?"border-secondary/30 bg-secondary/5":"border-tertiary/30 bg-tertiary/5";
|
| 1175 |
+
res.innerHTML=`
|
| 1176 |
+
<div class="p-4 rounded-xl border ${scoreBg} space-y-2">
|
| 1177 |
+
<div class="flex justify-between items-center"><span class="text-[10px] font-label text-on-surface-dim uppercase tracking-widest">Grader Score</span><span class="text-3xl font-black ${scoreColor}">${sc.toFixed(4)}</span></div>
|
| 1178 |
+
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-[10px] font-label">
|
| 1179 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Steps</span><span>${d.total_steps}</span></div>
|
| 1180 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Burned Out</span><span class="${f.burned_out?"text-tertiary":"text-secondary"}">${f.burned_out?"YES":"NO"}</span></div>
|
| 1181 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Final Energy</span><span>${f.energy.toFixed(2)}</span></div>
|
| 1182 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Followers</span><span>${f.followers.toLocaleString()}</span></div>
|
| 1183 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Engagement</span><span>${f.engagement_rate.toFixed(4)}</span></div>
|
| 1184 |
+
<div class="flex justify-between"><span class="text-on-surface-dim">Total Posts</span><span>${totalPostsCount}</span></div>
|
| 1185 |
+
</div>
|
| 1186 |
+
</div>`;
|
| 1187 |
+
updateBottomStats();
|
| 1188 |
+
setStatus("Simulation Done");
|
| 1189 |
+
loadHistory();
|
| 1190 |
+
}catch(e){setStatus("Error: "+e.message)}
|
| 1191 |
+
document.querySelectorAll(".sim-btn").forEach(b=>b.classList.remove("opacity-30","pointer-events-none"));
|
| 1192 |
+
simRunning=false;
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
function showPostForm(){document.getElementById("postForm").classList.remove("hidden")}
|
| 1196 |
+
function hidePostForm(){document.getElementById("postForm").classList.add("hidden")}
|
| 1197 |
+
function setStatus(s){
|
| 1198 |
+
const el=document.getElementById("statusDot");
|
| 1199 |
+
const color=s.includes("Error")?"text-error":s==="Running"?"text-secondary":s.includes("Done")?"text-primary":"text-on-surface-dim";
|
| 1200 |
+
el.className="flex items-center gap-2 text-xs font-label "+color;
|
| 1201 |
+
el.innerHTML=`<span class="w-2 h-2 rounded-full ${color.replace("text-","bg-")}"></span>${s}`;
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
async function loadHistory(){
|
| 1205 |
+
try{
|
| 1206 |
+
const r=await fetch(API+"/dashboard/history");
|
| 1207 |
+
const data=await r.json();
|
| 1208 |
+
const tb=document.getElementById("historyTable");
|
| 1209 |
+
if(!data.length){tb.innerHTML='<tr><td colspan="10" class="px-4 py-6 text-center text-on-surface-dim italic">No history yet — run a simulation</td></tr>';return}
|
| 1210 |
+
const taskLabels={weekly_engage:"Easy",weekly_strategic:"Medium",weekly_competitive:"Hard"};
|
| 1211 |
+
tb.innerHTML=data.slice().reverse().map(h=>{
|
| 1212 |
+
const dt=new Date(h.id);
|
| 1213 |
+
const time=dt.toLocaleDateString("en-US",{month:"short",day:"numeric"})+' '+dt.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"});
|
| 1214 |
+
const f=h.final||{};
|
| 1215 |
+
const delta=f.followers-10000;
|
| 1216 |
+
const deltaStr=(delta>=0?"+":"")+delta.toLocaleString();
|
| 1217 |
+
const deltaClass=delta>0?"text-secondary":delta<0?"text-tertiary":"text-on-surface-dim";
|
| 1218 |
+
const scoreColor=h.score>=0.7?"text-primary":h.score>=0.3?"text-secondary":"text-tertiary";
|
| 1219 |
+
const status=f.burned_out?'<span class="text-tertiary font-bold">BURNED</span>':h.total_steps>=168?'<span class="text-secondary">DONE</span>':'<span class="text-on-surface-dim">PARTIAL</span>';
|
| 1220 |
+
const energyColor=f.energy>=0.5?"text-secondary":f.energy>0?"text-tertiary":"text-error";
|
| 1221 |
+
const desc=(h.description||"").trim();
|
| 1222 |
+
return `<tr class="border-b border-white/5 hover:bg-white/[.02] transition">
|
| 1223 |
+
<td class="px-4 py-2.5 text-on-surface-dim whitespace-nowrap">${time}</td>
|
| 1224 |
+
<td class="px-4 py-2.5 min-w-[14rem] max-w-lg align-top">
|
| 1225 |
+
<div class="text-on-surface font-bold">${_escapeHtml(h.scenario)}</div>
|
| 1226 |
+
${desc?`<div class="text-[10px] text-on-surface/75 mt-1 leading-relaxed whitespace-normal">${_escapeHtml(desc)}</div>`:""}
|
| 1227 |
+
</td>
|
| 1228 |
+
<td class="px-4 py-2.5 text-on-surface-dim">${taskLabels[h.task]||h.task}</td>
|
| 1229 |
+
<td class="px-4 py-2.5 text-right ${scoreColor} font-bold">${h.score.toFixed(4)}</td>
|
| 1230 |
+
<td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_steps}</td>
|
| 1231 |
+
<td class="px-4 py-2.5 text-right text-on-surface-dim">${h.total_posts}</td>
|
| 1232 |
+
<td class="px-4 py-2.5 text-right text-on-surface">${(f.followers||0).toLocaleString()}</td>
|
| 1233 |
+
<td class="px-4 py-2.5 text-right ${deltaClass}">${deltaStr}</td>
|
| 1234 |
+
<td class="px-4 py-2.5 text-right ${energyColor}">${(f.energy||0).toFixed(2)}</td>
|
| 1235 |
+
<td class="px-4 py-2.5 text-center">${status}</td>
|
| 1236 |
+
</tr>`;
|
| 1237 |
+
}).join("");
|
| 1238 |
+
}catch(e){console.error("History load failed",e)}
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
async function clearHistory(){
|
| 1242 |
+
if(!confirm("Clear all simulation history?"))return;
|
| 1243 |
+
await fetch(API+"/dashboard/history",{method:"DELETE"});
|
| 1244 |
+
loadHistory();
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
function _escapeHtml(t){
|
| 1248 |
+
const d=document.createElement("div");
|
| 1249 |
+
d.textContent=t??"";
|
| 1250 |
+
return d.innerHTML;
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
let _scenarioItems=[];
|
| 1254 |
+
|
| 1255 |
+
async function loadScenarioButtons(){
|
| 1256 |
+
const grid=document.getElementById("scenarioGrid");
|
| 1257 |
+
const countEl=document.getElementById("scenarioCount");
|
| 1258 |
+
const filterEl=document.getElementById("scenarioFilter");
|
| 1259 |
+
if(!grid)return;
|
| 1260 |
+
try{
|
| 1261 |
+
const r=await fetch(API+"/dashboard/scenarios",{cache:"no-store",headers:{"Cache-Control":"no-cache"}});
|
| 1262 |
+
const data=await r.json();
|
| 1263 |
+
_scenarioItems=data.scenarios||[];
|
| 1264 |
+
if(countEl)countEl.textContent=_scenarioItems.length+" strategies";
|
| 1265 |
+
const pin=new Set(["easy_morning_story","easy_one_a_day","easy_relaxed","medium_queue_cycle","medium_trend_rotate","medium_two_format","smart","balanced","high_freq","optimal_sleep","sleep_conscious","sleep_debt_aware"]);
|
| 1266 |
+
_scenarioItems.sort((a,b)=>{
|
| 1267 |
+
const pa=pin.has(a.id)?0:1,pb=pin.has(b.id)?0:1;
|
| 1268 |
+
if(pa!==pb)return pa-pb;
|
| 1269 |
+
return (a.label||"").localeCompare(b.label||"","en",{sensitivity:"base"});
|
| 1270 |
+
});
|
| 1271 |
+
function render(){
|
| 1272 |
+
const q=(filterEl&&filterEl.value||"").trim().toLowerCase();
|
| 1273 |
+
grid.innerHTML="";
|
| 1274 |
+
let n=0;
|
| 1275 |
+
for(const s of _scenarioItems){
|
| 1276 |
+
const lab=(s.label||"").toLowerCase();
|
| 1277 |
+
const id=(s.id||"").toLowerCase();
|
| 1278 |
+
const desc=(s.description||"").toLowerCase();
|
| 1279 |
+
if(q&&!(lab.includes(q)||id.includes(q)||desc.includes(q)))continue;
|
| 1280 |
+
n++;
|
| 1281 |
+
const btn=document.createElement("button");
|
| 1282 |
+
btn.type="button";
|
| 1283 |
+
btn.className="sim-btn p-2.5 rounded-lg bg-surface border border-outline/20 hover:border-secondary/40 text-left transition";
|
| 1284 |
+
if(pin.has(s.id))btn.classList.add("border-primary/25","hover:border-primary/55");
|
| 1285 |
+
btn.onclick=()=>runSim(s.id);
|
| 1286 |
+
btn.innerHTML=`<div class="text-xs font-bold text-on-surface leading-tight">${_escapeHtml(s.label)}</div><div class="text-[8px] text-on-surface-dim mt-0.5 line-clamp-2">${_escapeHtml(s.description)}</div>`;
|
| 1287 |
+
grid.appendChild(btn);
|
| 1288 |
+
}
|
| 1289 |
+
if(!n)grid.innerHTML='<div class="col-span-full text-on-surface-dim text-[10px] italic py-4 text-center">No strategies match your search.</div>';
|
| 1290 |
+
}
|
| 1291 |
+
if(filterEl)filterEl.oninput=render;
|
| 1292 |
+
render();
|
| 1293 |
+
}catch(e){
|
| 1294 |
+
console.error(e);
|
| 1295 |
+
grid.innerHTML='<div class="col-span-full text-error text-[10px] py-3">Could not load strategies. Refresh the page.</div>';
|
| 1296 |
+
if(countEl)countEl.textContent="";
|
| 1297 |
+
}
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
loadScenarioButtons();
|
| 1301 |
+
loadHistory();
|
| 1302 |
+
doReset();
|
| 1303 |
+
refreshTaskScoreBlurb();
|
| 1304 |
+
</script>
|
| 1305 |
+
</body>
|
| 1306 |
+
</html>
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv[core]>=0.2.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
server/simulation_history.json
ADDED
|
@@ -0,0 +1,1802 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "2026-04-05T10:50:54.850500+00:00",
|
| 4 |
+
"scenario": "Always Rest",
|
| 5 |
+
"scenario_id": "always_rest",
|
| 6 |
+
"task": "weekly_competitive",
|
| 7 |
+
"score": 0.035,
|
| 8 |
+
"total_steps": 168,
|
| 9 |
+
"total_posts": 0,
|
| 10 |
+
"avg_reward": 0.15,
|
| 11 |
+
"final": {
|
| 12 |
+
"energy": 1.0,
|
| 13 |
+
"hours_since_sleep": 1,
|
| 14 |
+
"sleep_debt": 0.0,
|
| 15 |
+
"followers": 5497,
|
| 16 |
+
"engagement_rate": 0.0,
|
| 17 |
+
"burned_out": false
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "2026-04-05T10:50:54.859097+00:00",
|
| 22 |
+
"scenario": "Anti-Trend",
|
| 23 |
+
"scenario_id": "anti_trend",
|
| 24 |
+
"task": "weekly_competitive",
|
| 25 |
+
"score": 0.2316,
|
| 26 |
+
"total_steps": 168,
|
| 27 |
+
"total_posts": 14,
|
| 28 |
+
"avg_reward": 0.2201,
|
| 29 |
+
"final": {
|
| 30 |
+
"energy": 1.0,
|
| 31 |
+
"hours_since_sleep": 1,
|
| 32 |
+
"sleep_debt": 0.0,
|
| 33 |
+
"followers": 11125,
|
| 34 |
+
"engagement_rate": 0.747,
|
| 35 |
+
"burned_out": false
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "2026-04-05T10:50:54.868624+00:00",
|
| 40 |
+
"scenario": "Bad Timing",
|
| 41 |
+
"scenario_id": "bad_timing",
|
| 42 |
+
"task": "weekly_competitive",
|
| 43 |
+
"score": 0.0937,
|
| 44 |
+
"total_steps": 168,
|
| 45 |
+
"total_posts": 49,
|
| 46 |
+
"avg_reward": 0.1611,
|
| 47 |
+
"final": {
|
| 48 |
+
"energy": 0.59,
|
| 49 |
+
"hours_since_sleep": 5,
|
| 50 |
+
"sleep_debt": 0.0,
|
| 51 |
+
"followers": 10237,
|
| 52 |
+
"engagement_rate": 0.0358,
|
| 53 |
+
"burned_out": false
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "2026-04-05T10:50:54.878099+00:00",
|
| 58 |
+
"scenario": "Balanced Creator",
|
| 59 |
+
"scenario_id": "balanced",
|
| 60 |
+
"task": "weekly_competitive",
|
| 61 |
+
"score": 0.8775,
|
| 62 |
+
"total_steps": 168,
|
| 63 |
+
"total_posts": 28,
|
| 64 |
+
"avg_reward": 0.2187,
|
| 65 |
+
"final": {
|
| 66 |
+
"energy": 1.0,
|
| 67 |
+
"hours_since_sleep": 2,
|
| 68 |
+
"sleep_debt": 0.0,
|
| 69 |
+
"followers": 12534,
|
| 70 |
+
"engagement_rate": 0.8273,
|
| 71 |
+
"burned_out": false
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": "2026-04-05T10:50:54.891038+00:00",
|
| 76 |
+
"scenario": "Burst Poster",
|
| 77 |
+
"scenario_id": "burst",
|
| 78 |
+
"task": "weekly_competitive",
|
| 79 |
+
"score": 0.6111,
|
| 80 |
+
"total_steps": 168,
|
| 81 |
+
"total_posts": 57,
|
| 82 |
+
"avg_reward": 0.2318,
|
| 83 |
+
"final": {
|
| 84 |
+
"energy": 0.44,
|
| 85 |
+
"hours_since_sleep": 1,
|
| 86 |
+
"sleep_debt": 0.0,
|
| 87 |
+
"followers": 11701,
|
| 88 |
+
"engagement_rate": 0.2076,
|
| 89 |
+
"burned_out": false
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "2026-04-05T10:50:54.901147+00:00",
|
| 94 |
+
"scenario": "Carousel Only",
|
| 95 |
+
"scenario_id": "carousel_only",
|
| 96 |
+
"task": "weekly_competitive",
|
| 97 |
+
"score": 0.417,
|
| 98 |
+
"total_steps": 168,
|
| 99 |
+
"total_posts": 14,
|
| 100 |
+
"avg_reward": 0.2353,
|
| 101 |
+
"final": {
|
| 102 |
+
"energy": 1.0,
|
| 103 |
+
"hours_since_sleep": 1,
|
| 104 |
+
"sleep_debt": 0.0,
|
| 105 |
+
"followers": 12074,
|
| 106 |
+
"engagement_rate": 1.3175,
|
| 107 |
+
"burned_out": false
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"id": "2026-04-05T10:50:54.911264+00:00",
|
| 112 |
+
"scenario": "Competitor Avoider",
|
| 113 |
+
"scenario_id": "comp_avoider",
|
| 114 |
+
"task": "weekly_competitive",
|
| 115 |
+
"score": 0.446,
|
| 116 |
+
"total_steps": 168,
|
| 117 |
+
"total_posts": 14,
|
| 118 |
+
"avg_reward": 0.2365,
|
| 119 |
+
"final": {
|
| 120 |
+
"energy": 1.0,
|
| 121 |
+
"hours_since_sleep": 1,
|
| 122 |
+
"sleep_debt": 0.0,
|
| 123 |
+
"followers": 12678,
|
| 124 |
+
"engagement_rate": 1.8163,
|
| 125 |
+
"burned_out": false
|
| 126 |
+
}
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"id": "2026-04-05T10:50:54.921231+00:00",
|
| 130 |
+
"scenario": "Conservative Energy",
|
| 131 |
+
"scenario_id": "conservative",
|
| 132 |
+
"task": "weekly_competitive",
|
| 133 |
+
"score": 0.2181,
|
| 134 |
+
"total_steps": 168,
|
| 135 |
+
"total_posts": 7,
|
| 136 |
+
"avg_reward": 0.1967,
|
| 137 |
+
"final": {
|
| 138 |
+
"energy": 1.0,
|
| 139 |
+
"hours_since_sleep": 1,
|
| 140 |
+
"sleep_debt": 0.0,
|
| 141 |
+
"followers": 10239,
|
| 142 |
+
"engagement_rate": 0.3439,
|
| 143 |
+
"burned_out": false
|
| 144 |
+
}
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"id": "2026-04-05T10:50:54.931980+00:00",
|
| 148 |
+
"scenario": "Content Creator",
|
| 149 |
+
"scenario_id": "content_creator",
|
| 150 |
+
"task": "weekly_competitive",
|
| 151 |
+
"score": 0.6434,
|
| 152 |
+
"total_steps": 168,
|
| 153 |
+
"total_posts": 12,
|
| 154 |
+
"avg_reward": 0.2065,
|
| 155 |
+
"final": {
|
| 156 |
+
"energy": 0.309,
|
| 157 |
+
"hours_since_sleep": 28,
|
| 158 |
+
"sleep_debt": 0.017,
|
| 159 |
+
"followers": 10931,
|
| 160 |
+
"engagement_rate": 0.525,
|
| 161 |
+
"burned_out": false
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"id": "2026-04-05T10:50:54.942037+00:00",
|
| 166 |
+
"scenario": "Copycat",
|
| 167 |
+
"scenario_id": "copycat",
|
| 168 |
+
"task": "weekly_competitive",
|
| 169 |
+
"score": 0.6136,
|
| 170 |
+
"total_steps": 168,
|
| 171 |
+
"total_posts": 21,
|
| 172 |
+
"avg_reward": 0.1887,
|
| 173 |
+
"final": {
|
| 174 |
+
"energy": 1.0,
|
| 175 |
+
"hours_since_sleep": 1,
|
| 176 |
+
"sleep_debt": 0.0,
|
| 177 |
+
"followers": 11589,
|
| 178 |
+
"engagement_rate": 0.497,
|
| 179 |
+
"burned_out": false
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"id": "2026-04-05T10:50:54.951850+00:00",
|
| 184 |
+
"scenario": "Creator Economy",
|
| 185 |
+
"scenario_id": "creator_economy",
|
| 186 |
+
"task": "weekly_competitive",
|
| 187 |
+
"score": 0.2515,
|
| 188 |
+
"total_steps": 168,
|
| 189 |
+
"total_posts": 14,
|
| 190 |
+
"avg_reward": 0.2226,
|
| 191 |
+
"final": {
|
| 192 |
+
"energy": 1.0,
|
| 193 |
+
"hours_since_sleep": 1,
|
| 194 |
+
"sleep_debt": 0.0,
|
| 195 |
+
"followers": 11994,
|
| 196 |
+
"engagement_rate": 1.3918,
|
| 197 |
+
"burned_out": false
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"id": "2026-04-05T10:50:54.961166+00:00",
|
| 202 |
+
"scenario": "Crypto/Web3",
|
| 203 |
+
"scenario_id": "crypto_niche",
|
| 204 |
+
"task": "weekly_competitive",
|
| 205 |
+
"score": 0.2879,
|
| 206 |
+
"total_steps": 168,
|
| 207 |
+
"total_posts": 14,
|
| 208 |
+
"avg_reward": 0.2324,
|
| 209 |
+
"final": {
|
| 210 |
+
"energy": 1.0,
|
| 211 |
+
"hours_since_sleep": 1,
|
| 212 |
+
"sleep_debt": 0.0,
|
| 213 |
+
"followers": 12444,
|
| 214 |
+
"engagement_rate": 1.6187,
|
| 215 |
+
"burned_out": false
|
| 216 |
+
}
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"id": "2026-04-05T10:50:54.970461+00:00",
|
| 220 |
+
"scenario": "Double Peak",
|
| 221 |
+
"scenario_id": "double_peak",
|
| 222 |
+
"task": "weekly_competitive",
|
| 223 |
+
"score": 0.4519,
|
| 224 |
+
"total_steps": 168,
|
| 225 |
+
"total_posts": 14,
|
| 226 |
+
"avg_reward": 0.2352,
|
| 227 |
+
"final": {
|
| 228 |
+
"energy": 1.0,
|
| 229 |
+
"hours_since_sleep": 1,
|
| 230 |
+
"sleep_debt": 0.0,
|
| 231 |
+
"followers": 13138,
|
| 232 |
+
"engagement_rate": 2.0814,
|
| 233 |
+
"burned_out": false
|
| 234 |
+
}
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"id": "2026-04-05T10:50:54.980718+00:00",
|
| 238 |
+
"scenario": "Early Bird",
|
| 239 |
+
"scenario_id": "early_bird",
|
| 240 |
+
"task": "weekly_competitive",
|
| 241 |
+
"score": 0.2075,
|
| 242 |
+
"total_steps": 168,
|
| 243 |
+
"total_posts": 16,
|
| 244 |
+
"avg_reward": 0.2284,
|
| 245 |
+
"final": {
|
| 246 |
+
"energy": 0.62,
|
| 247 |
+
"hours_since_sleep": 2,
|
| 248 |
+
"sleep_debt": 0.0,
|
| 249 |
+
"followers": 10818,
|
| 250 |
+
"engagement_rate": 0.4138,
|
| 251 |
+
"burned_out": false
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"id": "2026-04-05T10:50:54.989979+00:00",
|
| 256 |
+
"scenario": "Energy Saver",
|
| 257 |
+
"scenario_id": "energy_saver",
|
| 258 |
+
"task": "weekly_competitive",
|
| 259 |
+
"score": 0.3744,
|
| 260 |
+
"total_steps": 168,
|
| 261 |
+
"total_posts": 7,
|
| 262 |
+
"avg_reward": 0.2111,
|
| 263 |
+
"final": {
|
| 264 |
+
"energy": 1.0,
|
| 265 |
+
"hours_since_sleep": 1,
|
| 266 |
+
"sleep_debt": 0.0,
|
| 267 |
+
"followers": 11080,
|
| 268 |
+
"engagement_rate": 1.5483,
|
| 269 |
+
"burned_out": false
|
| 270 |
+
}
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"id": "2026-04-05T10:50:55.000118+00:00",
|
| 274 |
+
"scenario": "Engagement Chaser",
|
| 275 |
+
"scenario_id": "engagement_chaser",
|
| 276 |
+
"task": "weekly_competitive",
|
| 277 |
+
"score": 0.4194,
|
| 278 |
+
"total_steps": 168,
|
| 279 |
+
"total_posts": 21,
|
| 280 |
+
"avg_reward": 0.2224,
|
| 281 |
+
"final": {
|
| 282 |
+
"energy": 1.0,
|
| 283 |
+
"hours_since_sleep": 1,
|
| 284 |
+
"sleep_debt": 0.0,
|
| 285 |
+
"followers": 15287,
|
| 286 |
+
"engagement_rate": 2.2466,
|
| 287 |
+
"burned_out": false
|
| 288 |
+
}
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
"id": "2026-04-05T10:50:55.009873+00:00",
|
| 292 |
+
"scenario": "Events/News",
|
| 293 |
+
"scenario_id": "events",
|
| 294 |
+
"task": "weekly_competitive",
|
| 295 |
+
"score": 0.158,
|
| 296 |
+
"total_steps": 168,
|
| 297 |
+
"total_posts": 4,
|
| 298 |
+
"avg_reward": 0.1732,
|
| 299 |
+
"final": {
|
| 300 |
+
"energy": 1.0,
|
| 301 |
+
"hours_since_sleep": 1,
|
| 302 |
+
"sleep_debt": 0.0,
|
| 303 |
+
"followers": 7491,
|
| 304 |
+
"engagement_rate": 1.4388,
|
| 305 |
+
"burned_out": false
|
| 306 |
+
}
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"id": "2026-04-05T10:50:55.018674+00:00",
|
| 310 |
+
"scenario": "Fashion Content",
|
| 311 |
+
"scenario_id": "fashion",
|
| 312 |
+
"task": "weekly_competitive",
|
| 313 |
+
"score": 0.2181,
|
| 314 |
+
"total_steps": 168,
|
| 315 |
+
"total_posts": 14,
|
| 316 |
+
"avg_reward": 0.2147,
|
| 317 |
+
"final": {
|
| 318 |
+
"energy": 1.0,
|
| 319 |
+
"hours_since_sleep": 1,
|
| 320 |
+
"sleep_debt": 0.0,
|
| 321 |
+
"followers": 11135,
|
| 322 |
+
"engagement_rate": 0.7898,
|
| 323 |
+
"burned_out": false
|
| 324 |
+
}
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"id": "2026-04-05T10:50:55.027894+00:00",
|
| 328 |
+
"scenario": "Food Creator",
|
| 329 |
+
"scenario_id": "food_creator",
|
| 330 |
+
"task": "weekly_competitive",
|
| 331 |
+
"score": 0.2612,
|
| 332 |
+
"total_steps": 168,
|
| 333 |
+
"total_posts": 15,
|
| 334 |
+
"avg_reward": 0.2293,
|
| 335 |
+
"final": {
|
| 336 |
+
"energy": 0.7,
|
| 337 |
+
"hours_since_sleep": 2,
|
| 338 |
+
"sleep_debt": 0.0,
|
| 339 |
+
"followers": 12091,
|
| 340 |
+
"engagement_rate": 1.1978,
|
| 341 |
+
"burned_out": false
|
| 342 |
+
}
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"id": "2026-04-05T10:50:55.037230+00:00",
|
| 346 |
+
"scenario": "Gaming Niche",
|
| 347 |
+
"scenario_id": "gaming_niche",
|
| 348 |
+
"task": "weekly_competitive",
|
| 349 |
+
"score": 0.2188,
|
| 350 |
+
"total_steps": 168,
|
| 351 |
+
"total_posts": 14,
|
| 352 |
+
"avg_reward": 0.2062,
|
| 353 |
+
"final": {
|
| 354 |
+
"energy": 1.0,
|
| 355 |
+
"hours_since_sleep": 1,
|
| 356 |
+
"sleep_debt": 0.0,
|
| 357 |
+
"followers": 11364,
|
| 358 |
+
"engagement_rate": 0.9138,
|
| 359 |
+
"burned_out": false
|
| 360 |
+
}
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"id": "2026-04-05T10:50:55.047589+00:00",
|
| 364 |
+
"scenario": "Growth Focus",
|
| 365 |
+
"scenario_id": "growth_focus",
|
| 366 |
+
"task": "weekly_competitive",
|
| 367 |
+
"score": 0.2764,
|
| 368 |
+
"total_steps": 168,
|
| 369 |
+
"total_posts": 14,
|
| 370 |
+
"avg_reward": 0.2205,
|
| 371 |
+
"final": {
|
| 372 |
+
"energy": 1.0,
|
| 373 |
+
"hours_since_sleep": 1,
|
| 374 |
+
"sleep_debt": 0.0,
|
| 375 |
+
"followers": 12621,
|
| 376 |
+
"engagement_rate": 1.7101,
|
| 377 |
+
"burned_out": false
|
| 378 |
+
}
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
"id": "2026-04-05T10:50:55.059854+00:00",
|
| 382 |
+
"scenario": "High Frequency",
|
| 383 |
+
"scenario_id": "high_freq",
|
| 384 |
+
"task": "weekly_competitive",
|
| 385 |
+
"score": 0.8611,
|
| 386 |
+
"total_steps": 168,
|
| 387 |
+
"total_posts": 22,
|
| 388 |
+
"avg_reward": 0.2058,
|
| 389 |
+
"final": {
|
| 390 |
+
"energy": 0.92,
|
| 391 |
+
"hours_since_sleep": 2,
|
| 392 |
+
"sleep_debt": 0.0,
|
| 393 |
+
"followers": 12654,
|
| 394 |
+
"engagement_rate": 1.079,
|
| 395 |
+
"burned_out": false
|
| 396 |
+
}
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"id": "2026-04-05T10:50:55.072522+00:00",
|
| 400 |
+
"scenario": "Lifestyle Niche",
|
| 401 |
+
"scenario_id": "lifestyle_niche",
|
| 402 |
+
"task": "weekly_competitive",
|
| 403 |
+
"score": 0.2612,
|
| 404 |
+
"total_steps": 168,
|
| 405 |
+
"total_posts": 14,
|
| 406 |
+
"avg_reward": 0.2288,
|
| 407 |
+
"final": {
|
| 408 |
+
"energy": 1.0,
|
| 409 |
+
"hours_since_sleep": 1,
|
| 410 |
+
"sleep_debt": 0.0,
|
| 411 |
+
"followers": 12251,
|
| 412 |
+
"engagement_rate": 1.6295,
|
| 413 |
+
"burned_out": false
|
| 414 |
+
}
|
| 415 |
+
},
|
| 416 |
+
{
|
| 417 |
+
"id": "2026-04-05T10:50:55.081957+00:00",
|
| 418 |
+
"scenario": "Low Frequency",
|
| 419 |
+
"scenario_id": "low_freq",
|
| 420 |
+
"task": "weekly_competitive",
|
| 421 |
+
"score": 0.3241,
|
| 422 |
+
"total_steps": 168,
|
| 423 |
+
"total_posts": 4,
|
| 424 |
+
"avg_reward": 0.1768,
|
| 425 |
+
"final": {
|
| 426 |
+
"energy": 1.0,
|
| 427 |
+
"hours_since_sleep": 1,
|
| 428 |
+
"sleep_debt": 0.0,
|
| 429 |
+
"followers": 10461,
|
| 430 |
+
"engagement_rate": 1.1563,
|
| 431 |
+
"burned_out": false
|
| 432 |
+
}
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"id": "2026-04-05T10:50:55.089553+00:00",
|
| 436 |
+
"scenario": "Marathon Runner",
|
| 437 |
+
"scenario_id": "marathon",
|
| 438 |
+
"task": "weekly_competitive",
|
| 439 |
+
"score": 0.0,
|
| 440 |
+
"total_steps": 50,
|
| 441 |
+
"total_posts": 9,
|
| 442 |
+
"avg_reward": 0.1323,
|
| 443 |
+
"final": {
|
| 444 |
+
"energy": 0.0,
|
| 445 |
+
"hours_since_sleep": 22,
|
| 446 |
+
"sleep_debt": 0.028,
|
| 447 |
+
"followers": 10137,
|
| 448 |
+
"engagement_rate": 0.157,
|
| 449 |
+
"burned_out": true
|
| 450 |
+
}
|
| 451 |
+
},
|
| 452 |
+
{
|
| 453 |
+
"id": "2026-04-05T10:50:55.095782+00:00",
|
| 454 |
+
"scenario": "Midday Focus",
|
| 455 |
+
"scenario_id": "midday",
|
| 456 |
+
"task": "weekly_competitive",
|
| 457 |
+
"score": 0.4317,
|
| 458 |
+
"total_steps": 168,
|
| 459 |
+
"total_posts": 14,
|
| 460 |
+
"avg_reward": 0.2306,
|
| 461 |
+
"final": {
|
| 462 |
+
"energy": 1.0,
|
| 463 |
+
"hours_since_sleep": 1,
|
| 464 |
+
"sleep_debt": 0.0,
|
| 465 |
+
"followers": 13537,
|
| 466 |
+
"engagement_rate": 2.3076,
|
| 467 |
+
"burned_out": false
|
| 468 |
+
}
|
| 469 |
+
},
|
| 470 |
+
{
|
| 471 |
+
"id": "2026-04-05T10:50:55.106103+00:00",
|
| 472 |
+
"scenario": "Minimal Poster",
|
| 473 |
+
"scenario_id": "minimal",
|
| 474 |
+
"task": "weekly_competitive",
|
| 475 |
+
"score": 0.3658,
|
| 476 |
+
"total_steps": 168,
|
| 477 |
+
"total_posts": 7,
|
| 478 |
+
"avg_reward": 0.2039,
|
| 479 |
+
"final": {
|
| 480 |
+
"energy": 1.0,
|
| 481 |
+
"hours_since_sleep": 1,
|
| 482 |
+
"sleep_debt": 0.0,
|
| 483 |
+
"followers": 10907,
|
| 484 |
+
"engagement_rate": 1.3002,
|
| 485 |
+
"burned_out": false
|
| 486 |
+
}
|
| 487 |
+
},
|
| 488 |
+
{
|
| 489 |
+
"id": "2026-04-05T10:50:55.116369+00:00",
|
| 490 |
+
"scenario": "ML/AI Deep Dive",
|
| 491 |
+
"scenario_id": "ml_deep",
|
| 492 |
+
"task": "weekly_competitive",
|
| 493 |
+
"score": 0.2266,
|
| 494 |
+
"total_steps": 168,
|
| 495 |
+
"total_posts": 14,
|
| 496 |
+
"avg_reward": 0.2197,
|
| 497 |
+
"final": {
|
| 498 |
+
"energy": 1.0,
|
| 499 |
+
"hours_since_sleep": 1,
|
| 500 |
+
"sleep_debt": 0.0,
|
| 501 |
+
"followers": 11180,
|
| 502 |
+
"engagement_rate": 0.7014,
|
| 503 |
+
"burned_out": false
|
| 504 |
+
}
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"id": "2026-04-05T10:50:55.125451+00:00",
|
| 508 |
+
"scenario": "Monday Motivation",
|
| 509 |
+
"scenario_id": "monday",
|
| 510 |
+
"task": "weekly_competitive",
|
| 511 |
+
"score": 0.2606,
|
| 512 |
+
"total_steps": 168,
|
| 513 |
+
"total_posts": 4,
|
| 514 |
+
"avg_reward": 0.159,
|
| 515 |
+
"final": {
|
| 516 |
+
"energy": 0.75,
|
| 517 |
+
"hours_since_sleep": 2,
|
| 518 |
+
"sleep_debt": 0.0,
|
| 519 |
+
"followers": 5827,
|
| 520 |
+
"engagement_rate": 0.911,
|
| 521 |
+
"burned_out": false
|
| 522 |
+
}
|
| 523 |
+
},
|
| 524 |
+
{
|
| 525 |
+
"id": "2026-04-05T10:50:55.134737+00:00",
|
| 526 |
+
"scenario": "Napper",
|
| 527 |
+
"scenario_id": "napper",
|
| 528 |
+
"task": "weekly_competitive",
|
| 529 |
+
"score": 0.3623,
|
| 530 |
+
"total_steps": 168,
|
| 531 |
+
"total_posts": 14,
|
| 532 |
+
"avg_reward": 0.2264,
|
| 533 |
+
"final": {
|
| 534 |
+
"energy": 1.0,
|
| 535 |
+
"hours_since_sleep": 1,
|
| 536 |
+
"sleep_debt": 0.0,
|
| 537 |
+
"followers": 11322,
|
| 538 |
+
"engagement_rate": 0.8914,
|
| 539 |
+
"burned_out": false
|
| 540 |
+
}
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"id": "2026-04-05T10:50:55.144641+00:00",
|
| 544 |
+
"scenario": "Night Owl",
|
| 545 |
+
"scenario_id": "night_owl",
|
| 546 |
+
"task": "weekly_competitive",
|
| 547 |
+
"score": 0.266,
|
| 548 |
+
"total_steps": 168,
|
| 549 |
+
"total_posts": 14,
|
| 550 |
+
"avg_reward": 0.194,
|
| 551 |
+
"final": {
|
| 552 |
+
"energy": 1.0,
|
| 553 |
+
"hours_since_sleep": 1,
|
| 554 |
+
"sleep_debt": 0.0,
|
| 555 |
+
"followers": 11927,
|
| 556 |
+
"engagement_rate": 1.328,
|
| 557 |
+
"burned_out": false
|
| 558 |
+
}
|
| 559 |
+
},
|
| 560 |
+
{
|
| 561 |
+
"id": "2026-04-05T10:50:55.153554+00:00",
|
| 562 |
+
"scenario": "Night Shift",
|
| 563 |
+
"scenario_id": "night_shift",
|
| 564 |
+
"task": "weekly_competitive",
|
| 565 |
+
"score": 0.2105,
|
| 566 |
+
"total_steps": 168,
|
| 567 |
+
"total_posts": 16,
|
| 568 |
+
"avg_reward": 0.2453,
|
| 569 |
+
"final": {
|
| 570 |
+
"energy": 1.0,
|
| 571 |
+
"hours_since_sleep": 1,
|
| 572 |
+
"sleep_debt": 0.0,
|
| 573 |
+
"followers": 11069,
|
| 574 |
+
"engagement_rate": 0.5602,
|
| 575 |
+
"burned_out": false
|
| 576 |
+
}
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"id": "2026-04-05T10:50:55.159353+00:00",
|
| 580 |
+
"scenario": "No Rest",
|
| 581 |
+
"scenario_id": "no_rest",
|
| 582 |
+
"task": "weekly_competitive",
|
| 583 |
+
"score": 0.0,
|
| 584 |
+
"total_steps": 8,
|
| 585 |
+
"total_posts": 8,
|
| 586 |
+
"avg_reward": 0.2686,
|
| 587 |
+
"final": {
|
| 588 |
+
"energy": 0.0,
|
| 589 |
+
"hours_since_sleep": 10,
|
| 590 |
+
"sleep_debt": 0.0,
|
| 591 |
+
"followers": 10213,
|
| 592 |
+
"engagement_rate": 0.2732,
|
| 593 |
+
"burned_out": true
|
| 594 |
+
}
|
| 595 |
+
},
|
| 596 |
+
{
|
| 597 |
+
"id": "2026-04-05T10:50:55.164846+00:00",
|
| 598 |
+
"scenario": "Optimal Sleep",
|
| 599 |
+
"scenario_id": "optimal_sleep",
|
| 600 |
+
"task": "weekly_competitive",
|
| 601 |
+
"score": 0.3635,
|
| 602 |
+
"total_steps": 168,
|
| 603 |
+
"total_posts": 14,
|
| 604 |
+
"avg_reward": 0.2257,
|
| 605 |
+
"final": {
|
| 606 |
+
"energy": 0.9,
|
| 607 |
+
"hours_since_sleep": 3,
|
| 608 |
+
"sleep_debt": 0.0,
|
| 609 |
+
"followers": 11305,
|
| 610 |
+
"engagement_rate": 0.8729,
|
| 611 |
+
"burned_out": false
|
| 612 |
+
}
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"id": "2026-04-05T10:50:55.174882+00:00",
|
| 616 |
+
"scenario": "Photography Focus",
|
| 617 |
+
"scenario_id": "photography",
|
| 618 |
+
"task": "weekly_competitive",
|
| 619 |
+
"score": 0.1838,
|
| 620 |
+
"total_steps": 168,
|
| 621 |
+
"total_posts": 16,
|
| 622 |
+
"avg_reward": 0.22,
|
| 623 |
+
"final": {
|
| 624 |
+
"energy": 0.5,
|
| 625 |
+
"hours_since_sleep": 3,
|
| 626 |
+
"sleep_debt": 0.0,
|
| 627 |
+
"followers": 10736,
|
| 628 |
+
"engagement_rate": 0.4388,
|
| 629 |
+
"burned_out": false
|
| 630 |
+
}
|
| 631 |
+
},
|
| 632 |
+
{
|
| 633 |
+
"id": "2026-04-05T10:50:55.184216+00:00",
|
| 634 |
+
"scenario": "Productivity Guru",
|
| 635 |
+
"scenario_id": "productivity",
|
| 636 |
+
"task": "weekly_competitive",
|
| 637 |
+
"score": 0.184,
|
| 638 |
+
"total_steps": 168,
|
| 639 |
+
"total_posts": 16,
|
| 640 |
+
"avg_reward": 0.227,
|
| 641 |
+
"final": {
|
| 642 |
+
"energy": 0.62,
|
| 643 |
+
"hours_since_sleep": 2,
|
| 644 |
+
"sleep_debt": 0.0,
|
| 645 |
+
"followers": 10741,
|
| 646 |
+
"engagement_rate": 0.3797,
|
| 647 |
+
"burned_out": false
|
| 648 |
+
}
|
| 649 |
+
},
|
| 650 |
+
{
|
| 651 |
+
"id": "2026-04-05T10:50:55.192896+00:00",
|
| 652 |
+
"scenario": "Queue Heavy",
|
| 653 |
+
"scenario_id": "queue_heavy",
|
| 654 |
+
"task": "weekly_competitive",
|
| 655 |
+
"score": 0.1933,
|
| 656 |
+
"total_steps": 168,
|
| 657 |
+
"total_posts": 8,
|
| 658 |
+
"avg_reward": 0.1923,
|
| 659 |
+
"final": {
|
| 660 |
+
"energy": 1.0,
|
| 661 |
+
"hours_since_sleep": 1,
|
| 662 |
+
"sleep_debt": 0.0,
|
| 663 |
+
"followers": 9453,
|
| 664 |
+
"engagement_rate": 0.781,
|
| 665 |
+
"burned_out": false
|
| 666 |
+
}
|
| 667 |
+
},
|
| 668 |
+
{
|
| 669 |
+
"id": "2026-04-05T10:50:55.202107+00:00",
|
| 670 |
+
"scenario": "Queue Optimizer",
|
| 671 |
+
"scenario_id": "queue_optimizer",
|
| 672 |
+
"task": "weekly_competitive",
|
| 673 |
+
"score": 0.352,
|
| 674 |
+
"total_steps": 168,
|
| 675 |
+
"total_posts": 14,
|
| 676 |
+
"avg_reward": 0.2233,
|
| 677 |
+
"final": {
|
| 678 |
+
"energy": 1.0,
|
| 679 |
+
"hours_since_sleep": 1,
|
| 680 |
+
"sleep_debt": 0.0,
|
| 681 |
+
"followers": 11215,
|
| 682 |
+
"engagement_rate": 0.8701,
|
| 683 |
+
"burned_out": false
|
| 684 |
+
}
|
| 685 |
+
},
|
| 686 |
+
{
|
| 687 |
+
"id": "2026-04-05T10:50:55.209453+00:00",
|
| 688 |
+
"scenario": "Random Actor",
|
| 689 |
+
"scenario_id": "random",
|
| 690 |
+
"task": "weekly_competitive",
|
| 691 |
+
"score": 0.0,
|
| 692 |
+
"total_steps": 22,
|
| 693 |
+
"total_posts": 11,
|
| 694 |
+
"avg_reward": 0.2318,
|
| 695 |
+
"final": {
|
| 696 |
+
"energy": 0.0,
|
| 697 |
+
"hours_since_sleep": 17,
|
| 698 |
+
"sleep_debt": 0.033,
|
| 699 |
+
"followers": 10159,
|
| 700 |
+
"engagement_rate": 0.087,
|
| 701 |
+
"burned_out": true
|
| 702 |
+
}
|
| 703 |
+
},
|
| 704 |
+
{
|
| 705 |
+
"id": "2026-04-05T10:50:55.215343+00:00",
|
| 706 |
+
"scenario": "Reel Maximizer",
|
| 707 |
+
"scenario_id": "reel_max",
|
| 708 |
+
"task": "weekly_competitive",
|
| 709 |
+
"score": 0.4344,
|
| 710 |
+
"total_steps": 168,
|
| 711 |
+
"total_posts": 14,
|
| 712 |
+
"avg_reward": 0.2295,
|
| 713 |
+
"final": {
|
| 714 |
+
"energy": 1.0,
|
| 715 |
+
"hours_since_sleep": 1,
|
| 716 |
+
"sleep_debt": 0.0,
|
| 717 |
+
"followers": 13314,
|
| 718 |
+
"engagement_rate": 2.1201,
|
| 719 |
+
"burned_out": false
|
| 720 |
+
}
|
| 721 |
+
},
|
| 722 |
+
{
|
| 723 |
+
"id": "2026-04-05T10:50:55.225542+00:00",
|
| 724 |
+
"scenario": "SaaS/Business",
|
| 725 |
+
"scenario_id": "saas",
|
| 726 |
+
"task": "weekly_competitive",
|
| 727 |
+
"score": 0.2015,
|
| 728 |
+
"total_steps": 168,
|
| 729 |
+
"total_posts": 14,
|
| 730 |
+
"avg_reward": 0.2182,
|
| 731 |
+
"final": {
|
| 732 |
+
"energy": 1.0,
|
| 733 |
+
"hours_since_sleep": 1,
|
| 734 |
+
"sleep_debt": 0.0,
|
| 735 |
+
"followers": 10958,
|
| 736 |
+
"engagement_rate": 0.6072,
|
| 737 |
+
"burned_out": false
|
| 738 |
+
}
|
| 739 |
+
},
|
| 740 |
+
{
|
| 741 |
+
"id": "2026-04-05T10:50:55.234793+00:00",
|
| 742 |
+
"scenario": "Sleep Conscious",
|
| 743 |
+
"scenario_id": "sleep_conscious",
|
| 744 |
+
"task": "weekly_competitive",
|
| 745 |
+
"score": 0.3635,
|
| 746 |
+
"total_steps": 168,
|
| 747 |
+
"total_posts": 14,
|
| 748 |
+
"avg_reward": 0.2257,
|
| 749 |
+
"final": {
|
| 750 |
+
"energy": 0.9,
|
| 751 |
+
"hours_since_sleep": 3,
|
| 752 |
+
"sleep_debt": 0.0,
|
| 753 |
+
"followers": 11305,
|
| 754 |
+
"engagement_rate": 0.8729,
|
| 755 |
+
"burned_out": false
|
| 756 |
+
}
|
| 757 |
+
},
|
| 758 |
+
{
|
| 759 |
+
"id": "2026-04-05T10:50:55.245249+00:00",
|
| 760 |
+
"scenario": "Sleep Debt Aware",
|
| 761 |
+
"scenario_id": "sleep_debt_aware",
|
| 762 |
+
"task": "weekly_competitive",
|
| 763 |
+
"score": 0.3745,
|
| 764 |
+
"total_steps": 168,
|
| 765 |
+
"total_posts": 14,
|
| 766 |
+
"avg_reward": 0.2293,
|
| 767 |
+
"final": {
|
| 768 |
+
"energy": 1.0,
|
| 769 |
+
"hours_since_sleep": 1,
|
| 770 |
+
"sleep_debt": 0.0,
|
| 771 |
+
"followers": 11412,
|
| 772 |
+
"engagement_rate": 0.9425,
|
| 773 |
+
"burned_out": false
|
| 774 |
+
}
|
| 775 |
+
},
|
| 776 |
+
{
|
| 777 |
+
"id": "2026-04-05T10:50:55.252673+00:00",
|
| 778 |
+
"scenario": "Sleep Deprived",
|
| 779 |
+
"scenario_id": "sleep_deprived",
|
| 780 |
+
"task": "weekly_competitive",
|
| 781 |
+
"score": 0.0,
|
| 782 |
+
"total_steps": 16,
|
| 783 |
+
"total_posts": 2,
|
| 784 |
+
"avg_reward": 0.2248,
|
| 785 |
+
"final": {
|
| 786 |
+
"energy": 0.0,
|
| 787 |
+
"hours_since_sleep": 18,
|
| 788 |
+
"sleep_debt": 0.045,
|
| 789 |
+
"followers": 10215,
|
| 790 |
+
"engagement_rate": 1.0806,
|
| 791 |
+
"burned_out": true
|
| 792 |
+
}
|
| 793 |
+
},
|
| 794 |
+
{
|
| 795 |
+
"id": "2026-04-05T10:50:55.258355+00:00",
|
| 796 |
+
"scenario": "Sleep Respecting",
|
| 797 |
+
"scenario_id": "sleep_respecting",
|
| 798 |
+
"task": "weekly_competitive",
|
| 799 |
+
"score": 0.3623,
|
| 800 |
+
"total_steps": 168,
|
| 801 |
+
"total_posts": 14,
|
| 802 |
+
"avg_reward": 0.2264,
|
| 803 |
+
"final": {
|
| 804 |
+
"energy": 1.0,
|
| 805 |
+
"hours_since_sleep": 1,
|
| 806 |
+
"sleep_debt": 0.0,
|
| 807 |
+
"followers": 11322,
|
| 808 |
+
"engagement_rate": 0.8914,
|
| 809 |
+
"burned_out": false
|
| 810 |
+
}
|
| 811 |
+
},
|
| 812 |
+
{
|
| 813 |
+
"id": "2026-04-05T10:50:55.268389+00:00",
|
| 814 |
+
"scenario": "Smart Agent",
|
| 815 |
+
"scenario_id": "smart",
|
| 816 |
+
"task": "weekly_competitive",
|
| 817 |
+
"score": 0.8745,
|
| 818 |
+
"total_steps": 168,
|
| 819 |
+
"total_posts": 14,
|
| 820 |
+
"avg_reward": 0.2301,
|
| 821 |
+
"final": {
|
| 822 |
+
"energy": 1.0,
|
| 823 |
+
"hours_since_sleep": 1,
|
| 824 |
+
"sleep_debt": 0.0,
|
| 825 |
+
"followers": 12200,
|
| 826 |
+
"engagement_rate": 1.5557,
|
| 827 |
+
"burned_out": false
|
| 828 |
+
}
|
| 829 |
+
},
|
| 830 |
+
{
|
| 831 |
+
"id": "2026-04-05T10:50:55.276258+00:00",
|
| 832 |
+
"scenario": "Spam Post",
|
| 833 |
+
"scenario_id": "spam",
|
| 834 |
+
"task": "weekly_competitive",
|
| 835 |
+
"score": 0.0,
|
| 836 |
+
"total_steps": 4,
|
| 837 |
+
"total_posts": 4,
|
| 838 |
+
"avg_reward": 0.387,
|
| 839 |
+
"final": {
|
| 840 |
+
"energy": 0.0,
|
| 841 |
+
"hours_since_sleep": 6,
|
| 842 |
+
"sleep_debt": 0.0,
|
| 843 |
+
"followers": 10625,
|
| 844 |
+
"engagement_rate": 1.567,
|
| 845 |
+
"burned_out": true
|
| 846 |
+
}
|
| 847 |
+
},
|
| 848 |
+
{
|
| 849 |
+
"id": "2026-04-05T10:50:55.281752+00:00",
|
| 850 |
+
"scenario": "Split Schedule",
|
| 851 |
+
"scenario_id": "split_schedule",
|
| 852 |
+
"task": "weekly_competitive",
|
| 853 |
+
"score": 0.385,
|
| 854 |
+
"total_steps": 168,
|
| 855 |
+
"total_posts": 15,
|
| 856 |
+
"avg_reward": 0.2347,
|
| 857 |
+
"final": {
|
| 858 |
+
"energy": 0.75,
|
| 859 |
+
"hours_since_sleep": 2,
|
| 860 |
+
"sleep_debt": 0.0,
|
| 861 |
+
"followers": 11689,
|
| 862 |
+
"engagement_rate": 0.9724,
|
| 863 |
+
"burned_out": false
|
| 864 |
+
}
|
| 865 |
+
},
|
| 866 |
+
{
|
| 867 |
+
"id": "2026-04-05T10:50:55.291899+00:00",
|
| 868 |
+
"scenario": "Stoic Philosophy",
|
| 869 |
+
"scenario_id": "stoic",
|
| 870 |
+
"task": "weekly_competitive",
|
| 871 |
+
"score": 0.1071,
|
| 872 |
+
"total_steps": 168,
|
| 873 |
+
"total_posts": 7,
|
| 874 |
+
"avg_reward": 0.2069,
|
| 875 |
+
"final": {
|
| 876 |
+
"energy": 1.0,
|
| 877 |
+
"hours_since_sleep": 1,
|
| 878 |
+
"sleep_debt": 0.0,
|
| 879 |
+
"followers": 10108,
|
| 880 |
+
"engagement_rate": 0.1578,
|
| 881 |
+
"burned_out": false
|
| 882 |
+
}
|
| 883 |
+
},
|
| 884 |
+
{
|
| 885 |
+
"id": "2026-04-05T10:50:55.301186+00:00",
|
| 886 |
+
"scenario": "Story Spammer",
|
| 887 |
+
"scenario_id": "story_spammer",
|
| 888 |
+
"task": "weekly_competitive",
|
| 889 |
+
"score": 0.1632,
|
| 890 |
+
"total_steps": 168,
|
| 891 |
+
"total_posts": 29,
|
| 892 |
+
"avg_reward": 0.1592,
|
| 893 |
+
"final": {
|
| 894 |
+
"energy": 0.87,
|
| 895 |
+
"hours_since_sleep": 2,
|
| 896 |
+
"sleep_debt": 0.0,
|
| 897 |
+
"followers": 10504,
|
| 898 |
+
"engagement_rate": 0.1285,
|
| 899 |
+
"burned_out": false
|
| 900 |
+
}
|
| 901 |
+
},
|
| 902 |
+
{
|
| 903 |
+
"id": "2026-04-05T10:50:55.310194+00:00",
|
| 904 |
+
"scenario": "Tag Exploiter",
|
| 905 |
+
"scenario_id": "tag_exploiter",
|
| 906 |
+
"task": "weekly_competitive",
|
| 907 |
+
"score": 0.2922,
|
| 908 |
+
"total_steps": 168,
|
| 909 |
+
"total_posts": 14,
|
| 910 |
+
"avg_reward": 0.2358,
|
| 911 |
+
"final": {
|
| 912 |
+
"energy": 1.0,
|
| 913 |
+
"hours_since_sleep": 1,
|
| 914 |
+
"sleep_debt": 0.0,
|
| 915 |
+
"followers": 13696,
|
| 916 |
+
"engagement_rate": 2.2487,
|
| 917 |
+
"burned_out": false
|
| 918 |
+
}
|
| 919 |
+
},
|
| 920 |
+
{
|
| 921 |
+
"id": "2026-04-05T10:50:55.320255+00:00",
|
| 922 |
+
"scenario": "Tag Explorer",
|
| 923 |
+
"scenario_id": "tag_explorer",
|
| 924 |
+
"task": "weekly_competitive",
|
| 925 |
+
"score": 0.8323,
|
| 926 |
+
"total_steps": 168,
|
| 927 |
+
"total_posts": 15,
|
| 928 |
+
"avg_reward": 0.2253,
|
| 929 |
+
"final": {
|
| 930 |
+
"energy": 0.94,
|
| 931 |
+
"hours_since_sleep": 2,
|
| 932 |
+
"sleep_debt": 0.0,
|
| 933 |
+
"followers": 11351,
|
| 934 |
+
"engagement_rate": 0.7735,
|
| 935 |
+
"burned_out": false
|
| 936 |
+
}
|
| 937 |
+
},
|
| 938 |
+
{
|
| 939 |
+
"id": "2026-04-05T10:50:55.333620+00:00",
|
| 940 |
+
"scenario": "Tech Niche",
|
| 941 |
+
"scenario_id": "tech_niche",
|
| 942 |
+
"task": "weekly_competitive",
|
| 943 |
+
"score": 0.2001,
|
| 944 |
+
"total_steps": 168,
|
| 945 |
+
"total_posts": 14,
|
| 946 |
+
"avg_reward": 0.215,
|
| 947 |
+
"final": {
|
| 948 |
+
"energy": 1.0,
|
| 949 |
+
"hours_since_sleep": 1,
|
| 950 |
+
"sleep_debt": 0.0,
|
| 951 |
+
"followers": 10770,
|
| 952 |
+
"engagement_rate": 0.533,
|
| 953 |
+
"burned_out": false
|
| 954 |
+
}
|
| 955 |
+
},
|
| 956 |
+
{
|
| 957 |
+
"id": "2026-04-05T10:50:55.343185+00:00",
|
| 958 |
+
"scenario": "Text Only",
|
| 959 |
+
"scenario_id": "text_only",
|
| 960 |
+
"task": "weekly_competitive",
|
| 961 |
+
"score": 0.1583,
|
| 962 |
+
"total_steps": 168,
|
| 963 |
+
"total_posts": 21,
|
| 964 |
+
"avg_reward": 0.1857,
|
| 965 |
+
"final": {
|
| 966 |
+
"energy": 1.0,
|
| 967 |
+
"hours_since_sleep": 1,
|
| 968 |
+
"sleep_debt": 0.0,
|
| 969 |
+
"followers": 10485,
|
| 970 |
+
"engagement_rate": 0.234,
|
| 971 |
+
"burned_out": false
|
| 972 |
+
}
|
| 973 |
+
},
|
| 974 |
+
{
|
| 975 |
+
"id": "2026-04-05T10:50:55.352680+00:00",
|
| 976 |
+
"scenario": "Travel Blogger",
|
| 977 |
+
"scenario_id": "travel",
|
| 978 |
+
"task": "weekly_competitive",
|
| 979 |
+
"score": 0.2975,
|
| 980 |
+
"total_steps": 168,
|
| 981 |
+
"total_posts": 14,
|
| 982 |
+
"avg_reward": 0.2307,
|
| 983 |
+
"final": {
|
| 984 |
+
"energy": 1.0,
|
| 985 |
+
"hours_since_sleep": 1,
|
| 986 |
+
"sleep_debt": 0.0,
|
| 987 |
+
"followers": 12749,
|
| 988 |
+
"engagement_rate": 1.9614,
|
| 989 |
+
"burned_out": false
|
| 990 |
+
}
|
| 991 |
+
},
|
| 992 |
+
{
|
| 993 |
+
"id": "2026-04-05T10:50:55.362329+00:00",
|
| 994 |
+
"scenario": "Trend Chaser",
|
| 995 |
+
"scenario_id": "trend_chaser",
|
| 996 |
+
"task": "weekly_competitive",
|
| 997 |
+
"score": 0.4344,
|
| 998 |
+
"total_steps": 168,
|
| 999 |
+
"total_posts": 14,
|
| 1000 |
+
"avg_reward": 0.2413,
|
| 1001 |
+
"final": {
|
| 1002 |
+
"energy": 1.0,
|
| 1003 |
+
"hours_since_sleep": 1,
|
| 1004 |
+
"sleep_debt": 0.0,
|
| 1005 |
+
"followers": 14148,
|
| 1006 |
+
"engagement_rate": 2.6985,
|
| 1007 |
+
"burned_out": false
|
| 1008 |
+
}
|
| 1009 |
+
},
|
| 1010 |
+
{
|
| 1011 |
+
"id": "2026-04-05T10:50:55.373024+00:00",
|
| 1012 |
+
"scenario": "Tuesday Thursday",
|
| 1013 |
+
"scenario_id": "tue_thu",
|
| 1014 |
+
"task": "weekly_competitive",
|
| 1015 |
+
"score": 0.1826,
|
| 1016 |
+
"total_steps": 168,
|
| 1017 |
+
"total_posts": 4,
|
| 1018 |
+
"avg_reward": 0.1731,
|
| 1019 |
+
"final": {
|
| 1020 |
+
"energy": 1.0,
|
| 1021 |
+
"hours_since_sleep": 1,
|
| 1022 |
+
"sleep_debt": 0.0,
|
| 1023 |
+
"followers": 9154,
|
| 1024 |
+
"engagement_rate": 3.4748,
|
| 1025 |
+
"burned_out": false
|
| 1026 |
+
}
|
| 1027 |
+
},
|
| 1028 |
+
{
|
| 1029 |
+
"id": "2026-04-05T10:50:55.382708+00:00",
|
| 1030 |
+
"scenario": "Weekday Only",
|
| 1031 |
+
"scenario_id": "weekday_only",
|
| 1032 |
+
"task": "weekly_competitive",
|
| 1033 |
+
"score": 0.2366,
|
| 1034 |
+
"total_steps": 168,
|
| 1035 |
+
"total_posts": 10,
|
| 1036 |
+
"avg_reward": 0.2046,
|
| 1037 |
+
"final": {
|
| 1038 |
+
"energy": 1.0,
|
| 1039 |
+
"hours_since_sleep": 1,
|
| 1040 |
+
"sleep_debt": 0.0,
|
| 1041 |
+
"followers": 9810,
|
| 1042 |
+
"engagement_rate": 1.0028,
|
| 1043 |
+
"burned_out": false
|
| 1044 |
+
}
|
| 1045 |
+
},
|
| 1046 |
+
{
|
| 1047 |
+
"id": "2026-04-05T10:50:55.392284+00:00",
|
| 1048 |
+
"scenario": "Weekend Warrior",
|
| 1049 |
+
"scenario_id": "weekend",
|
| 1050 |
+
"task": "weekly_competitive",
|
| 1051 |
+
"score": 0.1257,
|
| 1052 |
+
"total_steps": 168,
|
| 1053 |
+
"total_posts": 6,
|
| 1054 |
+
"avg_reward": 0.1648,
|
| 1055 |
+
"final": {
|
| 1056 |
+
"energy": 1.0,
|
| 1057 |
+
"hours_since_sleep": 1,
|
| 1058 |
+
"sleep_debt": 0.0,
|
| 1059 |
+
"followers": 7659,
|
| 1060 |
+
"engagement_rate": 0.635,
|
| 1061 |
+
"burned_out": false
|
| 1062 |
+
}
|
| 1063 |
+
},
|
| 1064 |
+
{
|
| 1065 |
+
"id": "2026-04-05T10:51:44.770556+00:00",
|
| 1066 |
+
"scenario": "Aggressive Energy",
|
| 1067 |
+
"scenario_id": "aggressive",
|
| 1068 |
+
"task": "weekly_competitive",
|
| 1069 |
+
"score": 0.8255,
|
| 1070 |
+
"total_steps": 168,
|
| 1071 |
+
"total_posts": 29,
|
| 1072 |
+
"avg_reward": 0.1875,
|
| 1073 |
+
"final": {
|
| 1074 |
+
"energy": 0.75,
|
| 1075 |
+
"hours_since_sleep": 2,
|
| 1076 |
+
"sleep_debt": 0.0,
|
| 1077 |
+
"followers": 13021,
|
| 1078 |
+
"engagement_rate": 0.8084,
|
| 1079 |
+
"burned_out": false
|
| 1080 |
+
}
|
| 1081 |
+
},
|
| 1082 |
+
{
|
| 1083 |
+
"id": "2026-04-06T14:25:47.636598+00:00",
|
| 1084 |
+
"scenario": "Sleep Respecting",
|
| 1085 |
+
"scenario_id": "sleep_respecting",
|
| 1086 |
+
"task": "weekly_competitive",
|
| 1087 |
+
"score": 0.3623,
|
| 1088 |
+
"total_steps": 168,
|
| 1089 |
+
"total_posts": 14,
|
| 1090 |
+
"avg_reward": 0.2264,
|
| 1091 |
+
"final": {
|
| 1092 |
+
"energy": 1.0,
|
| 1093 |
+
"hours_since_sleep": 1,
|
| 1094 |
+
"sleep_debt": 0.0,
|
| 1095 |
+
"followers": 11322,
|
| 1096 |
+
"engagement_rate": 0.8914,
|
| 1097 |
+
"burned_out": false
|
| 1098 |
+
}
|
| 1099 |
+
},
|
| 1100 |
+
{
|
| 1101 |
+
"id": "2026-04-06T14:26:41.631567+00:00",
|
| 1102 |
+
"scenario": "Creator Economy",
|
| 1103 |
+
"scenario_id": "creator_economy",
|
| 1104 |
+
"task": "weekly_competitive",
|
| 1105 |
+
"score": 0.2515,
|
| 1106 |
+
"total_steps": 168,
|
| 1107 |
+
"total_posts": 14,
|
| 1108 |
+
"avg_reward": 0.2226,
|
| 1109 |
+
"final": {
|
| 1110 |
+
"energy": 1.0,
|
| 1111 |
+
"hours_since_sleep": 1,
|
| 1112 |
+
"sleep_debt": 0.0,
|
| 1113 |
+
"followers": 11994,
|
| 1114 |
+
"engagement_rate": 1.3918,
|
| 1115 |
+
"burned_out": false
|
| 1116 |
+
}
|
| 1117 |
+
},
|
| 1118 |
+
{
|
| 1119 |
+
"id": "2026-04-06T14:27:32.195059+00:00",
|
| 1120 |
+
"scenario": "Weekday Only",
|
| 1121 |
+
"scenario_id": "weekday_only",
|
| 1122 |
+
"task": "weekly_competitive",
|
| 1123 |
+
"score": 0.2366,
|
| 1124 |
+
"total_steps": 168,
|
| 1125 |
+
"total_posts": 10,
|
| 1126 |
+
"avg_reward": 0.2046,
|
| 1127 |
+
"final": {
|
| 1128 |
+
"energy": 1.0,
|
| 1129 |
+
"hours_since_sleep": 1,
|
| 1130 |
+
"sleep_debt": 0.0,
|
| 1131 |
+
"followers": 9810,
|
| 1132 |
+
"engagement_rate": 1.0028,
|
| 1133 |
+
"burned_out": false
|
| 1134 |
+
}
|
| 1135 |
+
},
|
| 1136 |
+
{
|
| 1137 |
+
"id": "2026-04-06T14:28:12.547146+00:00",
|
| 1138 |
+
"scenario": "Weekday Only",
|
| 1139 |
+
"scenario_id": "weekday_only",
|
| 1140 |
+
"task": "weekly_competitive",
|
| 1141 |
+
"score": 0.2366,
|
| 1142 |
+
"total_steps": 168,
|
| 1143 |
+
"total_posts": 10,
|
| 1144 |
+
"avg_reward": 0.2046,
|
| 1145 |
+
"final": {
|
| 1146 |
+
"energy": 1.0,
|
| 1147 |
+
"hours_since_sleep": 1,
|
| 1148 |
+
"sleep_debt": 0.0,
|
| 1149 |
+
"followers": 9810,
|
| 1150 |
+
"engagement_rate": 1.0028,
|
| 1151 |
+
"burned_out": false
|
| 1152 |
+
}
|
| 1153 |
+
},
|
| 1154 |
+
{
|
| 1155 |
+
"id": "2026-04-06T14:29:19.356814+00:00",
|
| 1156 |
+
"scenario": "No Rest",
|
| 1157 |
+
"scenario_id": "no_rest",
|
| 1158 |
+
"task": "weekly_engage",
|
| 1159 |
+
"score": 0.027,
|
| 1160 |
+
"total_steps": 8,
|
| 1161 |
+
"total_posts": 8,
|
| 1162 |
+
"avg_reward": 0.2686,
|
| 1163 |
+
"final": {
|
| 1164 |
+
"energy": 0.0,
|
| 1165 |
+
"hours_since_sleep": 10,
|
| 1166 |
+
"sleep_debt": 0.0,
|
| 1167 |
+
"followers": 10213,
|
| 1168 |
+
"engagement_rate": 0.2732,
|
| 1169 |
+
"burned_out": true
|
| 1170 |
+
}
|
| 1171 |
+
},
|
| 1172 |
+
{
|
| 1173 |
+
"id": "2026-04-06T14:29:21.996045+00:00",
|
| 1174 |
+
"scenario": "No Rest",
|
| 1175 |
+
"scenario_id": "no_rest",
|
| 1176 |
+
"task": "weekly_engage",
|
| 1177 |
+
"score": 0.027,
|
| 1178 |
+
"total_steps": 8,
|
| 1179 |
+
"total_posts": 8,
|
| 1180 |
+
"avg_reward": 0.2686,
|
| 1181 |
+
"final": {
|
| 1182 |
+
"energy": 0.0,
|
| 1183 |
+
"hours_since_sleep": 10,
|
| 1184 |
+
"sleep_debt": 0.0,
|
| 1185 |
+
"followers": 10213,
|
| 1186 |
+
"engagement_rate": 0.2732,
|
| 1187 |
+
"burned_out": true
|
| 1188 |
+
}
|
| 1189 |
+
},
|
| 1190 |
+
{
|
| 1191 |
+
"id": "2026-04-06T14:29:33.742894+00:00",
|
| 1192 |
+
"scenario": "Text Only",
|
| 1193 |
+
"scenario_id": "text_only",
|
| 1194 |
+
"task": "weekly_engage",
|
| 1195 |
+
"score": 0.2049,
|
| 1196 |
+
"total_steps": 168,
|
| 1197 |
+
"total_posts": 21,
|
| 1198 |
+
"avg_reward": 0.1857,
|
| 1199 |
+
"final": {
|
| 1200 |
+
"energy": 1.0,
|
| 1201 |
+
"hours_since_sleep": 1,
|
| 1202 |
+
"sleep_debt": 0.0,
|
| 1203 |
+
"followers": 10485,
|
| 1204 |
+
"engagement_rate": 0.234,
|
| 1205 |
+
"burned_out": false
|
| 1206 |
+
}
|
| 1207 |
+
},
|
| 1208 |
+
{
|
| 1209 |
+
"id": "2026-04-06T14:29:39.176314+00:00",
|
| 1210 |
+
"scenario": "Gaming Niche",
|
| 1211 |
+
"scenario_id": "gaming_niche",
|
| 1212 |
+
"task": "weekly_engage",
|
| 1213 |
+
"score": 0.5658,
|
| 1214 |
+
"total_steps": 168,
|
| 1215 |
+
"total_posts": 14,
|
| 1216 |
+
"avg_reward": 0.2062,
|
| 1217 |
+
"final": {
|
| 1218 |
+
"energy": 1.0,
|
| 1219 |
+
"hours_since_sleep": 1,
|
| 1220 |
+
"sleep_debt": 0.0,
|
| 1221 |
+
"followers": 11364,
|
| 1222 |
+
"engagement_rate": 0.9138,
|
| 1223 |
+
"burned_out": false
|
| 1224 |
+
}
|
| 1225 |
+
},
|
| 1226 |
+
{
|
| 1227 |
+
"id": "2026-04-06T14:29:50.321368+00:00",
|
| 1228 |
+
"scenario": "Midday Focus",
|
| 1229 |
+
"scenario_id": "midday",
|
| 1230 |
+
"task": "weekly_engage",
|
| 1231 |
+
"score": 1.0,
|
| 1232 |
+
"total_steps": 168,
|
| 1233 |
+
"total_posts": 14,
|
| 1234 |
+
"avg_reward": 0.2306,
|
| 1235 |
+
"final": {
|
| 1236 |
+
"energy": 1.0,
|
| 1237 |
+
"hours_since_sleep": 1,
|
| 1238 |
+
"sleep_debt": 0.0,
|
| 1239 |
+
"followers": 13537,
|
| 1240 |
+
"engagement_rate": 2.3076,
|
| 1241 |
+
"burned_out": false
|
| 1242 |
+
}
|
| 1243 |
+
},
|
| 1244 |
+
{
|
| 1245 |
+
"id": "2026-04-06T17:52:48.224991+00:00",
|
| 1246 |
+
"scenario": "Double Peak",
|
| 1247 |
+
"scenario_id": "double_peak",
|
| 1248 |
+
"task": "weekly_competitive",
|
| 1249 |
+
"score": 0.4519,
|
| 1250 |
+
"total_steps": 168,
|
| 1251 |
+
"total_posts": 14,
|
| 1252 |
+
"avg_reward": 0.2352,
|
| 1253 |
+
"final": {
|
| 1254 |
+
"energy": 1.0,
|
| 1255 |
+
"hours_since_sleep": 1,
|
| 1256 |
+
"sleep_debt": 0.0,
|
| 1257 |
+
"followers": 13138,
|
| 1258 |
+
"engagement_rate": 2.0814,
|
| 1259 |
+
"burned_out": false
|
| 1260 |
+
}
|
| 1261 |
+
},
|
| 1262 |
+
{
|
| 1263 |
+
"id": "2026-04-06T17:53:45.401024+00:00",
|
| 1264 |
+
"scenario": "Photography Focus",
|
| 1265 |
+
"scenario_id": "photography",
|
| 1266 |
+
"task": "weekly_competitive",
|
| 1267 |
+
"score": 0.1838,
|
| 1268 |
+
"total_steps": 168,
|
| 1269 |
+
"total_posts": 16,
|
| 1270 |
+
"avg_reward": 0.22,
|
| 1271 |
+
"final": {
|
| 1272 |
+
"energy": 0.5,
|
| 1273 |
+
"hours_since_sleep": 3,
|
| 1274 |
+
"sleep_debt": 0.0,
|
| 1275 |
+
"followers": 10736,
|
| 1276 |
+
"engagement_rate": 0.4388,
|
| 1277 |
+
"burned_out": false
|
| 1278 |
+
}
|
| 1279 |
+
},
|
| 1280 |
+
{
|
| 1281 |
+
"id": "2026-04-06T17:54:16.540951+00:00",
|
| 1282 |
+
"scenario": "Burst Poster",
|
| 1283 |
+
"scenario_id": "burst",
|
| 1284 |
+
"task": "weekly_competitive",
|
| 1285 |
+
"score": 0.6111,
|
| 1286 |
+
"total_steps": 168,
|
| 1287 |
+
"total_posts": 57,
|
| 1288 |
+
"avg_reward": 0.2318,
|
| 1289 |
+
"final": {
|
| 1290 |
+
"energy": 0.44,
|
| 1291 |
+
"hours_since_sleep": 1,
|
| 1292 |
+
"sleep_debt": 0.0,
|
| 1293 |
+
"followers": 11701,
|
| 1294 |
+
"engagement_rate": 0.2076,
|
| 1295 |
+
"burned_out": false
|
| 1296 |
+
}
|
| 1297 |
+
},
|
| 1298 |
+
{
|
| 1299 |
+
"id": "2026-04-06T17:54:39.699482+00:00",
|
| 1300 |
+
"scenario": "Engagement Chaser",
|
| 1301 |
+
"scenario_id": "engagement_chaser",
|
| 1302 |
+
"task": "weekly_competitive",
|
| 1303 |
+
"score": 0.4194,
|
| 1304 |
+
"total_steps": 168,
|
| 1305 |
+
"total_posts": 21,
|
| 1306 |
+
"avg_reward": 0.2224,
|
| 1307 |
+
"final": {
|
| 1308 |
+
"energy": 1.0,
|
| 1309 |
+
"hours_since_sleep": 1,
|
| 1310 |
+
"sleep_debt": 0.0,
|
| 1311 |
+
"followers": 15287,
|
| 1312 |
+
"engagement_rate": 2.2466,
|
| 1313 |
+
"burned_out": false
|
| 1314 |
+
}
|
| 1315 |
+
},
|
| 1316 |
+
{
|
| 1317 |
+
"id": "2026-04-06T18:09:31.470202+00:00",
|
| 1318 |
+
"scenario": "Lifestyle Niche",
|
| 1319 |
+
"scenario_id": "lifestyle_niche",
|
| 1320 |
+
"task": "weekly_competitive",
|
| 1321 |
+
"score": 0.2612,
|
| 1322 |
+
"total_steps": 168,
|
| 1323 |
+
"total_posts": 14,
|
| 1324 |
+
"avg_reward": 0.2288,
|
| 1325 |
+
"final": {
|
| 1326 |
+
"energy": 1.0,
|
| 1327 |
+
"hours_since_sleep": 1,
|
| 1328 |
+
"sleep_debt": 0.0,
|
| 1329 |
+
"followers": 12251,
|
| 1330 |
+
"engagement_rate": 1.6295,
|
| 1331 |
+
"burned_out": false
|
| 1332 |
+
}
|
| 1333 |
+
},
|
| 1334 |
+
{
|
| 1335 |
+
"id": "2026-04-06T18:09:42.791462+00:00",
|
| 1336 |
+
"scenario": "Content Creator",
|
| 1337 |
+
"scenario_id": "content_creator",
|
| 1338 |
+
"task": "weekly_competitive",
|
| 1339 |
+
"score": 0.6434,
|
| 1340 |
+
"total_steps": 168,
|
| 1341 |
+
"total_posts": 12,
|
| 1342 |
+
"avg_reward": 0.2065,
|
| 1343 |
+
"final": {
|
| 1344 |
+
"energy": 0.309,
|
| 1345 |
+
"hours_since_sleep": 28,
|
| 1346 |
+
"sleep_debt": 0.017,
|
| 1347 |
+
"followers": 10931,
|
| 1348 |
+
"engagement_rate": 0.525,
|
| 1349 |
+
"burned_out": false
|
| 1350 |
+
}
|
| 1351 |
+
},
|
| 1352 |
+
{
|
| 1353 |
+
"id": "2026-04-06T18:25:35.360345+00:00",
|
| 1354 |
+
"scenario": "Anti-Trend",
|
| 1355 |
+
"scenario_id": "anti_trend",
|
| 1356 |
+
"task": "weekly_competitive",
|
| 1357 |
+
"score": 0.2316,
|
| 1358 |
+
"total_steps": 168,
|
| 1359 |
+
"total_posts": 14,
|
| 1360 |
+
"avg_reward": 0.2201,
|
| 1361 |
+
"final": {
|
| 1362 |
+
"energy": 1.0,
|
| 1363 |
+
"hours_since_sleep": 1,
|
| 1364 |
+
"sleep_debt": 0.0,
|
| 1365 |
+
"followers": 11125,
|
| 1366 |
+
"engagement_rate": 0.747,
|
| 1367 |
+
"burned_out": false
|
| 1368 |
+
}
|
| 1369 |
+
},
|
| 1370 |
+
{
|
| 1371 |
+
"id": "2026-04-06T18:28:21.455943+00:00",
|
| 1372 |
+
"scenario": "Fashion Content",
|
| 1373 |
+
"scenario_id": "fashion",
|
| 1374 |
+
"task": "weekly_competitive",
|
| 1375 |
+
"score": 0.2181,
|
| 1376 |
+
"total_steps": 168,
|
| 1377 |
+
"total_posts": 14,
|
| 1378 |
+
"avg_reward": 0.2147,
|
| 1379 |
+
"final": {
|
| 1380 |
+
"energy": 1.0,
|
| 1381 |
+
"hours_since_sleep": 1,
|
| 1382 |
+
"sleep_debt": 0.0,
|
| 1383 |
+
"followers": 11135,
|
| 1384 |
+
"engagement_rate": 0.7898,
|
| 1385 |
+
"burned_out": false
|
| 1386 |
+
}
|
| 1387 |
+
},
|
| 1388 |
+
{
|
| 1389 |
+
"id": "2026-04-06T18:28:26.860641+00:00",
|
| 1390 |
+
"scenario": "Low Frequency",
|
| 1391 |
+
"scenario_id": "low_freq",
|
| 1392 |
+
"task": "weekly_competitive",
|
| 1393 |
+
"score": 0.3241,
|
| 1394 |
+
"total_steps": 168,
|
| 1395 |
+
"total_posts": 4,
|
| 1396 |
+
"avg_reward": 0.1768,
|
| 1397 |
+
"final": {
|
| 1398 |
+
"energy": 1.0,
|
| 1399 |
+
"hours_since_sleep": 1,
|
| 1400 |
+
"sleep_debt": 0.0,
|
| 1401 |
+
"followers": 10461,
|
| 1402 |
+
"engagement_rate": 1.1563,
|
| 1403 |
+
"burned_out": false
|
| 1404 |
+
}
|
| 1405 |
+
},
|
| 1406 |
+
{
|
| 1407 |
+
"id": "2026-04-06T18:28:36.279972+00:00",
|
| 1408 |
+
"scenario": "Balanced Creator",
|
| 1409 |
+
"scenario_id": "balanced",
|
| 1410 |
+
"task": "weekly_competitive",
|
| 1411 |
+
"score": 0.8775,
|
| 1412 |
+
"total_steps": 168,
|
| 1413 |
+
"total_posts": 28,
|
| 1414 |
+
"avg_reward": 0.2187,
|
| 1415 |
+
"final": {
|
| 1416 |
+
"energy": 1.0,
|
| 1417 |
+
"hours_since_sleep": 2,
|
| 1418 |
+
"sleep_debt": 0.0,
|
| 1419 |
+
"followers": 12534,
|
| 1420 |
+
"engagement_rate": 0.8273,
|
| 1421 |
+
"burned_out": false
|
| 1422 |
+
}
|
| 1423 |
+
},
|
| 1424 |
+
{
|
| 1425 |
+
"id": "2026-04-06T18:29:19.542258+00:00",
|
| 1426 |
+
"scenario": "Napper",
|
| 1427 |
+
"scenario_id": "napper",
|
| 1428 |
+
"task": "weekly_competitive",
|
| 1429 |
+
"score": 0.3623,
|
| 1430 |
+
"total_steps": 168,
|
| 1431 |
+
"total_posts": 14,
|
| 1432 |
+
"avg_reward": 0.2264,
|
| 1433 |
+
"final": {
|
| 1434 |
+
"energy": 1.0,
|
| 1435 |
+
"hours_since_sleep": 1,
|
| 1436 |
+
"sleep_debt": 0.0,
|
| 1437 |
+
"followers": 11322,
|
| 1438 |
+
"engagement_rate": 0.8914,
|
| 1439 |
+
"burned_out": false
|
| 1440 |
+
}
|
| 1441 |
+
},
|
| 1442 |
+
{
|
| 1443 |
+
"id": "2026-04-06T19:48:37.931282+00:00",
|
| 1444 |
+
"scenario": "Optimal Sleep",
|
| 1445 |
+
"scenario_id": "optimal_sleep",
|
| 1446 |
+
"task": "weekly_competitive",
|
| 1447 |
+
"score": 0.3635,
|
| 1448 |
+
"total_steps": 168,
|
| 1449 |
+
"total_posts": 14,
|
| 1450 |
+
"avg_reward": 0.2257,
|
| 1451 |
+
"final": {
|
| 1452 |
+
"energy": 0.9,
|
| 1453 |
+
"hours_since_sleep": 3,
|
| 1454 |
+
"sleep_debt": 0.0,
|
| 1455 |
+
"followers": 11305,
|
| 1456 |
+
"engagement_rate": 0.8729,
|
| 1457 |
+
"burned_out": false
|
| 1458 |
+
}
|
| 1459 |
+
},
|
| 1460 |
+
{
|
| 1461 |
+
"id": "2026-04-06T19:49:01.327141+00:00",
|
| 1462 |
+
"scenario": "Marathon Runner",
|
| 1463 |
+
"scenario_id": "marathon",
|
| 1464 |
+
"task": "weekly_competitive",
|
| 1465 |
+
"score": 0.0,
|
| 1466 |
+
"total_steps": 50,
|
| 1467 |
+
"total_posts": 9,
|
| 1468 |
+
"avg_reward": 0.1323,
|
| 1469 |
+
"final": {
|
| 1470 |
+
"energy": 0.0,
|
| 1471 |
+
"hours_since_sleep": 22,
|
| 1472 |
+
"sleep_debt": 0.028,
|
| 1473 |
+
"followers": 10137,
|
| 1474 |
+
"engagement_rate": 0.157,
|
| 1475 |
+
"burned_out": true
|
| 1476 |
+
}
|
| 1477 |
+
},
|
| 1478 |
+
{
|
| 1479 |
+
"id": "2026-04-06T19:49:13.972097+00:00",
|
| 1480 |
+
"scenario": "Balanced Creator",
|
| 1481 |
+
"scenario_id": "balanced",
|
| 1482 |
+
"task": "weekly_competitive",
|
| 1483 |
+
"score": 0.8775,
|
| 1484 |
+
"total_steps": 168,
|
| 1485 |
+
"total_posts": 28,
|
| 1486 |
+
"avg_reward": 0.2187,
|
| 1487 |
+
"final": {
|
| 1488 |
+
"energy": 1.0,
|
| 1489 |
+
"hours_since_sleep": 2,
|
| 1490 |
+
"sleep_debt": 0.0,
|
| 1491 |
+
"followers": 12534,
|
| 1492 |
+
"engagement_rate": 0.8273,
|
| 1493 |
+
"burned_out": false
|
| 1494 |
+
}
|
| 1495 |
+
},
|
| 1496 |
+
{
|
| 1497 |
+
"id": "2026-04-06T19:49:37.864235+00:00",
|
| 1498 |
+
"scenario": "Engagement Chaser",
|
| 1499 |
+
"scenario_id": "engagement_chaser",
|
| 1500 |
+
"task": "weekly_competitive",
|
| 1501 |
+
"score": 0.4194,
|
| 1502 |
+
"total_steps": 168,
|
| 1503 |
+
"total_posts": 21,
|
| 1504 |
+
"avg_reward": 0.2224,
|
| 1505 |
+
"final": {
|
| 1506 |
+
"energy": 1.0,
|
| 1507 |
+
"hours_since_sleep": 1,
|
| 1508 |
+
"sleep_debt": 0.0,
|
| 1509 |
+
"followers": 15287,
|
| 1510 |
+
"engagement_rate": 2.2466,
|
| 1511 |
+
"burned_out": false
|
| 1512 |
+
}
|
| 1513 |
+
},
|
| 1514 |
+
{
|
| 1515 |
+
"id": "2026-04-06T19:50:08.348742+00:00",
|
| 1516 |
+
"scenario": "Early Bird",
|
| 1517 |
+
"scenario_id": "early_bird",
|
| 1518 |
+
"task": "weekly_competitive",
|
| 1519 |
+
"score": 0.2075,
|
| 1520 |
+
"total_steps": 168,
|
| 1521 |
+
"total_posts": 16,
|
| 1522 |
+
"avg_reward": 0.2284,
|
| 1523 |
+
"final": {
|
| 1524 |
+
"energy": 0.62,
|
| 1525 |
+
"hours_since_sleep": 2,
|
| 1526 |
+
"sleep_debt": 0.0,
|
| 1527 |
+
"followers": 10818,
|
| 1528 |
+
"engagement_rate": 0.4138,
|
| 1529 |
+
"burned_out": false
|
| 1530 |
+
}
|
| 1531 |
+
},
|
| 1532 |
+
{
|
| 1533 |
+
"id": "2026-04-06T19:50:15.765261+00:00",
|
| 1534 |
+
"scenario": "Queue Heavy",
|
| 1535 |
+
"scenario_id": "queue_heavy",
|
| 1536 |
+
"task": "weekly_competitive",
|
| 1537 |
+
"score": 0.1933,
|
| 1538 |
+
"total_steps": 168,
|
| 1539 |
+
"total_posts": 8,
|
| 1540 |
+
"avg_reward": 0.1923,
|
| 1541 |
+
"final": {
|
| 1542 |
+
"energy": 1.0,
|
| 1543 |
+
"hours_since_sleep": 1,
|
| 1544 |
+
"sleep_debt": 0.0,
|
| 1545 |
+
"followers": 9453,
|
| 1546 |
+
"engagement_rate": 0.781,
|
| 1547 |
+
"burned_out": false
|
| 1548 |
+
}
|
| 1549 |
+
},
|
| 1550 |
+
{
|
| 1551 |
+
"id": "2026-04-06T19:50:26.015235+00:00",
|
| 1552 |
+
"scenario": "Balanced Creator",
|
| 1553 |
+
"scenario_id": "balanced",
|
| 1554 |
+
"task": "weekly_competitive",
|
| 1555 |
+
"score": 0.8775,
|
| 1556 |
+
"total_steps": 168,
|
| 1557 |
+
"total_posts": 28,
|
| 1558 |
+
"avg_reward": 0.2187,
|
| 1559 |
+
"final": {
|
| 1560 |
+
"energy": 1.0,
|
| 1561 |
+
"hours_since_sleep": 2,
|
| 1562 |
+
"sleep_debt": 0.0,
|
| 1563 |
+
"followers": 12534,
|
| 1564 |
+
"engagement_rate": 0.8273,
|
| 1565 |
+
"burned_out": false
|
| 1566 |
+
}
|
| 1567 |
+
},
|
| 1568 |
+
{
|
| 1569 |
+
"id": "2026-04-06T19:50:30.364460+00:00",
|
| 1570 |
+
"scenario": "High Frequency",
|
| 1571 |
+
"scenario_id": "high_freq",
|
| 1572 |
+
"task": "weekly_competitive",
|
| 1573 |
+
"score": 0.8611,
|
| 1574 |
+
"total_steps": 168,
|
| 1575 |
+
"total_posts": 22,
|
| 1576 |
+
"avg_reward": 0.2058,
|
| 1577 |
+
"final": {
|
| 1578 |
+
"energy": 0.92,
|
| 1579 |
+
"hours_since_sleep": 2,
|
| 1580 |
+
"sleep_debt": 0.0,
|
| 1581 |
+
"followers": 12654,
|
| 1582 |
+
"engagement_rate": 1.079,
|
| 1583 |
+
"burned_out": false
|
| 1584 |
+
}
|
| 1585 |
+
},
|
| 1586 |
+
{
|
| 1587 |
+
"id": "2026-04-06T19:50:38.185556+00:00",
|
| 1588 |
+
"scenario": "Sleep Conscious",
|
| 1589 |
+
"scenario_id": "sleep_conscious",
|
| 1590 |
+
"task": "weekly_competitive",
|
| 1591 |
+
"score": 0.3635,
|
| 1592 |
+
"total_steps": 168,
|
| 1593 |
+
"total_posts": 14,
|
| 1594 |
+
"avg_reward": 0.2257,
|
| 1595 |
+
"final": {
|
| 1596 |
+
"energy": 0.9,
|
| 1597 |
+
"hours_since_sleep": 3,
|
| 1598 |
+
"sleep_debt": 0.0,
|
| 1599 |
+
"followers": 11305,
|
| 1600 |
+
"engagement_rate": 0.8729,
|
| 1601 |
+
"burned_out": false
|
| 1602 |
+
}
|
| 1603 |
+
},
|
| 1604 |
+
{
|
| 1605 |
+
"id": "2026-04-06T19:50:44.256241+00:00",
|
| 1606 |
+
"scenario": "Burst Poster",
|
| 1607 |
+
"scenario_id": "burst",
|
| 1608 |
+
"task": "weekly_competitive",
|
| 1609 |
+
"score": 0.6111,
|
| 1610 |
+
"total_steps": 168,
|
| 1611 |
+
"total_posts": 57,
|
| 1612 |
+
"avg_reward": 0.2318,
|
| 1613 |
+
"final": {
|
| 1614 |
+
"energy": 0.44,
|
| 1615 |
+
"hours_since_sleep": 1,
|
| 1616 |
+
"sleep_debt": 0.0,
|
| 1617 |
+
"followers": 11701,
|
| 1618 |
+
"engagement_rate": 0.2076,
|
| 1619 |
+
"burned_out": false
|
| 1620 |
+
}
|
| 1621 |
+
},
|
| 1622 |
+
{
|
| 1623 |
+
"id": "2026-04-06T19:51:00.755964+00:00",
|
| 1624 |
+
"scenario": "Queue Optimizer",
|
| 1625 |
+
"scenario_id": "queue_optimizer",
|
| 1626 |
+
"task": "weekly_competitive",
|
| 1627 |
+
"score": 0.352,
|
| 1628 |
+
"total_steps": 168,
|
| 1629 |
+
"total_posts": 14,
|
| 1630 |
+
"avg_reward": 0.2233,
|
| 1631 |
+
"final": {
|
| 1632 |
+
"energy": 1.0,
|
| 1633 |
+
"hours_since_sleep": 1,
|
| 1634 |
+
"sleep_debt": 0.0,
|
| 1635 |
+
"followers": 11215,
|
| 1636 |
+
"engagement_rate": 0.8701,
|
| 1637 |
+
"burned_out": false
|
| 1638 |
+
}
|
| 1639 |
+
},
|
| 1640 |
+
{
|
| 1641 |
+
"id": "2026-04-07T19:19:06.982475+00:00",
|
| 1642 |
+
"scenario": "Easy: Afternoon story",
|
| 1643 |
+
"scenario_id": "easy_relaxed",
|
| 1644 |
+
"task": "weekly_engage",
|
| 1645 |
+
"score": 0.0776,
|
| 1646 |
+
"total_steps": 168,
|
| 1647 |
+
"total_posts": 7,
|
| 1648 |
+
"avg_reward": 0.1885,
|
| 1649 |
+
"final": {
|
| 1650 |
+
"energy": 1.0,
|
| 1651 |
+
"hours_since_sleep": 1,
|
| 1652 |
+
"sleep_debt": 0.0,
|
| 1653 |
+
"followers": 10185,
|
| 1654 |
+
"engagement_rate": 0.2689,
|
| 1655 |
+
"burned_out": false
|
| 1656 |
+
}
|
| 1657 |
+
},
|
| 1658 |
+
{
|
| 1659 |
+
"id": "2026-04-07T19:25:22.760913+00:00",
|
| 1660 |
+
"scenario": "Medium: Reel + carousel day",
|
| 1661 |
+
"scenario_id": "medium_two_format",
|
| 1662 |
+
"task": "weekly_engage",
|
| 1663 |
+
"score": 1.0,
|
| 1664 |
+
"total_steps": 168,
|
| 1665 |
+
"total_posts": 14,
|
| 1666 |
+
"avg_reward": 0.2305,
|
| 1667 |
+
"final": {
|
| 1668 |
+
"energy": 1.0,
|
| 1669 |
+
"hours_since_sleep": 1,
|
| 1670 |
+
"sleep_debt": 0.0,
|
| 1671 |
+
"followers": 13498,
|
| 1672 |
+
"engagement_rate": 2.3223,
|
| 1673 |
+
"burned_out": false
|
| 1674 |
+
}
|
| 1675 |
+
},
|
| 1676 |
+
{
|
| 1677 |
+
"id": "2026-04-07T19:37:07.163654+00:00",
|
| 1678 |
+
"scenario": "Easy: Morning story",
|
| 1679 |
+
"scenario_id": "easy_morning_story",
|
| 1680 |
+
"task": "weekly_engage",
|
| 1681 |
+
"score": 0.1126,
|
| 1682 |
+
"total_steps": 168,
|
| 1683 |
+
"total_posts": 7,
|
| 1684 |
+
"avg_reward": 0.2064,
|
| 1685 |
+
"final": {
|
| 1686 |
+
"energy": 1.0,
|
| 1687 |
+
"hours_since_sleep": 1,
|
| 1688 |
+
"sleep_debt": 0.0,
|
| 1689 |
+
"followers": 10269,
|
| 1690 |
+
"engagement_rate": 0.3903,
|
| 1691 |
+
"burned_out": false
|
| 1692 |
+
}
|
| 1693 |
+
},
|
| 1694 |
+
{
|
| 1695 |
+
"id": "2026-04-07T19:37:08.936466+00:00",
|
| 1696 |
+
"scenario": "Easy: One text at 1pm",
|
| 1697 |
+
"scenario_id": "easy_one_a_day",
|
| 1698 |
+
"task": "weekly_engage",
|
| 1699 |
+
"score": 0.0992,
|
| 1700 |
+
"total_steps": 168,
|
| 1701 |
+
"total_posts": 7,
|
| 1702 |
+
"avg_reward": 0.1933,
|
| 1703 |
+
"final": {
|
| 1704 |
+
"energy": 1.0,
|
| 1705 |
+
"hours_since_sleep": 1,
|
| 1706 |
+
"sleep_debt": 0.0,
|
| 1707 |
+
"followers": 10239,
|
| 1708 |
+
"engagement_rate": 0.3439,
|
| 1709 |
+
"burned_out": false
|
| 1710 |
+
}
|
| 1711 |
+
},
|
| 1712 |
+
{
|
| 1713 |
+
"id": "2026-04-07T19:37:10.555676+00:00",
|
| 1714 |
+
"scenario": "Easy: Afternoon story",
|
| 1715 |
+
"scenario_id": "easy_relaxed",
|
| 1716 |
+
"task": "weekly_engage",
|
| 1717 |
+
"score": 0.0776,
|
| 1718 |
+
"total_steps": 168,
|
| 1719 |
+
"total_posts": 7,
|
| 1720 |
+
"avg_reward": 0.1885,
|
| 1721 |
+
"final": {
|
| 1722 |
+
"energy": 1.0,
|
| 1723 |
+
"hours_since_sleep": 1,
|
| 1724 |
+
"sleep_debt": 0.0,
|
| 1725 |
+
"followers": 10185,
|
| 1726 |
+
"engagement_rate": 0.2689,
|
| 1727 |
+
"burned_out": false
|
| 1728 |
+
}
|
| 1729 |
+
},
|
| 1730 |
+
{
|
| 1731 |
+
"id": "2026-04-07T19:37:12.240540+00:00",
|
| 1732 |
+
"scenario": "Medium: Create then post",
|
| 1733 |
+
"scenario_id": "medium_queue_cycle",
|
| 1734 |
+
"task": "weekly_engage",
|
| 1735 |
+
"score": 0.8459,
|
| 1736 |
+
"total_steps": 168,
|
| 1737 |
+
"total_posts": 14,
|
| 1738 |
+
"avg_reward": 0.2318,
|
| 1739 |
+
"final": {
|
| 1740 |
+
"energy": 1.0,
|
| 1741 |
+
"hours_since_sleep": 1,
|
| 1742 |
+
"sleep_debt": 0.0,
|
| 1743 |
+
"followers": 12045,
|
| 1744 |
+
"engagement_rate": 1.3511,
|
| 1745 |
+
"burned_out": false
|
| 1746 |
+
}
|
| 1747 |
+
},
|
| 1748 |
+
{
|
| 1749 |
+
"id": "2026-04-07T19:37:14.032300+00:00",
|
| 1750 |
+
"scenario": "Medium: Trend + format rotation",
|
| 1751 |
+
"scenario_id": "medium_trend_rotate",
|
| 1752 |
+
"task": "weekly_engage",
|
| 1753 |
+
"score": 0.5524,
|
| 1754 |
+
"total_steps": 168,
|
| 1755 |
+
"total_posts": 14,
|
| 1756 |
+
"avg_reward": 0.2265,
|
| 1757 |
+
"final": {
|
| 1758 |
+
"energy": 1.0,
|
| 1759 |
+
"hours_since_sleep": 1,
|
| 1760 |
+
"sleep_debt": 0.0,
|
| 1761 |
+
"followers": 11332,
|
| 1762 |
+
"engagement_rate": 0.9003,
|
| 1763 |
+
"burned_out": false
|
| 1764 |
+
}
|
| 1765 |
+
},
|
| 1766 |
+
{
|
| 1767 |
+
"id": "2026-04-07T19:37:15.697454+00:00",
|
| 1768 |
+
"scenario": "Medium: Reel + carousel day",
|
| 1769 |
+
"scenario_id": "medium_two_format",
|
| 1770 |
+
"task": "weekly_engage",
|
| 1771 |
+
"score": 1.0,
|
| 1772 |
+
"total_steps": 168,
|
| 1773 |
+
"total_posts": 14,
|
| 1774 |
+
"avg_reward": 0.2305,
|
| 1775 |
+
"final": {
|
| 1776 |
+
"energy": 1.0,
|
| 1777 |
+
"hours_since_sleep": 1,
|
| 1778 |
+
"sleep_debt": 0.0,
|
| 1779 |
+
"followers": 13498,
|
| 1780 |
+
"engagement_rate": 2.3223,
|
| 1781 |
+
"burned_out": false
|
| 1782 |
+
}
|
| 1783 |
+
},
|
| 1784 |
+
{
|
| 1785 |
+
"id": "2026-04-07T19:38:24.165792+00:00",
|
| 1786 |
+
"scenario": "Easy: One text at 1pm",
|
| 1787 |
+
"scenario_id": "easy_one_a_day",
|
| 1788 |
+
"task": "weekly_engage",
|
| 1789 |
+
"score": 0.0992,
|
| 1790 |
+
"total_steps": 168,
|
| 1791 |
+
"total_posts": 7,
|
| 1792 |
+
"avg_reward": 0.1933,
|
| 1793 |
+
"final": {
|
| 1794 |
+
"energy": 1.0,
|
| 1795 |
+
"hours_since_sleep": 1,
|
| 1796 |
+
"sleep_debt": 0.0,
|
| 1797 |
+
"followers": 10239,
|
| 1798 |
+
"engagement_rate": 0.3439,
|
| 1799 |
+
"burned_out": false
|
| 1800 |
+
}
|
| 1801 |
+
}
|
| 1802 |
+
]
|
server/viraltest_environment.py
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viraltest Environment — RL-Based Creator Optimization Simulation.
|
| 3 |
+
|
| 4 |
+
Simulates a social media creator's weekly posting lifecycle.
|
| 5 |
+
The agent decides when to post, what format, which tags, and how
|
| 6 |
+
to differentiate from competitors, while managing burnout.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import random
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
from uuid import uuid4
|
| 14 |
+
|
| 15 |
+
from openenv.core.env_server.interfaces import Environment
|
| 16 |
+
from openenv.core.env_server.types import State
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from ..models import ScheduledAction, ViraltestAction, ViraltestObservation
|
| 20 |
+
except ImportError:
|
| 21 |
+
from models import ScheduledAction, ViraltestAction, ViraltestObservation
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Constants (research-backed)
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
TASK_HORIZON = 7 # 7 daily steps (each step simulates 24 hours internally)
|
| 28 |
+
|
| 29 |
+
CONTENT_ENERGY_COST = {
|
| 30 |
+
"reel": 0.25,
|
| 31 |
+
"carousel": 0.20,
|
| 32 |
+
"story": 0.08,
|
| 33 |
+
"text_post": 0.06,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
BASE_ENGAGEMENT = {
|
| 37 |
+
"reel": 0.52,
|
| 38 |
+
"carousel": 0.55,
|
| 39 |
+
"story": 0.30,
|
| 40 |
+
"text_post": 0.37,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
REACH_MULT = {
|
| 44 |
+
"reel": 2.25,
|
| 45 |
+
"carousel": 1.0,
|
| 46 |
+
"story": 0.5,
|
| 47 |
+
"text_post": 0.44,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
TAG_POOL = [
|
| 51 |
+
# Tech
|
| 52 |
+
"ai", "ml", "coding", "startup", "saas", "devtools",
|
| 53 |
+
# Lifestyle
|
| 54 |
+
"fitness", "travel", "food", "wellness", "fashion", "photography",
|
| 55 |
+
# Trending (base set — rotated daily)
|
| 56 |
+
"summer", "worldcup", "election", "newyear", "oscars", "climate",
|
| 57 |
+
# Niche
|
| 58 |
+
"productivity", "minimalism", "stoic", "web3", "gaming", "crypto",
|
| 59 |
+
# Broad
|
| 60 |
+
"motivation", "tips", "howto", "viral", "trending", "growth",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
TOPIC_CATEGORIES = {
|
| 64 |
+
"tech": ["AI tools", "coding tips", "startup life", "tech news", "SaaS growth", "dev workflow"],
|
| 65 |
+
"lifestyle": ["fitness routine", "travel guide", "food recipe", "wellness tips", "fashion haul", "photo editing"],
|
| 66 |
+
"business": ["growth hacks", "marketing strategy", "creator economy", "monetization", "brand deals", "analytics"],
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
VALID_TASKS = ("weekly_engage", "weekly_strategic", "weekly_competitive")
|
| 70 |
+
|
| 71 |
+
# Hour multipliers (Buffer 9.6M post study)
|
| 72 |
+
PEAK_HOURS = {
|
| 73 |
+
"weekday_morning": (9, 12, 1.3),
|
| 74 |
+
"weekday_peak": (12, 15, 1.4),
|
| 75 |
+
"evening": (18, 20, 1.25),
|
| 76 |
+
"late_evening": (20, 23, 1.1),
|
| 77 |
+
"night": (23, 6, 0.5),
|
| 78 |
+
"off_hours": (6, 9, 0.8),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
WEEKEND_PENALTY = 0.7
|
| 82 |
+
PEAK_DAYS = (1, 2, 3) # Tue, Wed, Thu (0=Mon)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class CompetitorState:
|
| 87 |
+
name: str
|
| 88 |
+
niche_topics: List[str]
|
| 89 |
+
preferred_types: List[str]
|
| 90 |
+
posting_frequency: float
|
| 91 |
+
base_engagement: float
|
| 92 |
+
tag_preferences: List[str]
|
| 93 |
+
recent_posts: List[Dict[str, Any]] = field(default_factory=list)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
COMPETITOR_PROFILES = [
|
| 97 |
+
{
|
| 98 |
+
"name": "creator_alpha",
|
| 99 |
+
"niche_topics": ["AI tools", "coding tips", "tech news"],
|
| 100 |
+
"preferred_types": ["reel", "carousel"],
|
| 101 |
+
"posting_frequency": 2.5,
|
| 102 |
+
"base_engagement": 0.45,
|
| 103 |
+
"tag_preferences": ["ai", "coding", "tech news"],
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"name": "creator_beta",
|
| 107 |
+
"niche_topics": ["growth hacks", "marketing strategy", "creator economy"],
|
| 108 |
+
"preferred_types": ["carousel", "text_post"],
|
| 109 |
+
"posting_frequency": 1.8,
|
| 110 |
+
"base_engagement": 0.40,
|
| 111 |
+
"tag_preferences": ["growth", "tips", "viral"],
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"name": "creator_gamma",
|
| 115 |
+
"niche_topics": ["fitness routine", "wellness tips", "motivation"],
|
| 116 |
+
"preferred_types": ["reel", "story"],
|
| 117 |
+
"posting_frequency": 3.0,
|
| 118 |
+
"base_engagement": 0.38,
|
| 119 |
+
"tag_preferences": ["fitness", "wellness", "motivation"],
|
| 120 |
+
},
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
INITIAL_FOLLOWERS = 10000
|
| 124 |
+
REST_RECOVERY = 0.12
|
| 125 |
+
CREATE_CONTENT_COST = 0.05
|
| 126 |
+
REPETITION_ENERGY_PENALTY = 0.05
|
| 127 |
+
AUDIENCE_FATIGUE_THRESHOLD_1 = 3
|
| 128 |
+
AUDIENCE_FATIGUE_THRESHOLD_2 = 5
|
| 129 |
+
FOLLOWER_DECAY_HOURS = 48
|
| 130 |
+
ALGORITHM_PENALTY_MULT = 0.6
|
| 131 |
+
ALGORITHM_PENALTY_DURATION = 2
|
| 132 |
+
|
| 133 |
+
# Sleep mechanics (research-backed: Frontiers Neuroscience 2025, Frontiers Human Neuroscience 2014)
|
| 134 |
+
# - Cognitive performance follows a continuous decay curve, not step functions
|
| 135 |
+
# - Full night deprivation (~24hrs) impairs performance by ~50%
|
| 136 |
+
# - Uses exponential decay: quality = 1.0 * (0.5 ^ ((hours - optimal) / halflife))
|
| 137 |
+
SLEEP_OPTIMAL_AWAKE = 14 # Hours awake with no performance impact
|
| 138 |
+
SLEEP_HALFLIFE_HOURS = 10 # Hours beyond optimal for quality to halve
|
| 139 |
+
SLEEP_MIN_QUALITY = 0.30 # Floor for sleep-based quality (can't go below 30%)
|
| 140 |
+
SLEEP_ENERGY_DRAIN_START = 16 # Hours awake before extra energy drain kicks in
|
| 141 |
+
SLEEP_ENERGY_DRAIN_RATE = 0.015 # Energy drain per hour when sleep deprived
|
| 142 |
+
SLEEP_RECOVERY_PER_REST = 2 # Hours of "sleep credit" per rest action (rest = nap)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# Environment
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
class ViraltestEnvironment(Environment):
|
| 150 |
+
"""
|
| 151 |
+
Weekly creator optimization simulation.
|
| 152 |
+
|
| 153 |
+
The agent manages a social media creator's posting strategy over 7 days
|
| 154 |
+
(168 hourly steps), balancing engagement, energy, tags, and competition.
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
SUPPORTS_CONCURRENT_SESSIONS: bool = True
|
| 158 |
+
|
| 159 |
+
def __init__(self) -> None:
|
| 160 |
+
self._state = State(episode_id=str(uuid4()), step_count=0)
|
| 161 |
+
self._task = "weekly_engage"
|
| 162 |
+
self._rng = random.Random(42)
|
| 163 |
+
self._init_state()
|
| 164 |
+
|
| 165 |
+
def _init_state(self) -> None:
|
| 166 |
+
self._energy = 1.0
|
| 167 |
+
self._followers = INITIAL_FOLLOWERS
|
| 168 |
+
self._initial_followers = INITIAL_FOLLOWERS
|
| 169 |
+
self._hour = 9
|
| 170 |
+
self._day = 0 # 0=Mon
|
| 171 |
+
self._posts_today = 0
|
| 172 |
+
self._last_post_types: List[str] = []
|
| 173 |
+
self._time_since_last_post = 0
|
| 174 |
+
self._engagement_history: List[float] = []
|
| 175 |
+
self._tag_history: Dict[str, List[float]] = defaultdict(list)
|
| 176 |
+
self._content_queue = 0
|
| 177 |
+
self._unique_tags_used: set = set()
|
| 178 |
+
self._unique_content_types: set = set()
|
| 179 |
+
self._energy_history: List[float] = [1.0]
|
| 180 |
+
self._posting_steps = 0
|
| 181 |
+
self._episode_done = False
|
| 182 |
+
self._last_topic: Optional[str] = None
|
| 183 |
+
self._final_observation: Optional[ViraltestObservation] = None
|
| 184 |
+
self._unique_topic_steps = 0
|
| 185 |
+
self._days_with_good_posts: set = set()
|
| 186 |
+
self._total_engagement = 0.0
|
| 187 |
+
self._posts_per_day: Dict[int, int] = defaultdict(int)
|
| 188 |
+
self._algorithm_penalty_remaining = 0
|
| 189 |
+
|
| 190 |
+
self._trending_topics = self._pick_trending_topics()
|
| 191 |
+
self._trending_tags = self._pick_trending_tags()
|
| 192 |
+
self._competitors = [CompetitorState(**p) for p in COMPETITOR_PROFILES]
|
| 193 |
+
|
| 194 |
+
# Sleep state: creator starts well-rested at 9am (awake since ~7am)
|
| 195 |
+
self._hours_since_sleep = 2 # Woke up 2 hours ago at start (9am)
|
| 196 |
+
self._sleep_debt = 0.0 # 0 = fully rested, 1 = severe deprivation
|
| 197 |
+
|
| 198 |
+
# ----- trend rotation -----
|
| 199 |
+
|
| 200 |
+
def _pick_trending_topics(self) -> List[str]:
|
| 201 |
+
all_topics = []
|
| 202 |
+
for cat_topics in TOPIC_CATEGORIES.values():
|
| 203 |
+
all_topics.extend(cat_topics)
|
| 204 |
+
return self._rng.sample(all_topics, min(3, len(all_topics)))
|
| 205 |
+
|
| 206 |
+
def _pick_trending_tags(self) -> List[str]:
|
| 207 |
+
return self._rng.sample(TAG_POOL, min(5, len(TAG_POOL)))
|
| 208 |
+
|
| 209 |
+
def _rotate_trends(self) -> None:
|
| 210 |
+
self._trending_topics = self._pick_trending_topics()
|
| 211 |
+
self._trending_tags = self._pick_trending_tags()
|
| 212 |
+
|
| 213 |
+
# ----- hour multiplier -----
|
| 214 |
+
|
| 215 |
+
def _get_hour_multiplier(self) -> float:
|
| 216 |
+
h = self._hour
|
| 217 |
+
d = self._day
|
| 218 |
+
|
| 219 |
+
is_weekend = d >= 5
|
| 220 |
+
base = WEEKEND_PENALTY if is_weekend else 1.0
|
| 221 |
+
|
| 222 |
+
if 12 <= h < 15 and d in PEAK_DAYS:
|
| 223 |
+
return base * 1.4
|
| 224 |
+
if 9 <= h < 12:
|
| 225 |
+
return base * 1.3
|
| 226 |
+
if 18 <= h < 20:
|
| 227 |
+
return base * 1.25
|
| 228 |
+
if 20 <= h < 23:
|
| 229 |
+
return base * 1.1
|
| 230 |
+
if h >= 23 or h < 6:
|
| 231 |
+
return base * 0.5
|
| 232 |
+
return base * 0.8
|
| 233 |
+
|
| 234 |
+
# ----- quality -----
|
| 235 |
+
|
| 236 |
+
def _get_quality_modifier(self) -> float:
|
| 237 |
+
"""
|
| 238 |
+
Quality affected by both energy and sleep debt.
|
| 239 |
+
|
| 240 |
+
Sleep uses exponential decay curve (not step function):
|
| 241 |
+
- No impact until SLEEP_OPTIMAL_AWAKE hours (14hrs)
|
| 242 |
+
- Then: quality = 0.5 ^ ((hours - optimal) / halflife)
|
| 243 |
+
- At 24hrs awake: ~50% quality (matches research)
|
| 244 |
+
- Floor at SLEEP_MIN_QUALITY (30%)
|
| 245 |
+
"""
|
| 246 |
+
# Energy component (existing logic)
|
| 247 |
+
if self._energy > 0.5:
|
| 248 |
+
energy_factor = 1.0
|
| 249 |
+
else:
|
| 250 |
+
energy_factor = max(0.48, self._energy * 1.5)
|
| 251 |
+
|
| 252 |
+
# Sleep component - exponential decay curve
|
| 253 |
+
if self._hours_since_sleep <= SLEEP_OPTIMAL_AWAKE:
|
| 254 |
+
sleep_factor = 1.0
|
| 255 |
+
else:
|
| 256 |
+
hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
|
| 257 |
+
# Exponential decay: halves every SLEEP_HALFLIFE_HOURS
|
| 258 |
+
sleep_factor = 0.5 ** (hours_over / SLEEP_HALFLIFE_HOURS)
|
| 259 |
+
sleep_factor = max(SLEEP_MIN_QUALITY, sleep_factor)
|
| 260 |
+
|
| 261 |
+
return energy_factor * sleep_factor
|
| 262 |
+
|
| 263 |
+
# ----- tags -----
|
| 264 |
+
|
| 265 |
+
def _calc_tag_boost(self, tags: Optional[List[str]]) -> float:
|
| 266 |
+
if not tags:
|
| 267 |
+
return 1.0
|
| 268 |
+
trending_count = sum(1 for t in tags if t in self._trending_tags)
|
| 269 |
+
perf_values = [
|
| 270 |
+
self._tag_performance_avg(t) for t in tags if self._tag_performance_avg(t) > 0
|
| 271 |
+
]
|
| 272 |
+
perf_avg = sum(perf_values) / len(perf_values) if perf_values else 0.0
|
| 273 |
+
return 1.0 + 0.1 * trending_count + 0.05 * perf_avg
|
| 274 |
+
|
| 275 |
+
def _tag_performance_avg(self, tag: str) -> float:
|
| 276 |
+
history = self._tag_history.get(tag, [])
|
| 277 |
+
if not history:
|
| 278 |
+
return 0.0
|
| 279 |
+
window = history[-5:]
|
| 280 |
+
return sum(window) / len(window)
|
| 281 |
+
|
| 282 |
+
def _get_tag_performance_dict(self) -> Dict[str, float]:
|
| 283 |
+
return {tag: self._tag_performance_avg(tag) for tag in self._unique_tags_used}
|
| 284 |
+
|
| 285 |
+
# ----- competitors -----
|
| 286 |
+
|
| 287 |
+
def _advance_competitors(self) -> None:
|
| 288 |
+
for comp in self._competitors:
|
| 289 |
+
for p in comp.recent_posts:
|
| 290 |
+
p["hours_ago"] += 1
|
| 291 |
+
comp.recent_posts = [p for p in comp.recent_posts if p["hours_ago"] < 48]
|
| 292 |
+
|
| 293 |
+
post_prob = comp.posting_frequency / 24.0
|
| 294 |
+
if self._rng.random() < post_prob:
|
| 295 |
+
ct = self._rng.choice(comp.preferred_types)
|
| 296 |
+
topic = self._rng.choice(comp.niche_topics)
|
| 297 |
+
tags = self._rng.sample(
|
| 298 |
+
comp.tag_preferences, min(3, len(comp.tag_preferences))
|
| 299 |
+
)
|
| 300 |
+
eng = comp.base_engagement + self._rng.uniform(-0.1, 0.1)
|
| 301 |
+
eng = max(0.0, min(1.0, eng))
|
| 302 |
+
comp.recent_posts.append({
|
| 303 |
+
"content_type": ct,
|
| 304 |
+
"topic": topic,
|
| 305 |
+
"tags": tags,
|
| 306 |
+
"engagement": round(eng, 3),
|
| 307 |
+
"hours_ago": 0,
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
def _get_competitor_recent_posts(self, limit: int = 5) -> List[Dict[str, Any]]:
|
| 311 |
+
all_posts: List[Dict[str, Any]] = []
|
| 312 |
+
for comp in self._competitors:
|
| 313 |
+
for p in comp.recent_posts:
|
| 314 |
+
all_posts.append(p)
|
| 315 |
+
all_posts.sort(key=lambda x: x["hours_ago"])
|
| 316 |
+
return all_posts[:limit]
|
| 317 |
+
|
| 318 |
+
def _get_competitor_avg_engagement(self) -> float:
|
| 319 |
+
engagements = []
|
| 320 |
+
for comp in self._competitors:
|
| 321 |
+
for p in comp.recent_posts:
|
| 322 |
+
engagements.append(p["engagement"])
|
| 323 |
+
return sum(engagements) / len(engagements) if engagements else 0.0
|
| 324 |
+
|
| 325 |
+
def _calc_niche_saturation(self, topic: Optional[str]) -> float:
|
| 326 |
+
if not topic:
|
| 327 |
+
return 0.0
|
| 328 |
+
recent_topics = []
|
| 329 |
+
for comp in self._competitors:
|
| 330 |
+
for p in comp.recent_posts:
|
| 331 |
+
if p["hours_ago"] < 12:
|
| 332 |
+
recent_topics.append(p["topic"].lower())
|
| 333 |
+
if not recent_topics:
|
| 334 |
+
return 0.0
|
| 335 |
+
topic_lower = topic.lower()
|
| 336 |
+
overlap = sum(1 for t in recent_topics if _topic_overlap(topic_lower, t))
|
| 337 |
+
return min(1.0, overlap / max(1, len(recent_topics)))
|
| 338 |
+
|
| 339 |
+
def _calc_competitor_diff(self, topic: Optional[str]) -> float:
|
| 340 |
+
if not topic:
|
| 341 |
+
return 1.0
|
| 342 |
+
saturation = self._calc_niche_saturation(topic)
|
| 343 |
+
recent_topics = []
|
| 344 |
+
for comp in self._competitors:
|
| 345 |
+
for p in comp.recent_posts:
|
| 346 |
+
if p["hours_ago"] < 12:
|
| 347 |
+
recent_topics.append(p["topic"].lower())
|
| 348 |
+
topic_lower = topic.lower()
|
| 349 |
+
has_overlap = any(_topic_overlap(topic_lower, t) for t in recent_topics)
|
| 350 |
+
if not has_overlap:
|
| 351 |
+
return 1.3
|
| 352 |
+
if saturation > 0.7:
|
| 353 |
+
return 0.6
|
| 354 |
+
return 1.0
|
| 355 |
+
|
| 356 |
+
# ----- core API -----
|
| 357 |
+
|
| 358 |
+
def reset(
|
| 359 |
+
self,
|
| 360 |
+
seed: Optional[int] = None,
|
| 361 |
+
episode_id: Optional[str] = None,
|
| 362 |
+
**kwargs: Any,
|
| 363 |
+
) -> ViraltestObservation:
|
| 364 |
+
self._task = kwargs.get("task", "weekly_engage")
|
| 365 |
+
if self._task not in VALID_TASKS:
|
| 366 |
+
self._task = "weekly_engage"
|
| 367 |
+
|
| 368 |
+
self._rng = random.Random(seed if seed is not None else 42)
|
| 369 |
+
self._state = State(
|
| 370 |
+
episode_id=episode_id or str(uuid4()), step_count=0
|
| 371 |
+
)
|
| 372 |
+
self._init_state()
|
| 373 |
+
|
| 374 |
+
return self._build_observation(reward=0.0, error=None)
|
| 375 |
+
|
| 376 |
+
def step(self, action: ViraltestAction, **kwargs: Any) -> ViraltestObservation: # type: ignore[override]
|
| 377 |
+
"""Process a daily step: run 24 hourly sub-steps using the sparse schedule."""
|
| 378 |
+
if self._episode_done and self._final_observation is not None:
|
| 379 |
+
return self._final_observation
|
| 380 |
+
|
| 381 |
+
self._state.step_count += 1
|
| 382 |
+
|
| 383 |
+
schedule: Dict[int, ScheduledAction] = {}
|
| 384 |
+
errors: List[str] = []
|
| 385 |
+
for sa in action.scheduled_actions:
|
| 386 |
+
if sa.hour < 0 or sa.hour > 23:
|
| 387 |
+
errors.append(f"Invalid hour: {sa.hour}")
|
| 388 |
+
continue
|
| 389 |
+
err = self._validate_scheduled_action(sa)
|
| 390 |
+
if err:
|
| 391 |
+
errors.append(f"hour {sa.hour}: {err}")
|
| 392 |
+
continue
|
| 393 |
+
schedule[sa.hour] = sa
|
| 394 |
+
|
| 395 |
+
daily_engagement = 0.0
|
| 396 |
+
daily_reward = 0.0
|
| 397 |
+
daily_posts = 0
|
| 398 |
+
energy_min = self._energy
|
| 399 |
+
burned_out = False
|
| 400 |
+
|
| 401 |
+
for hour in range(24):
|
| 402 |
+
if burned_out:
|
| 403 |
+
break
|
| 404 |
+
|
| 405 |
+
if hour in schedule:
|
| 406 |
+
sa = schedule[hour]
|
| 407 |
+
hourly_eng, hourly_reward = self._process_hour_action(sa)
|
| 408 |
+
else:
|
| 409 |
+
hourly_eng, hourly_reward = self._process_hour_rest()
|
| 410 |
+
|
| 411 |
+
daily_engagement += hourly_eng
|
| 412 |
+
daily_reward += hourly_reward
|
| 413 |
+
if hourly_eng > 0:
|
| 414 |
+
daily_posts += 1
|
| 415 |
+
energy_min = min(energy_min, self._energy)
|
| 416 |
+
|
| 417 |
+
self._advance_competitors()
|
| 418 |
+
self._advance_time()
|
| 419 |
+
self._energy_history.append(self._energy)
|
| 420 |
+
|
| 421 |
+
if self._energy <= 0.0:
|
| 422 |
+
burned_out = True
|
| 423 |
+
|
| 424 |
+
day_posts = self._posts_per_day.get(self._day - 1, 0) if self._day > 0 else self._posts_per_day.get(0, 0)
|
| 425 |
+
prev_day = max(0, self._day - 1)
|
| 426 |
+
if 1 <= self._posts_per_day.get(prev_day, 0) <= 2:
|
| 427 |
+
self._days_with_good_posts.add(prev_day)
|
| 428 |
+
|
| 429 |
+
avg_reward = daily_reward / 24.0
|
| 430 |
+
|
| 431 |
+
error_str = "; ".join(errors) if errors else None
|
| 432 |
+
|
| 433 |
+
done = self._state.step_count >= TASK_HORIZON or self._energy <= 0.0
|
| 434 |
+
if done:
|
| 435 |
+
self._episode_done = True
|
| 436 |
+
grader_score = self._run_grader()
|
| 437 |
+
self._final_observation = self._build_observation(
|
| 438 |
+
reward=round(avg_reward, 4),
|
| 439 |
+
error=error_str,
|
| 440 |
+
done=True,
|
| 441 |
+
grader_score=grader_score,
|
| 442 |
+
daily_total_engagement=daily_engagement,
|
| 443 |
+
daily_posts_made=daily_posts,
|
| 444 |
+
daily_energy_min=energy_min,
|
| 445 |
+
)
|
| 446 |
+
return self._final_observation
|
| 447 |
+
|
| 448 |
+
return self._build_observation(
|
| 449 |
+
reward=round(avg_reward, 4),
|
| 450 |
+
error=error_str,
|
| 451 |
+
daily_total_engagement=daily_engagement,
|
| 452 |
+
daily_posts_made=daily_posts,
|
| 453 |
+
daily_energy_min=energy_min,
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
def _process_hour_action(self, sa: ScheduledAction) -> tuple:
|
| 457 |
+
"""Process a single scheduled (non-rest) hourly action. Returns (engagement, reward)."""
|
| 458 |
+
engagement = 0.0
|
| 459 |
+
|
| 460 |
+
if sa.action_type == "post":
|
| 461 |
+
cost = CONTENT_ENERGY_COST.get(sa.content_type, 0.1) # type: ignore[arg-type]
|
| 462 |
+
if self._content_queue > 0:
|
| 463 |
+
cost *= 0.5
|
| 464 |
+
self._content_queue -= 1
|
| 465 |
+
if len(self._last_post_types) >= 3 and all(
|
| 466 |
+
t == sa.content_type for t in self._last_post_types[-3:]
|
| 467 |
+
):
|
| 468 |
+
cost += REPETITION_ENERGY_PENALTY
|
| 469 |
+
self._energy = max(0.0, self._energy - cost)
|
| 470 |
+
self._unique_content_types.add(sa.content_type) # type: ignore[arg-type]
|
| 471 |
+
|
| 472 |
+
if self._energy <= 0.0:
|
| 473 |
+
engagement = 0.0
|
| 474 |
+
else:
|
| 475 |
+
base = BASE_ENGAGEMENT.get(sa.content_type, 0.3) # type: ignore[arg-type]
|
| 476 |
+
reach = REACH_MULT.get(sa.content_type, 1.0) # type: ignore[arg-type]
|
| 477 |
+
hour_mult = self._get_hour_multiplier()
|
| 478 |
+
quality = self._get_quality_modifier()
|
| 479 |
+
tag_boost = self._calc_tag_boost(sa.tags)
|
| 480 |
+
trending_bonus = 1.5 if self._is_topic_trending(sa.topic) else 1.0
|
| 481 |
+
comp_diff = self._calc_competitor_diff(sa.topic)
|
| 482 |
+
|
| 483 |
+
fatigue = 1.0
|
| 484 |
+
if self._posts_today >= AUDIENCE_FATIGUE_THRESHOLD_2:
|
| 485 |
+
fatigue = 0.1
|
| 486 |
+
elif self._posts_today >= AUDIENCE_FATIGUE_THRESHOLD_1:
|
| 487 |
+
fatigue = 0.5
|
| 488 |
+
|
| 489 |
+
algo_mult = 1.0
|
| 490 |
+
if self._algorithm_penalty_remaining > 0:
|
| 491 |
+
algo_mult = ALGORITHM_PENALTY_MULT
|
| 492 |
+
self._algorithm_penalty_remaining -= 1
|
| 493 |
+
|
| 494 |
+
engagement = (
|
| 495 |
+
base * reach * hour_mult * quality * tag_boost
|
| 496 |
+
* trending_bonus * comp_diff * fatigue * algo_mult
|
| 497 |
+
)
|
| 498 |
+
engagement = min(engagement, 5.0)
|
| 499 |
+
|
| 500 |
+
self._last_topic = sa.topic
|
| 501 |
+
|
| 502 |
+
if sa.tags and engagement > 0:
|
| 503 |
+
for tag in sa.tags:
|
| 504 |
+
tag_lower = tag.lower()
|
| 505 |
+
self._tag_history[tag_lower].append(engagement)
|
| 506 |
+
self._unique_tags_used.add(tag_lower)
|
| 507 |
+
|
| 508 |
+
self._engagement_history.append(engagement)
|
| 509 |
+
self._total_engagement += engagement
|
| 510 |
+
self._posting_steps += 1
|
| 511 |
+
|
| 512 |
+
if self._calc_competitor_diff(sa.topic) >= 1.3:
|
| 513 |
+
self._unique_topic_steps += 1
|
| 514 |
+
|
| 515 |
+
self._last_post_types.append(sa.content_type) # type: ignore[arg-type]
|
| 516 |
+
if len(self._last_post_types) > 3:
|
| 517 |
+
self._last_post_types = self._last_post_types[-3:]
|
| 518 |
+
self._posts_today += 1
|
| 519 |
+
self._posts_per_day[self._day] += 1
|
| 520 |
+
self._time_since_last_post = 0
|
| 521 |
+
|
| 522 |
+
if engagement > 0:
|
| 523 |
+
self._followers += int(engagement * 100)
|
| 524 |
+
|
| 525 |
+
elif sa.action_type == "create_content":
|
| 526 |
+
self._energy = max(0.0, self._energy - CREATE_CONTENT_COST)
|
| 527 |
+
self._content_queue += 1
|
| 528 |
+
self._time_since_last_post += 1
|
| 529 |
+
|
| 530 |
+
if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
|
| 531 |
+
self._followers = max(0, self._followers - int(self._followers * 0.005))
|
| 532 |
+
if self._algorithm_penalty_remaining == 0:
|
| 533 |
+
self._algorithm_penalty_remaining = ALGORITHM_PENALTY_DURATION
|
| 534 |
+
|
| 535 |
+
reward = 0.0 if self._energy <= 0.0 else self._compute_hourly_reward(sa, engagement)
|
| 536 |
+
return engagement, reward
|
| 537 |
+
|
| 538 |
+
def _process_hour_rest(self) -> tuple:
|
| 539 |
+
"""Process a rest hour. Returns (0.0, reward)."""
|
| 540 |
+
self._energy = min(1.0, self._energy + REST_RECOVERY)
|
| 541 |
+
self._hours_since_sleep = max(0, self._hours_since_sleep - SLEEP_RECOVERY_PER_REST)
|
| 542 |
+
self._sleep_debt = max(0.0, self._sleep_debt - 0.1)
|
| 543 |
+
self._time_since_last_post += 1
|
| 544 |
+
|
| 545 |
+
if self._time_since_last_post >= FOLLOWER_DECAY_HOURS:
|
| 546 |
+
self._followers = max(0, self._followers - int(self._followers * 0.005))
|
| 547 |
+
if self._algorithm_penalty_remaining == 0:
|
| 548 |
+
self._algorithm_penalty_remaining = ALGORITHM_PENALTY_DURATION
|
| 549 |
+
|
| 550 |
+
reward = 0.0 if self._energy <= 0.0 else self._compute_rest_reward()
|
| 551 |
+
return 0.0, reward
|
| 552 |
+
|
| 553 |
+
@property
|
| 554 |
+
def state(self) -> State:
|
| 555 |
+
return self._state
|
| 556 |
+
|
| 557 |
+
# ----- validation -----
|
| 558 |
+
|
| 559 |
+
def _validate_scheduled_action(self, sa: ScheduledAction) -> Optional[str]:
|
| 560 |
+
if sa.action_type not in ("post", "create_content"):
|
| 561 |
+
return f"Invalid action_type: {sa.action_type}"
|
| 562 |
+
if sa.action_type == "post":
|
| 563 |
+
if not sa.content_type:
|
| 564 |
+
return "content_type is required when posting"
|
| 565 |
+
if sa.content_type not in CONTENT_ENERGY_COST:
|
| 566 |
+
return f"Invalid content_type: {sa.content_type}"
|
| 567 |
+
if not sa.topic or not sa.topic.strip():
|
| 568 |
+
return "topic is required when posting"
|
| 569 |
+
if len(sa.topic) > 200:
|
| 570 |
+
return "topic must be ≤200 characters"
|
| 571 |
+
if sa.tags:
|
| 572 |
+
valid = [t for t in sa.tags if t.lower() in TAG_POOL]
|
| 573 |
+
sa.tags = valid if valid else None
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
# ----- trending -----
|
| 577 |
+
|
| 578 |
+
def _is_topic_trending(self, topic: Optional[str]) -> bool:
|
| 579 |
+
if not topic:
|
| 580 |
+
return False
|
| 581 |
+
topic_lower = topic.lower()
|
| 582 |
+
return any(t.lower() in topic_lower for t in self._trending_topics)
|
| 583 |
+
|
| 584 |
+
# ----- reward -----
|
| 585 |
+
|
| 586 |
+
def _compute_hourly_reward(self, sa: ScheduledAction, engagement: float) -> float:
|
| 587 |
+
eng_component = min(1.0, engagement / 2.0) * 0.3
|
| 588 |
+
|
| 589 |
+
prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
|
| 590 |
+
energy_delta = self._energy - prev_energy
|
| 591 |
+
energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
|
| 592 |
+
|
| 593 |
+
day_posts = self._posts_per_day.get(self._day, 0)
|
| 594 |
+
if 1 <= day_posts <= 2:
|
| 595 |
+
consistency = 1.0
|
| 596 |
+
elif day_posts == 0 or day_posts == 3:
|
| 597 |
+
consistency = 0.5
|
| 598 |
+
else:
|
| 599 |
+
consistency = 0.0
|
| 600 |
+
consistency_component = consistency * 0.15
|
| 601 |
+
|
| 602 |
+
tag_component = 0.0
|
| 603 |
+
if sa.action_type == "post" and sa.tags:
|
| 604 |
+
trending_match = sum(1 for t in sa.tags if t.lower() in self._trending_tags) / 5.0
|
| 605 |
+
tag_component = min(1.0, trending_match + 0.3) * 0.15
|
| 606 |
+
|
| 607 |
+
comp_component = 0.0
|
| 608 |
+
if sa.action_type == "post":
|
| 609 |
+
diff = self._calc_competitor_diff(sa.topic)
|
| 610 |
+
comp_component = min(1.0, diff / 1.3) * 0.15
|
| 611 |
+
|
| 612 |
+
burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
|
| 613 |
+
|
| 614 |
+
raw = eng_component + energy_component + consistency_component + tag_component + comp_component - burnout_penalty
|
| 615 |
+
return max(0.0, min(1.0, raw))
|
| 616 |
+
|
| 617 |
+
def _compute_rest_reward(self) -> float:
|
| 618 |
+
prev_energy = self._energy_history[-2] if len(self._energy_history) >= 2 else 1.0
|
| 619 |
+
energy_delta = self._energy - prev_energy
|
| 620 |
+
energy_component = max(0.0, min(1.0, (energy_delta + 0.3) / 0.6)) * 0.15
|
| 621 |
+
|
| 622 |
+
day_posts = self._posts_per_day.get(self._day, 0)
|
| 623 |
+
if 1 <= day_posts <= 2:
|
| 624 |
+
consistency = 1.0
|
| 625 |
+
elif day_posts == 0 or day_posts == 3:
|
| 626 |
+
consistency = 0.5
|
| 627 |
+
else:
|
| 628 |
+
consistency = 0.0
|
| 629 |
+
consistency_component = consistency * 0.15
|
| 630 |
+
|
| 631 |
+
burnout_penalty = 0.1 if self._energy < 0.2 else 0.0
|
| 632 |
+
raw = energy_component + consistency_component - burnout_penalty
|
| 633 |
+
return max(0.0, min(1.0, raw))
|
| 634 |
+
|
| 635 |
+
# ----- time -----
|
| 636 |
+
|
| 637 |
+
def _advance_time(self) -> None:
|
| 638 |
+
self._hour += 1
|
| 639 |
+
|
| 640 |
+
# Track hours since sleep (always increases unless resting)
|
| 641 |
+
self._hours_since_sleep += 1
|
| 642 |
+
|
| 643 |
+
# Sleep deprivation drains extra energy (smooth ramp after threshold)
|
| 644 |
+
if self._hours_since_sleep > SLEEP_ENERGY_DRAIN_START:
|
| 645 |
+
hours_over = self._hours_since_sleep - SLEEP_ENERGY_DRAIN_START
|
| 646 |
+
# Drain increases smoothly the longer you're awake
|
| 647 |
+
drain = SLEEP_ENERGY_DRAIN_RATE * (1 + hours_over * 0.1)
|
| 648 |
+
self._energy = max(0.0, self._energy - drain)
|
| 649 |
+
|
| 650 |
+
# Update sleep debt (smooth accumulation based on hours awake)
|
| 651 |
+
if self._hours_since_sleep > SLEEP_OPTIMAL_AWAKE:
|
| 652 |
+
hours_over = self._hours_since_sleep - SLEEP_OPTIMAL_AWAKE
|
| 653 |
+
# Debt accumulates faster the longer awake (quadratic-ish curve)
|
| 654 |
+
debt_rate = 0.01 * (1 + hours_over * 0.05)
|
| 655 |
+
self._sleep_debt = min(1.0, self._sleep_debt + debt_rate)
|
| 656 |
+
|
| 657 |
+
if self._hour >= 24:
|
| 658 |
+
self._hour = 0
|
| 659 |
+
self._day += 1
|
| 660 |
+
self._posts_today = 0
|
| 661 |
+
self._rotate_trends()
|
| 662 |
+
|
| 663 |
+
# ----- observation builder -----
|
| 664 |
+
|
| 665 |
+
def _build_observation(
|
| 666 |
+
self,
|
| 667 |
+
reward: float,
|
| 668 |
+
error: Optional[str],
|
| 669 |
+
done: bool = False,
|
| 670 |
+
grader_score: Optional[float] = None,
|
| 671 |
+
daily_total_engagement: float = 0.0,
|
| 672 |
+
daily_posts_made: int = 0,
|
| 673 |
+
daily_energy_min: float = 1.0,
|
| 674 |
+
) -> ViraltestObservation:
|
| 675 |
+
recent_eng = self._engagement_history[-10:] if self._engagement_history else []
|
| 676 |
+
eng_rate = sum(recent_eng) / len(recent_eng) if recent_eng else 0.0
|
| 677 |
+
|
| 678 |
+
meta: Dict[str, Any] = {"step": self._state.step_count, "task": self._task}
|
| 679 |
+
if grader_score is not None:
|
| 680 |
+
meta["grader_score"] = round(grader_score, 4)
|
| 681 |
+
|
| 682 |
+
return ViraltestObservation(
|
| 683 |
+
current_hour=self._hour,
|
| 684 |
+
day_of_week=self._day % 7,
|
| 685 |
+
days_elapsed=self._day,
|
| 686 |
+
creator_energy=round(self._energy, 3),
|
| 687 |
+
hours_since_sleep=self._hours_since_sleep,
|
| 688 |
+
sleep_debt=round(self._sleep_debt, 3),
|
| 689 |
+
follower_count=self._followers,
|
| 690 |
+
engagement_rate=round(eng_rate, 4),
|
| 691 |
+
posts_today=self._posts_today,
|
| 692 |
+
time_since_last_post=self._time_since_last_post,
|
| 693 |
+
trending_topics=list(self._trending_topics),
|
| 694 |
+
content_queue_size=self._content_queue,
|
| 695 |
+
last_post_type=self._last_post_types[-1] if self._last_post_types else "none",
|
| 696 |
+
tag_performance=self._get_tag_performance_dict(),
|
| 697 |
+
trending_tags=list(self._trending_tags),
|
| 698 |
+
competitor_recent_posts=self._get_competitor_recent_posts(),
|
| 699 |
+
competitor_avg_engagement=round(self._get_competitor_avg_engagement(), 4),
|
| 700 |
+
niche_saturation=round(self._calc_niche_saturation(self._last_topic), 3),
|
| 701 |
+
daily_total_engagement=round(daily_total_engagement, 4),
|
| 702 |
+
daily_posts_made=daily_posts_made,
|
| 703 |
+
daily_energy_min=round(daily_energy_min, 3),
|
| 704 |
+
grader_score=round(grader_score, 4) if grader_score is not None else None,
|
| 705 |
+
error=error,
|
| 706 |
+
done=done,
|
| 707 |
+
reward=round(reward, 4),
|
| 708 |
+
metadata=meta,
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
# ----- graders -----
|
| 712 |
+
|
| 713 |
+
def _run_grader(self) -> float:
|
| 714 |
+
if self._task == "weekly_engage":
|
| 715 |
+
return self._grade_weekly_engage()
|
| 716 |
+
elif self._task == "weekly_strategic":
|
| 717 |
+
return self._grade_weekly_strategic()
|
| 718 |
+
elif self._task == "weekly_competitive":
|
| 719 |
+
return self._grade_weekly_competitive()
|
| 720 |
+
return 0.0
|
| 721 |
+
|
| 722 |
+
def _theoretical_max_engagement(self) -> float:
|
| 723 |
+
best_base = max(BASE_ENGAGEMENT.values())
|
| 724 |
+
best_reach = max(REACH_MULT.values())
|
| 725 |
+
peak_mult = 1.4
|
| 726 |
+
quality = 1.0
|
| 727 |
+
posts_per_day = 2
|
| 728 |
+
days = 7
|
| 729 |
+
return best_base * best_reach * peak_mult * quality * posts_per_day * days
|
| 730 |
+
|
| 731 |
+
def _grade_weekly_engage(self) -> float:
|
| 732 |
+
theoretical_max = self._theoretical_max_engagement()
|
| 733 |
+
if theoretical_max <= 0:
|
| 734 |
+
return 0.0
|
| 735 |
+
raw = min(1.0, self._total_engagement / theoretical_max)
|
| 736 |
+
if self._energy <= 0.0:
|
| 737 |
+
raw *= 0.3 # burnout penalty even on easy task
|
| 738 |
+
return raw
|
| 739 |
+
|
| 740 |
+
def _grade_weekly_strategic(self) -> float:
|
| 741 |
+
# Burnout = severe penalty (not total fail like competitive, but close)
|
| 742 |
+
if self._energy <= 0.0:
|
| 743 |
+
return max(0.0, min(0.15, self._total_engagement * 0.01))
|
| 744 |
+
|
| 745 |
+
# Engagement: 35%
|
| 746 |
+
theoretical_max = self._theoretical_max_engagement()
|
| 747 |
+
norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
|
| 748 |
+
|
| 749 |
+
# Tag score: 25% (40% discovery + 60% exploitation)
|
| 750 |
+
positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
|
| 751 |
+
tag_discovery = min(1.0, positive_tags / 30.0)
|
| 752 |
+
top_perfs = sorted(
|
| 753 |
+
[self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True
|
| 754 |
+
)[:3]
|
| 755 |
+
tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
|
| 756 |
+
tag_exploitation = min(1.0, tag_exploitation / 2.0)
|
| 757 |
+
tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
|
| 758 |
+
|
| 759 |
+
# Avg energy: 25%
|
| 760 |
+
avg_energy = sum(self._energy_history) / len(self._energy_history) if self._energy_history else 0.0
|
| 761 |
+
|
| 762 |
+
# Consistency: 15%
|
| 763 |
+
consistency = len(self._days_with_good_posts) / 7.0
|
| 764 |
+
|
| 765 |
+
raw = 0.35 * norm_eng + 0.25 * tag_score + 0.25 * avg_energy + 0.15 * consistency
|
| 766 |
+
|
| 767 |
+
# Constraints
|
| 768 |
+
min_energy = min(self._energy_history) if self._energy_history else 0.0
|
| 769 |
+
if min_energy < 0.2:
|
| 770 |
+
raw *= 0.4 # crashed hard
|
| 771 |
+
elif min_energy < 0.3:
|
| 772 |
+
raw = min(raw, 0.45)
|
| 773 |
+
if len(self._unique_tags_used) < 5:
|
| 774 |
+
raw *= 0.7
|
| 775 |
+
|
| 776 |
+
return max(0.0, min(1.0, raw))
|
| 777 |
+
|
| 778 |
+
def _grade_weekly_competitive(self) -> float:
|
| 779 |
+
# Burnout = total fail
|
| 780 |
+
if self._energy <= 0.0:
|
| 781 |
+
return 0.0
|
| 782 |
+
|
| 783 |
+
# Engagement: 25%
|
| 784 |
+
theoretical_max = self._theoretical_max_engagement()
|
| 785 |
+
norm_eng = min(1.0, self._total_engagement / theoretical_max) if theoretical_max > 0 else 0.0
|
| 786 |
+
|
| 787 |
+
# Tag score: 20%
|
| 788 |
+
positive_tags = sum(1 for t in self._unique_tags_used if self._tag_performance_avg(t) > 0)
|
| 789 |
+
tag_discovery = min(1.0, positive_tags / 30.0)
|
| 790 |
+
top_perfs = sorted(
|
| 791 |
+
[self._tag_performance_avg(t) for t in self._unique_tags_used], reverse=True
|
| 792 |
+
)[:3]
|
| 793 |
+
tag_exploitation = (sum(top_perfs) / len(top_perfs)) if top_perfs else 0.0
|
| 794 |
+
tag_exploitation = min(1.0, tag_exploitation / 2.0)
|
| 795 |
+
tag_score = 0.4 * tag_discovery + 0.6 * tag_exploitation
|
| 796 |
+
|
| 797 |
+
# Follower growth: 20%
|
| 798 |
+
growth = (self._followers - self._initial_followers) / self._initial_followers if self._initial_followers > 0 else 0.0
|
| 799 |
+
target_growth = 0.05
|
| 800 |
+
norm_growth = min(1.0, max(0.0, growth / target_growth))
|
| 801 |
+
|
| 802 |
+
# Competitor outperformance: 15%
|
| 803 |
+
comp_avg = self._get_competitor_avg_engagement()
|
| 804 |
+
my_avg = self._total_engagement / self._posting_steps if self._posting_steps > 0 else 0.0
|
| 805 |
+
outperformance = my_avg / comp_avg if comp_avg > 0 else 1.0
|
| 806 |
+
norm_outperformance = min(1.0, outperformance / 1.5)
|
| 807 |
+
|
| 808 |
+
# Differentiation: 10%
|
| 809 |
+
differentiation = self._unique_topic_steps / self._posting_steps if self._posting_steps > 0 else 0.0
|
| 810 |
+
|
| 811 |
+
# Energy floor: 10%
|
| 812 |
+
min_energy = min(self._energy_history) if self._energy_history else 0.0
|
| 813 |
+
energy_floor = min(1.0, max(0.0, min_energy))
|
| 814 |
+
|
| 815 |
+
raw = (
|
| 816 |
+
0.25 * norm_eng
|
| 817 |
+
+ 0.20 * tag_score
|
| 818 |
+
+ 0.20 * norm_growth
|
| 819 |
+
+ 0.15 * norm_outperformance
|
| 820 |
+
+ 0.10 * differentiation
|
| 821 |
+
+ 0.10 * energy_floor
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
# Constraints
|
| 825 |
+
if len(self._unique_content_types) < 3:
|
| 826 |
+
raw *= 0.5
|
| 827 |
+
if len(self._unique_tags_used) < 8:
|
| 828 |
+
raw *= 0.7
|
| 829 |
+
|
| 830 |
+
return max(0.0, min(1.0, raw))
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
# ---------------------------------------------------------------------------
|
| 834 |
+
# Helpers
|
| 835 |
+
# ---------------------------------------------------------------------------
|
| 836 |
+
|
| 837 |
+
def _topic_overlap(topic_a: str, topic_b: str) -> bool:
|
| 838 |
+
"""Check if two topics have significant word overlap."""
|
| 839 |
+
words_a = set(topic_a.split())
|
| 840 |
+
words_b = set(topic_b.split())
|
| 841 |
+
if not words_a or not words_b:
|
| 842 |
+
return False
|
| 843 |
+
common = words_a & words_b
|
| 844 |
+
return len(common) / min(len(words_a), len(words_b)) >= 0.5
|
test_scenarios.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viraltest — Edge Case & Scenario Tests (Daily Plan Format)
|
| 3 |
+
Runs scenarios for all 3 tasks using the new daily step format.
|
| 4 |
+
Each step = one full day. Agent submits a sparse daily plan.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import random as stdlib_random
|
| 8 |
+
from typing import Callable, Dict, List, Tuple
|
| 9 |
+
|
| 10 |
+
from models import ScheduledAction, ViraltestAction
|
| 11 |
+
from server.viraltest_environment import (
|
| 12 |
+
TAG_POOL,
|
| 13 |
+
ViraltestEnvironment,
|
| 14 |
+
ViraltestObservation,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
TASKS = ["weekly_engage", "weekly_strategic", "weekly_competitive"]
|
| 18 |
+
SEED = 42
|
| 19 |
+
|
| 20 |
+
_CONTENT_TYPES = ["reel", "carousel", "story", "text_post"]
|
| 21 |
+
_TOPICS = ["AI tools", "fitness routine", "growth hacks", "travel guide", "food recipe", "wellness tips"]
|
| 22 |
+
_rng = stdlib_random.Random(99)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _plan(actions: list) -> ViraltestAction:
|
| 26 |
+
return ViraltestAction(scheduled_actions=[ScheduledAction(**a) for a in actions])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_episode(
|
| 30 |
+
task: str,
|
| 31 |
+
plan_fn: Callable[[Dict, int], ViraltestAction],
|
| 32 |
+
label: str,
|
| 33 |
+
) -> float:
|
| 34 |
+
env = ViraltestEnvironment()
|
| 35 |
+
obs = env.reset(task=task, seed=SEED)
|
| 36 |
+
obs_dict = obs.model_dump()
|
| 37 |
+
rewards: List[float] = []
|
| 38 |
+
min_energy = 1.0
|
| 39 |
+
burned_out = False
|
| 40 |
+
|
| 41 |
+
for day in range(1, 8):
|
| 42 |
+
action = plan_fn(obs_dict, day)
|
| 43 |
+
obs = env.step(action)
|
| 44 |
+
obs_dict = obs.model_dump()
|
| 45 |
+
r = obs.reward if obs.reward is not None else 0.0
|
| 46 |
+
rewards.append(r)
|
| 47 |
+
min_energy = min(min_energy, obs.creator_energy)
|
| 48 |
+
if obs.done and obs.creator_energy <= 0:
|
| 49 |
+
burned_out = True
|
| 50 |
+
if obs.done:
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
score = (obs.metadata or {}).get("grader_score", 0.0)
|
| 54 |
+
total_steps = len(rewards)
|
| 55 |
+
|
| 56 |
+
print(f" Task: {task}")
|
| 57 |
+
print(f" Days: {total_steps} | Done: {obs.done} | Burned out: {burned_out}")
|
| 58 |
+
print(f" Score: {score:.4f} | Total reward: {sum(rewards):.2f} | Avg reward: {sum(rewards)/len(rewards):.3f}")
|
| 59 |
+
print(f" Energy: {obs.creator_energy:.2f} | Min energy: {min_energy:.2f}")
|
| 60 |
+
print(f" Followers: {obs.follower_count} (started 10000, delta {obs.follower_count - 10000:+d})")
|
| 61 |
+
print(f" Engagement rate: {obs.engagement_rate:.4f}")
|
| 62 |
+
print(f" Unique tags: {len(obs.tag_performance)}")
|
| 63 |
+
print(f" Niche saturation: {obs.niche_saturation:.3f}")
|
| 64 |
+
print()
|
| 65 |
+
return score
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def plan_always_rest(obs: dict, day: int) -> ViraltestAction:
|
| 69 |
+
return _plan([])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def plan_spam(obs: dict, day: int) -> ViraltestAction:
|
| 73 |
+
return _plan([{"hour": h, "action_type": "post", "content_type": "reel",
|
| 74 |
+
"topic": "AI tools", "tags": ["ai"]} for h in range(24)])
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def plan_smart(obs: dict, day: int) -> ViraltestAction:
|
| 78 |
+
trending = (obs.get("trending_topics") or ["AI tools"])[0]
|
| 79 |
+
t_tags = list((obs.get("trending_tags") or [])[:2])
|
| 80 |
+
pool_tag = TAG_POOL[(day * 2) % len(TAG_POOL)]
|
| 81 |
+
pool_tag2 = TAG_POOL[(day * 2 + 1) % len(TAG_POOL)]
|
| 82 |
+
ct1 = _CONTENT_TYPES[(day * 2) % 4]
|
| 83 |
+
ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
|
| 84 |
+
return _plan([
|
| 85 |
+
{"hour": 8, "action_type": "create_content"},
|
| 86 |
+
{"hour": 12, "action_type": "post", "content_type": ct1, "topic": trending, "tags": t_tags + [pool_tag]},
|
| 87 |
+
{"hour": 19, "action_type": "post", "content_type": ct2, "topic": trending, "tags": t_tags + [pool_tag2]},
|
| 88 |
+
])
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def plan_no_rest(obs: dict, day: int) -> ViraltestAction:
|
| 92 |
+
actions = []
|
| 93 |
+
for h in range(24):
|
| 94 |
+
ct = _CONTENT_TYPES[h % 4]
|
| 95 |
+
topic = _rng.choice(_TOPICS)
|
| 96 |
+
tags = _rng.sample(TAG_POOL, 3)
|
| 97 |
+
actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
|
| 98 |
+
return _plan(actions)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def plan_minimal(obs: dict, day: int) -> ViraltestAction:
|
| 102 |
+
trending = (obs.get("trending_topics") or ["minimalism"])[0]
|
| 103 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 104 |
+
return _plan([
|
| 105 |
+
{"hour": 12, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
|
| 106 |
+
])
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def plan_tag_explorer(obs: dict, day: int) -> ViraltestAction:
|
| 110 |
+
trending = (obs.get("trending_topics") or ["devtools"])[0]
|
| 111 |
+
start = (day * 6) % len(TAG_POOL)
|
| 112 |
+
tags1 = [TAG_POOL[(start + i) % len(TAG_POOL)] for i in range(3)]
|
| 113 |
+
tags2 = [TAG_POOL[(start + 3 + i) % len(TAG_POOL)] for i in range(3)]
|
| 114 |
+
ct1 = _CONTENT_TYPES[(day * 2) % 4]
|
| 115 |
+
ct2 = _CONTENT_TYPES[(day * 2 + 1) % 4]
|
| 116 |
+
return _plan([
|
| 117 |
+
{"hour": 10, "action_type": "post", "content_type": ct1, "topic": trending, "tags": tags1},
|
| 118 |
+
{"hour": 18, "action_type": "post", "content_type": ct2, "topic": trending, "tags": tags2},
|
| 119 |
+
])
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def plan_queue_optimizer(obs: dict, day: int) -> ViraltestAction:
|
| 123 |
+
trending = (obs.get("trending_topics") or ["productivity"])[0]
|
| 124 |
+
tags = list((obs.get("trending_tags") or [])[:2]) + ["growth"]
|
| 125 |
+
queue = obs.get("content_queue_size", 0)
|
| 126 |
+
if day < 3 or queue < 2:
|
| 127 |
+
return _plan([
|
| 128 |
+
{"hour": 8, "action_type": "create_content"},
|
| 129 |
+
{"hour": 10, "action_type": "create_content"},
|
| 130 |
+
{"hour": 14, "action_type": "create_content"},
|
| 131 |
+
])
|
| 132 |
+
ct = _CONTENT_TYPES[day % 4]
|
| 133 |
+
return _plan([
|
| 134 |
+
{"hour": 12, "action_type": "post", "content_type": ct, "topic": trending, "tags": tags},
|
| 135 |
+
{"hour": 19, "action_type": "post", "content_type": _CONTENT_TYPES[(day + 1) % 4], "topic": trending, "tags": tags},
|
| 136 |
+
])
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def plan_double_peak(obs: dict, day: int) -> ViraltestAction:
|
| 140 |
+
trending = (obs.get("trending_topics") or ["peak time content"])[0]
|
| 141 |
+
tags = list((obs.get("trending_tags") or [])[:3])
|
| 142 |
+
return _plan([
|
| 143 |
+
{"hour": 9, "action_type": "post", "content_type": "reel", "topic": trending, "tags": tags},
|
| 144 |
+
{"hour": 15, "action_type": "post", "content_type": "carousel", "topic": trending, "tags": tags},
|
| 145 |
+
])
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def plan_random(obs: dict, day: int) -> ViraltestAction:
|
| 149 |
+
actions = []
|
| 150 |
+
for h in range(24):
|
| 151 |
+
r = _rng.random()
|
| 152 |
+
if r < 0.1:
|
| 153 |
+
ct = _rng.choice(_CONTENT_TYPES)
|
| 154 |
+
topic = _rng.choice(["random topic", "AI tools", "fitness", "travel"])
|
| 155 |
+
tags = _rng.sample(TAG_POOL, 2)
|
| 156 |
+
actions.append({"hour": h, "action_type": "post", "content_type": ct, "topic": topic, "tags": tags})
|
| 157 |
+
elif r < 0.15:
|
| 158 |
+
actions.append({"hour": h, "action_type": "create_content"})
|
| 159 |
+
return _plan(actions)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
SCENARIOS: List[Tuple[str, Callable, str]] = [
|
| 163 |
+
("Always Rest", plan_always_rest, "Zero engagement, no growth, energy stays max"),
|
| 164 |
+
("Spam Post", plan_spam, "Post every hour, burns out instantly"),
|
| 165 |
+
("Smart Agent", plan_smart, "Peak hours, trending, varied types, energy management"),
|
| 166 |
+
("No Rest", plan_no_rest, "Post every hour, never rests, burns out"),
|
| 167 |
+
("Minimal Poster", plan_minimal, "1 carousel at noon per day"),
|
| 168 |
+
("Tag Explorer", plan_tag_explorer, "Rotates through tag pool for max discovery"),
|
| 169 |
+
("Queue Optimizer", plan_queue_optimizer, "Creates content first, posts from queue"),
|
| 170 |
+
("Double Peak", plan_double_peak, "Posts at 9am and 3pm"),
|
| 171 |
+
("Random Actor", plan_random, "Random sparse actions each day"),
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
print("=" * 70)
|
| 177 |
+
print("VIRALTEST — DAILY PLAN SCENARIO TESTS")
|
| 178 |
+
print("=" * 70)
|
| 179 |
+
print()
|
| 180 |
+
|
| 181 |
+
for scenario_name, plan_fn, description in SCENARIOS:
|
| 182 |
+
print("=" * 70)
|
| 183 |
+
print(f"{scenario_name}")
|
| 184 |
+
print(f" {description}")
|
| 185 |
+
print("=" * 70)
|
| 186 |
+
print()
|
| 187 |
+
|
| 188 |
+
for task in TASKS:
|
| 189 |
+
_rng = stdlib_random.Random(99)
|
| 190 |
+
run_episode(task, plan_fn, scenario_name)
|
| 191 |
+
|
| 192 |
+
print()
|
| 193 |
+
|
| 194 |
+
print("=" * 70)
|
| 195 |
+
print("SUMMARY TABLE")
|
| 196 |
+
print("=" * 70)
|
| 197 |
+
print()
|
| 198 |
+
print(f"{'Scenario':<30} {'Engage':>8} {'Strategic':>10} {'Competitive':>12}")
|
| 199 |
+
print("-" * 62)
|
| 200 |
+
|
| 201 |
+
for scenario_name, plan_fn, _ in SCENARIOS:
|
| 202 |
+
scores = []
|
| 203 |
+
for task in TASKS:
|
| 204 |
+
_rng = stdlib_random.Random(99)
|
| 205 |
+
env = ViraltestEnvironment()
|
| 206 |
+
obs = env.reset(task=task, seed=SEED)
|
| 207 |
+
obs_dict = obs.model_dump()
|
| 208 |
+
for day in range(1, 8):
|
| 209 |
+
action = plan_fn(obs_dict, day)
|
| 210 |
+
obs = env.step(action)
|
| 211 |
+
obs_dict = obs.model_dump()
|
| 212 |
+
if obs.done:
|
| 213 |
+
break
|
| 214 |
+
scores.append((obs.metadata or {}).get("grader_score", 0.0))
|
| 215 |
+
print(f"{scenario_name:<30} {scores[0]:>8.4f} {scores[1]:>10.4f} {scores[2]:>12.4f}")
|
| 216 |
+
|
| 217 |
+
print()
|
| 218 |
+
print("EXPECTED: Smart/Queue/Tag Explorer should score highest.")
|
| 219 |
+
print("Burnout agents (spam, no_rest) should score near 0 on strategic/competitive.")
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
validate-submission.sh
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# validate-submission.sh — OpenEnv Submission Validator for Viraltest
|
| 4 |
+
#
|
| 5 |
+
# Checks that your HF Space is live, Docker image builds, and openenv validate passes.
|
| 6 |
+
#
|
| 7 |
+
# Prerequisites:
|
| 8 |
+
# - Docker: https://docs.docker.com/get-docker/
|
| 9 |
+
# - openenv validate: uv sync (uses .venv/bin/openenv), or pip install openenv-core, or uv on PATH
|
| 10 |
+
# - curl (usually pre-installed)
|
| 11 |
+
#
|
| 12 |
+
# Run:
|
| 13 |
+
# chmod +x validate-submission.sh
|
| 14 |
+
# ./validate-submission.sh <ping_url> [repo_dir]
|
| 15 |
+
#
|
| 16 |
+
# Optional: create repo-local .env (gitignored) with HF_TOKEN=... — sourced automatically.
|
| 17 |
+
# cp .env.example .env # then edit .env
|
| 18 |
+
#
|
| 19 |
+
# Skip Docker build (Step 2) — faster local checks; run full build before submit:
|
| 20 |
+
# SKIP_DOCKER=1 ./validate-submission.sh https://your-space.hf.space
|
| 21 |
+
#
|
| 22 |
+
# Step 5 — Hugging Face Inference Router LLM smoke test (runs by default if HF_TOKEN is set):
|
| 23 |
+
# export HF_TOKEN=hf_... # required for Step 5; never commit; use Space Secrets for deploys
|
| 24 |
+
# # Optional overrides (defaults match inference.py / HF router):
|
| 25 |
+
# export MODEL_NAME=gemma-4-E4B-it-IQ4_XS
|
| 26 |
+
# export API_BASE_URL=https://router.huggingface.co/v1
|
| 27 |
+
# SKIP_LLM_SMOKE=1 # only if you must skip Step 5 (e.g. CI without secrets)
|
| 28 |
+
#
|
| 29 |
+
# HF token permissions (403 = insufficient permissions):
|
| 30 |
+
# - Create or edit at https://huggingface.co/settings/tokens
|
| 31 |
+
# - For https://router.huggingface.co/v1 the token must be allowed to call
|
| 32 |
+
# Inference Providers / serverless inference for your account (UI labels vary).
|
| 33 |
+
# - If 403 persists, confirm billing/access for Inference Providers in HF account settings.
|
| 34 |
+
# - LLM_SMOKE_OPTIONAL=1 — still pass Steps 1,3–5 when Step 5 auth fails (not for production).
|
| 35 |
+
#
|
| 36 |
+
# Arguments:
|
| 37 |
+
# ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)
|
| 38 |
+
# repo_dir Path to your repo (default: current directory)
|
| 39 |
+
#
|
| 40 |
+
# Examples:
|
| 41 |
+
# ./validate-submission.sh https://my-team.hf.space
|
| 42 |
+
# ./validate-submission.sh https://my-team.hf.space ./viraltest
|
| 43 |
+
|
| 44 |
+
set -uo pipefail
|
| 45 |
+
|
| 46 |
+
DOCKER_BUILD_TIMEOUT=600
|
| 47 |
+
if [ -t 1 ]; then
|
| 48 |
+
RED='\033[0;31m'
|
| 49 |
+
GREEN='\033[0;32m'
|
| 50 |
+
YELLOW='\033[1;33m'
|
| 51 |
+
BOLD='\033[1m'
|
| 52 |
+
NC='\033[0m'
|
| 53 |
+
else
|
| 54 |
+
RED='' GREEN='' YELLOW='' BOLD='' NC=''
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
run_with_timeout() {
|
| 58 |
+
local secs="$1"; shift
|
| 59 |
+
if command -v timeout &>/dev/null; then
|
| 60 |
+
timeout "$secs" "$@"
|
| 61 |
+
elif command -v gtimeout &>/dev/null; then
|
| 62 |
+
gtimeout "$secs" "$@"
|
| 63 |
+
else
|
| 64 |
+
"$@" &
|
| 65 |
+
local pid=$!
|
| 66 |
+
( sleep "$secs" && kill "$pid" 2>/dev/null ) &
|
| 67 |
+
local watcher=$!
|
| 68 |
+
wait "$pid" 2>/dev/null
|
| 69 |
+
local rc=$?
|
| 70 |
+
kill "$watcher" 2>/dev/null
|
| 71 |
+
wait "$watcher" 2>/dev/null
|
| 72 |
+
return $rc
|
| 73 |
+
fi
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
portable_mktemp() {
|
| 77 |
+
local prefix="${1:-validate}"
|
| 78 |
+
mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
CLEANUP_FILES=()
|
| 82 |
+
cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
|
| 83 |
+
trap cleanup EXIT
|
| 84 |
+
|
| 85 |
+
PING_URL="${1:-}"
|
| 86 |
+
REPO_DIR="${2:-.}"
|
| 87 |
+
|
| 88 |
+
if [ -z "$PING_URL" ]; then
|
| 89 |
+
printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
|
| 90 |
+
printf "\n"
|
| 91 |
+
printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
|
| 92 |
+
printf " repo_dir Path to your repo (default: current directory)\n"
|
| 93 |
+
exit 1
|
| 94 |
+
fi
|
| 95 |
+
|
| 96 |
+
if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
|
| 97 |
+
printf "Error: directory '%s' not found\n" "${2:-.}"
|
| 98 |
+
exit 1
|
| 99 |
+
fi
|
| 100 |
+
PING_URL="${PING_URL%/}"
|
| 101 |
+
export PING_URL
|
| 102 |
+
PASS=0
|
| 103 |
+
|
| 104 |
+
log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
|
| 105 |
+
pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
|
| 106 |
+
fail() { log "${RED}FAILED${NC} -- $1"; }
|
| 107 |
+
hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
|
| 108 |
+
stop_at() {
|
| 109 |
+
printf "\n"
|
| 110 |
+
printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
|
| 111 |
+
exit 1
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if [ -f "$REPO_DIR/.env" ]; then
|
| 115 |
+
set -a
|
| 116 |
+
# shellcheck disable=SC1091
|
| 117 |
+
. "$REPO_DIR/.env"
|
| 118 |
+
set +a
|
| 119 |
+
fi
|
| 120 |
+
|
| 121 |
+
printf "\n"
|
| 122 |
+
printf "${BOLD}========================================${NC}\n"
|
| 123 |
+
printf "${BOLD} Viraltest Submission Validator${NC}\n"
|
| 124 |
+
printf "${BOLD}========================================${NC}\n"
|
| 125 |
+
log "Repo: $REPO_DIR"
|
| 126 |
+
log "Ping URL: $PING_URL"
|
| 127 |
+
if [ "${SKIP_DOCKER:-}" = "1" ]; then
|
| 128 |
+
log "${YELLOW}SKIP_DOCKER=1 — Docker build will be skipped${NC}"
|
| 129 |
+
fi
|
| 130 |
+
printf "\n"
|
| 131 |
+
|
| 132 |
+
# ──────────────────────────────────────
|
| 133 |
+
# Step 1: Ping HF Space
|
| 134 |
+
# ──────────────────────────────────────
|
| 135 |
+
log "${BOLD}Step 1/5: Pinging HF Space${NC} ($PING_URL/reset) ..."
|
| 136 |
+
|
| 137 |
+
CURL_OUTPUT=$(portable_mktemp "validate-curl")
|
| 138 |
+
CLEANUP_FILES+=("$CURL_OUTPUT")
|
| 139 |
+
HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
|
| 140 |
+
-H "Content-Type: application/json" -d '{}' \
|
| 141 |
+
"$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
|
| 142 |
+
|
| 143 |
+
if [ "$HTTP_CODE" = "200" ]; then
|
| 144 |
+
pass "HF Space is live and responds to /reset"
|
| 145 |
+
elif [ "$HTTP_CODE" = "000" ]; then
|
| 146 |
+
fail "HF Space not reachable (connection failed or timed out)"
|
| 147 |
+
hint "Check your network and that the Space is running."
|
| 148 |
+
stop_at "Step 1"
|
| 149 |
+
else
|
| 150 |
+
fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
|
| 151 |
+
hint "Make sure your Space is running. Try: curl -X POST $PING_URL/reset"
|
| 152 |
+
stop_at "Step 1"
|
| 153 |
+
fi
|
| 154 |
+
|
| 155 |
+
# ──────────────────────────────────────
|
| 156 |
+
# Step 2: Docker build
|
| 157 |
+
# ──────────────────────────────────────
|
| 158 |
+
if [ "${SKIP_DOCKER:-}" = "1" ]; then
|
| 159 |
+
log "${BOLD}Step 2/5: Docker build${NC} ${YELLOW}SKIPPED${NC} (SKIP_DOCKER=1)"
|
| 160 |
+
hint "Run without SKIP_DOCKER=1 before submission to confirm docker build still succeeds."
|
| 161 |
+
else
|
| 162 |
+
log "${BOLD}Step 2/5: Running docker build${NC} ..."
|
| 163 |
+
|
| 164 |
+
if ! command -v docker &>/dev/null; then
|
| 165 |
+
fail "docker command not found"
|
| 166 |
+
hint "Install Docker: https://docs.docker.com/get-docker/"
|
| 167 |
+
stop_at "Step 2"
|
| 168 |
+
fi
|
| 169 |
+
|
| 170 |
+
if [ -f "$REPO_DIR/Dockerfile" ]; then
|
| 171 |
+
DOCKER_CONTEXT="$REPO_DIR"
|
| 172 |
+
elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
|
| 173 |
+
DOCKER_CONTEXT="$REPO_DIR/server"
|
| 174 |
+
else
|
| 175 |
+
fail "No Dockerfile found in repo root or server/ directory"
|
| 176 |
+
stop_at "Step 2"
|
| 177 |
+
fi
|
| 178 |
+
|
| 179 |
+
log " Found Dockerfile in $DOCKER_CONTEXT"
|
| 180 |
+
|
| 181 |
+
BUILD_OK=false
|
| 182 |
+
BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
|
| 183 |
+
|
| 184 |
+
if [ "$BUILD_OK" = true ]; then
|
| 185 |
+
pass "Docker build succeeded"
|
| 186 |
+
else
|
| 187 |
+
fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
|
| 188 |
+
printf "%s\n" "$BUILD_OUTPUT" | tail -20
|
| 189 |
+
stop_at "Step 2"
|
| 190 |
+
fi
|
| 191 |
+
fi
|
| 192 |
+
|
| 193 |
+
# ──────────────────────────────────────
|
| 194 |
+
# Step 3: openenv validate
|
| 195 |
+
# ──────────────────────────────────────
|
| 196 |
+
log "${BOLD}Step 3/5: Running openenv validate${NC} ..."
|
| 197 |
+
|
| 198 |
+
VALIDATE_OK=false
|
| 199 |
+
VALIDATE_OUTPUT=""
|
| 200 |
+
VENV_OPENENV="$REPO_DIR/.venv/bin/openenv"
|
| 201 |
+
if command -v uv &>/dev/null && [ -f "$REPO_DIR/pyproject.toml" ]; then
|
| 202 |
+
log " Using: uv run openenv validate (avoids global CLI / Python mismatch)"
|
| 203 |
+
VALIDATE_OUTPUT=$(cd "$REPO_DIR" && uv run openenv validate 2>&1) && VALIDATE_OK=true
|
| 204 |
+
elif command -v openenv &>/dev/null; then
|
| 205 |
+
VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
|
| 206 |
+
elif [ -x "$VENV_OPENENV" ]; then
|
| 207 |
+
log " Using: .venv/bin/openenv (repo virtualenv; run: uv sync)"
|
| 208 |
+
VALIDATE_OUTPUT=$(cd "$REPO_DIR" && "$VENV_OPENENV" validate 2>&1) && VALIDATE_OK=true
|
| 209 |
+
else
|
| 210 |
+
fail "openenv not found (no uv, no openenv on PATH, no .venv/bin/openenv)"
|
| 211 |
+
hint "From the repo: uv sync # then re-run; or: pip install openenv-core"
|
| 212 |
+
stop_at "Step 3"
|
| 213 |
+
fi
|
| 214 |
+
|
| 215 |
+
if [ "$VALIDATE_OK" = true ]; then
|
| 216 |
+
pass "openenv validate passed"
|
| 217 |
+
[ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
|
| 218 |
+
else
|
| 219 |
+
fail "openenv validate failed"
|
| 220 |
+
printf "%s\n" "$VALIDATE_OUTPUT"
|
| 221 |
+
stop_at "Step 3"
|
| 222 |
+
fi
|
| 223 |
+
|
| 224 |
+
# ──────────────────────────────────────
|
| 225 |
+
# Step 4: Viraltest-specific checks
|
| 226 |
+
# ──────────────────────────────────────
|
| 227 |
+
log "${BOLD}Step 4/5: Viraltest environment checks${NC} ..."
|
| 228 |
+
|
| 229 |
+
STEP_OUTPUT=$(portable_mktemp "validate-step")
|
| 230 |
+
CLEANUP_FILES+=("$STEP_OUTPUT")
|
| 231 |
+
|
| 232 |
+
# Test all 3 tasks respond to reset
|
| 233 |
+
for TASK in weekly_engage weekly_strategic weekly_competitive; do
|
| 234 |
+
TASK_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
|
| 235 |
+
-H "Content-Type: application/json" \
|
| 236 |
+
-d "{\"task\": \"$TASK\"}" \
|
| 237 |
+
"$PING_URL/reset" --max-time 15 2>/dev/null || printf "000")
|
| 238 |
+
|
| 239 |
+
if [ "$TASK_CODE" = "200" ]; then
|
| 240 |
+
log " ${GREEN}OK${NC} task=$TASK reset responds"
|
| 241 |
+
else
|
| 242 |
+
fail "Task $TASK reset returned HTTP $TASK_CODE"
|
| 243 |
+
stop_at "Step 4"
|
| 244 |
+
fi
|
| 245 |
+
done
|
| 246 |
+
|
| 247 |
+
# Test step endpoint with a daily plan action (sparse: one post at hour 12)
|
| 248 |
+
STEP_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
|
| 249 |
+
-H "Content-Type: application/json" \
|
| 250 |
+
-d '{"action":{"scheduled_actions":[{"hour":12,"action_type":"post","content_type":"reel","topic":"AI trends","tags":["ai","ml"]}]}}' \
|
| 251 |
+
"$PING_URL/step" --max-time 15 2>/dev/null || printf "000")
|
| 252 |
+
|
| 253 |
+
if [ "$STEP_CODE" = "200" ]; then
|
| 254 |
+
pass "Step endpoint responds correctly"
|
| 255 |
+
else
|
| 256 |
+
fail "Step endpoint returned HTTP $STEP_CODE"
|
| 257 |
+
stop_at "Step 4"
|
| 258 |
+
fi
|
| 259 |
+
|
| 260 |
+
# Check inference.py exists
|
| 261 |
+
if [ -f "$REPO_DIR/inference.py" ]; then
|
| 262 |
+
pass "inference.py found in project root"
|
| 263 |
+
else
|
| 264 |
+
fail "inference.py not found in $REPO_DIR"
|
| 265 |
+
stop_at "Step 4"
|
| 266 |
+
fi
|
| 267 |
+
|
| 268 |
+
# ──────────────────────────────────────
|
| 269 |
+
# Step 5: HF Inference Router — one chat completion
|
| 270 |
+
# ──────────────────────────────────────
|
| 271 |
+
DEFAULT_SMOKE_MODEL="gemma-4-E4B-it-IQ4_XS"
|
| 272 |
+
DEFAULT_SMOKE_API="https://router.huggingface.co/v1"
|
| 273 |
+
SMOKE_MODEL="${MODEL_NAME:-$DEFAULT_SMOKE_MODEL}"
|
| 274 |
+
SMOKE_API="${API_BASE_URL:-$DEFAULT_SMOKE_API}"
|
| 275 |
+
|
| 276 |
+
if [ "${SKIP_LLM_SMOKE:-}" = "1" ]; then
|
| 277 |
+
log "${BOLD}Step 5/5: LLM router smoke test${NC} ${YELLOW}SKIPPED${NC} (SKIP_LLM_SMOKE=1)"
|
| 278 |
+
elif [ -z "${HF_TOKEN:-}" ]; then
|
| 279 |
+
fail "Step 5 requires HF_TOKEN (Inference router). Export it from https://huggingface.co/settings/tokens"
|
| 280 |
+
hint "Override model/URL: MODEL_NAME and API_BASE_URL (defaults: $DEFAULT_SMOKE_MODEL, $DEFAULT_SMOKE_API). To skip Step 5: SKIP_LLM_SMOKE=1"
|
| 281 |
+
stop_at "Step 5"
|
| 282 |
+
else
|
| 283 |
+
log "${BOLD}Step 5/5: LLM router smoke test${NC} (model=$SMOKE_MODEL) ..."
|
| 284 |
+
LLM_OK=false
|
| 285 |
+
LLM_OUT=""
|
| 286 |
+
if [ ! -f "$REPO_DIR/pyproject.toml" ]; then
|
| 287 |
+
fail "No pyproject.toml in repo — cannot run LLM smoke test"
|
| 288 |
+
stop_at "Step 5"
|
| 289 |
+
fi
|
| 290 |
+
RUN_PYTHON=()
|
| 291 |
+
if command -v uv &>/dev/null; then
|
| 292 |
+
RUN_PYTHON=(uv run python)
|
| 293 |
+
elif [ -x "$REPO_DIR/.venv/bin/python" ]; then
|
| 294 |
+
RUN_PYTHON=("$REPO_DIR/.venv/bin/python")
|
| 295 |
+
else
|
| 296 |
+
fail "Need uv on PATH or .venv/bin/python (run: uv sync)"
|
| 297 |
+
stop_at "Step 5"
|
| 298 |
+
fi
|
| 299 |
+
if [ "${#RUN_PYTHON[@]}" -gt 0 ]; then
|
| 300 |
+
LLM_OUT=$(cd "$REPO_DIR" && \
|
| 301 |
+
MODEL_NAME="$SMOKE_MODEL" API_BASE_URL="$SMOKE_API" HF_TOKEN="$HF_TOKEN" \
|
| 302 |
+
"${RUN_PYTHON[@]}" - <<'PY' 2>&1
|
| 303 |
+
import os, sys
|
| 304 |
+
from openai import OpenAI
|
| 305 |
+
|
| 306 |
+
def main() -> None:
|
| 307 |
+
client = OpenAI(
|
| 308 |
+
base_url=os.environ["API_BASE_URL"].rstrip("/"),
|
| 309 |
+
api_key=os.environ["HF_TOKEN"],
|
| 310 |
+
)
|
| 311 |
+
r = client.chat.completions.create(
|
| 312 |
+
model=os.environ["MODEL_NAME"],
|
| 313 |
+
messages=[{"role": "user", "content": "Reply with exactly: OK"}],
|
| 314 |
+
max_tokens=32,
|
| 315 |
+
temperature=0.0,
|
| 316 |
+
)
|
| 317 |
+
text = (r.choices[0].message.content or "").strip()
|
| 318 |
+
if not text:
|
| 319 |
+
print("empty completion", file=sys.stderr)
|
| 320 |
+
sys.exit(1)
|
| 321 |
+
print(text[:500])
|
| 322 |
+
|
| 323 |
+
if __name__ == "__main__":
|
| 324 |
+
main()
|
| 325 |
+
PY
|
| 326 |
+
) && LLM_OK=true
|
| 327 |
+
fi
|
| 328 |
+
|
| 329 |
+
if [ "$LLM_OK" = true ]; then
|
| 330 |
+
pass "LLM router responded"
|
| 331 |
+
if [ -n "$LLM_OUT" ]; then
|
| 332 |
+
preview="${LLM_OUT:0:120}"
|
| 333 |
+
[ "${#LLM_OUT}" -gt 120 ] && preview="${preview}..."
|
| 334 |
+
log " completion: $preview"
|
| 335 |
+
fi
|
| 336 |
+
else
|
| 337 |
+
fail "LLM router smoke test failed"
|
| 338 |
+
printf "%s\n" "$LLM_OUT"
|
| 339 |
+
if [ "${LLM_SMOKE_OPTIONAL:-}" = "1" ]; then
|
| 340 |
+
hint "LLM_SMOKE_OPTIONAL=1 set — continuing (fix HF token / Inference Providers access for real inference runs)."
|
| 341 |
+
else
|
| 342 |
+
hint "403 often means the token cannot use Inference Providers for this account. See HF token settings or set LLM_SMOKE_OPTIONAL=1 to still pass Steps 1–4."
|
| 343 |
+
stop_at "Step 5"
|
| 344 |
+
fi
|
| 345 |
+
fi
|
| 346 |
+
fi
|
| 347 |
+
|
| 348 |
+
printf "\n"
|
| 349 |
+
printf "${BOLD}========================================${NC}\n"
|
| 350 |
+
printf "${GREEN}${BOLD} All checks passed!${NC}\n"
|
| 351 |
+
printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
|
| 352 |
+
printf "${BOLD}========================================${NC}\n"
|
| 353 |
+
printf "\n"
|
| 354 |
+
|
| 355 |
+
exit 0
|
visualize_optimal.py
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Visualization of optimal posting strategies for the Viraltest environment.
|
| 3 |
+
Shows engagement multipliers, sleep effects, recommended posting windows,
|
| 4 |
+
and simulation results for all 61 test scenarios.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
from matplotlib.patches import Rectangle
|
| 10 |
+
from matplotlib.colors import LinearSegmentedColormap
|
| 11 |
+
from collections import Counter
|
| 12 |
+
from typing import Callable, List, Tuple, Dict, Any
|
| 13 |
+
|
| 14 |
+
# Environment constants (matching viraltest_environment.py)
|
| 15 |
+
CONTENT_ENERGY_COST = {"reel": 0.25, "carousel": 0.20, "story": 0.08, "text_post": 0.06}
|
| 16 |
+
BASE_ENGAGEMENT = {"reel": 0.52, "carousel": 0.55, "story": 0.30, "text_post": 0.37}
|
| 17 |
+
REACH_MULT = {"reel": 2.25, "carousel": 1.0, "story": 0.5, "text_post": 0.44}
|
| 18 |
+
WEEKEND_PENALTY = 0.7
|
| 19 |
+
PEAK_DAYS = (1, 2, 3) # Tue, Wed, Thu
|
| 20 |
+
|
| 21 |
+
# Sleep constants
|
| 22 |
+
SLEEP_OPTIMAL_AWAKE = 14
|
| 23 |
+
SLEEP_HALFLIFE_HOURS = 10
|
| 24 |
+
SLEEP_MIN_QUALITY = 0.30
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_hour_multiplier(hour: int, day: int) -> float:
|
| 28 |
+
"""Calculate engagement multiplier for given hour and day."""
|
| 29 |
+
is_weekend = day >= 5
|
| 30 |
+
base = WEEKEND_PENALTY if is_weekend else 1.0
|
| 31 |
+
|
| 32 |
+
if 12 <= hour < 15 and day in PEAK_DAYS:
|
| 33 |
+
return base * 1.4
|
| 34 |
+
if 9 <= hour < 12:
|
| 35 |
+
return base * 1.3
|
| 36 |
+
if 18 <= hour < 20:
|
| 37 |
+
return base * 1.25
|
| 38 |
+
if 20 <= hour < 23:
|
| 39 |
+
return base * 1.1
|
| 40 |
+
if hour >= 23 or hour < 6:
|
| 41 |
+
return base * 0.5
|
| 42 |
+
return base * 0.8
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_sleep_factor(hours_since_sleep: int) -> float:
|
| 46 |
+
"""Calculate sleep quality factor (exponential decay)."""
|
| 47 |
+
if hours_since_sleep <= SLEEP_OPTIMAL_AWAKE:
|
| 48 |
+
return 1.0
|
| 49 |
+
hours_over = hours_since_sleep - SLEEP_OPTIMAL_AWAKE
|
| 50 |
+
factor = 0.5 ** (hours_over / SLEEP_HALFLIFE_HOURS)
|
| 51 |
+
return max(SLEEP_MIN_QUALITY, factor)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def create_visualizations():
|
| 55 |
+
"""Generate all visualization plots."""
|
| 56 |
+
fig = plt.figure(figsize=(16, 14))
|
| 57 |
+
fig.suptitle('Viraltest Environment - Optimal Posting Strategy Guide',
|
| 58 |
+
fontsize=16, fontweight='bold', y=0.98)
|
| 59 |
+
|
| 60 |
+
# 1. Hour x Day Engagement Heatmap
|
| 61 |
+
ax1 = fig.add_subplot(2, 2, 1)
|
| 62 |
+
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
| 63 |
+
hours = list(range(24))
|
| 64 |
+
|
| 65 |
+
heatmap_data = np.zeros((24, 7))
|
| 66 |
+
for d in range(7):
|
| 67 |
+
for h in range(24):
|
| 68 |
+
heatmap_data[h, d] = get_hour_multiplier(h, d)
|
| 69 |
+
|
| 70 |
+
im = ax1.imshow(heatmap_data, aspect='auto', cmap='RdYlGn', vmin=0.3, vmax=1.5)
|
| 71 |
+
ax1.set_xticks(range(7))
|
| 72 |
+
ax1.set_xticklabels(days)
|
| 73 |
+
ax1.set_yticks(range(0, 24, 2))
|
| 74 |
+
ax1.set_yticklabels([f'{h:02d}:00' for h in range(0, 24, 2)])
|
| 75 |
+
ax1.set_xlabel('Day of Week')
|
| 76 |
+
ax1.set_ylabel('Hour of Day')
|
| 77 |
+
ax1.set_title('Engagement Multiplier by Hour & Day', fontweight='bold')
|
| 78 |
+
|
| 79 |
+
# Add colorbar
|
| 80 |
+
cbar = plt.colorbar(im, ax=ax1, shrink=0.8)
|
| 81 |
+
cbar.set_label('Multiplier')
|
| 82 |
+
|
| 83 |
+
# Highlight peak zones
|
| 84 |
+
for d in PEAK_DAYS:
|
| 85 |
+
rect = Rectangle((d-0.5, 11.5), 1, 3, linewidth=2,
|
| 86 |
+
edgecolor='blue', facecolor='none', linestyle='--')
|
| 87 |
+
ax1.add_patch(rect)
|
| 88 |
+
ax1.text(2, 10.5, 'PEAK\nZONE', fontsize=8, color='blue', ha='center', fontweight='bold')
|
| 89 |
+
|
| 90 |
+
# 2. Content Type Comparison
|
| 91 |
+
ax2 = fig.add_subplot(2, 2, 2)
|
| 92 |
+
content_types = list(BASE_ENGAGEMENT.keys())
|
| 93 |
+
x = np.arange(len(content_types))
|
| 94 |
+
width = 0.25
|
| 95 |
+
|
| 96 |
+
base_vals = [BASE_ENGAGEMENT[ct] for ct in content_types]
|
| 97 |
+
reach_vals = [REACH_MULT[ct] for ct in content_types]
|
| 98 |
+
energy_vals = [CONTENT_ENERGY_COST[ct] for ct in content_types]
|
| 99 |
+
|
| 100 |
+
# Calculate effective engagement (base * reach)
|
| 101 |
+
effective = [BASE_ENGAGEMENT[ct] * REACH_MULT[ct] for ct in content_types]
|
| 102 |
+
|
| 103 |
+
bars1 = ax2.bar(x - width, base_vals, width, label='Base Engagement', color='steelblue')
|
| 104 |
+
bars2 = ax2.bar(x, reach_vals, width, label='Reach Multiplier', color='seagreen')
|
| 105 |
+
bars3 = ax2.bar(x + width, energy_vals, width, label='Energy Cost', color='coral')
|
| 106 |
+
|
| 107 |
+
ax2.set_xlabel('Content Type')
|
| 108 |
+
ax2.set_ylabel('Value')
|
| 109 |
+
ax2.set_title('Content Type Comparison', fontweight='bold')
|
| 110 |
+
ax2.set_xticks(x)
|
| 111 |
+
ax2.set_xticklabels(['Reel', 'Carousel', 'Story', 'Text Post'])
|
| 112 |
+
ax2.legend(loc='upper right')
|
| 113 |
+
ax2.grid(axis='y', alpha=0.3)
|
| 114 |
+
|
| 115 |
+
# Add efficiency annotation
|
| 116 |
+
efficiency = [(BASE_ENGAGEMENT[ct] * REACH_MULT[ct]) / CONTENT_ENERGY_COST[ct]
|
| 117 |
+
for ct in content_types]
|
| 118 |
+
for i, (ct, eff) in enumerate(zip(content_types, efficiency)):
|
| 119 |
+
ax2.annotate(f'Eff: {eff:.1f}', (i, max(base_vals[i], reach_vals[i], energy_vals[i]) + 0.1),
|
| 120 |
+
ha='center', fontsize=8, color='purple')
|
| 121 |
+
|
| 122 |
+
# 3. Sleep Quality Decay Curve
|
| 123 |
+
ax3 = fig.add_subplot(2, 2, 3)
|
| 124 |
+
hours_awake = np.linspace(0, 40, 200)
|
| 125 |
+
sleep_quality = [get_sleep_factor(int(h)) for h in hours_awake]
|
| 126 |
+
|
| 127 |
+
ax3.plot(hours_awake, sleep_quality, 'b-', linewidth=2, label='Quality Factor')
|
| 128 |
+
ax3.axvline(x=SLEEP_OPTIMAL_AWAKE, color='green', linestyle='--',
|
| 129 |
+
label=f'Optimal threshold ({SLEEP_OPTIMAL_AWAKE}h)')
|
| 130 |
+
ax3.axhline(y=0.5, color='orange', linestyle=':', alpha=0.7,
|
| 131 |
+
label='50% quality (24h awake)')
|
| 132 |
+
ax3.axhline(y=SLEEP_MIN_QUALITY, color='red', linestyle=':', alpha=0.7,
|
| 133 |
+
label=f'Floor ({SLEEP_MIN_QUALITY*100:.0f}%)')
|
| 134 |
+
|
| 135 |
+
# Fill regions
|
| 136 |
+
ax3.fill_between(hours_awake, sleep_quality, alpha=0.3)
|
| 137 |
+
ax3.axvspan(0, SLEEP_OPTIMAL_AWAKE, alpha=0.1, color='green', label='_No fatigue')
|
| 138 |
+
ax3.axvspan(SLEEP_OPTIMAL_AWAKE, 24, alpha=0.1, color='yellow')
|
| 139 |
+
ax3.axvspan(24, 40, alpha=0.1, color='red')
|
| 140 |
+
|
| 141 |
+
ax3.set_xlabel('Hours Since Sleep')
|
| 142 |
+
ax3.set_ylabel('Quality Multiplier')
|
| 143 |
+
ax3.set_title('Sleep Deprivation Effect (Exponential Decay)', fontweight='bold')
|
| 144 |
+
ax3.set_xlim(0, 40)
|
| 145 |
+
ax3.set_ylim(0, 1.1)
|
| 146 |
+
ax3.legend(loc='upper right', fontsize=8)
|
| 147 |
+
ax3.grid(alpha=0.3)
|
| 148 |
+
|
| 149 |
+
# Add annotations
|
| 150 |
+
ax3.annotate('No impact', xy=(7, 1.02), fontsize=9, color='green')
|
| 151 |
+
ax3.annotate('Mild fatigue', xy=(18, 0.85), fontsize=9, color='orange')
|
| 152 |
+
ax3.annotate('Severe', xy=(30, 0.4), fontsize=9, color='red')
|
| 153 |
+
|
| 154 |
+
# 4. Optimal Daily Schedule
|
| 155 |
+
ax4 = fig.add_subplot(2, 2, 4)
|
| 156 |
+
|
| 157 |
+
# Create a 24-hour timeline
|
| 158 |
+
hours_day = np.arange(24)
|
| 159 |
+
|
| 160 |
+
# Define activity zones
|
| 161 |
+
sleep_zone = [(0, 7)] # Sleep 0-7
|
| 162 |
+
low_zone = [(7, 9), (21, 24)] # Low engagement
|
| 163 |
+
medium_zone = [(9, 12), (15, 18), (20, 21)] # Medium
|
| 164 |
+
peak_zone = [(12, 15), (18, 20)] # Peak
|
| 165 |
+
|
| 166 |
+
# Plot colored bands
|
| 167 |
+
for start, end in sleep_zone:
|
| 168 |
+
ax4.axvspan(start, end, alpha=0.3, color='navy', label='Sleep (rest)' if start == 0 else '')
|
| 169 |
+
for start, end in low_zone:
|
| 170 |
+
ax4.axvspan(start, end, alpha=0.3, color='gray', label='Low engagement' if start == 7 else '')
|
| 171 |
+
for start, end in medium_zone:
|
| 172 |
+
ax4.axvspan(start, end, alpha=0.3, color='yellow', label='Medium' if start == 9 else '')
|
| 173 |
+
for start, end in peak_zone:
|
| 174 |
+
ax4.axvspan(start, end, alpha=0.4, color='green', label='Peak hours' if start == 12 else '')
|
| 175 |
+
|
| 176 |
+
# Plot engagement curve for peak weekday
|
| 177 |
+
engagement_curve = [get_hour_multiplier(h, 2) for h in hours_day] # Wednesday
|
| 178 |
+
ax4.plot(hours_day, engagement_curve, 'k-', linewidth=2, marker='o', markersize=4)
|
| 179 |
+
|
| 180 |
+
# Add recommended actions
|
| 181 |
+
actions = [
|
| 182 |
+
(3, 0.3, 'SLEEP', 'white'),
|
| 183 |
+
(10, 1.35, 'POST #1', 'darkgreen'),
|
| 184 |
+
(13, 1.45, 'PEAK POST', 'darkgreen'),
|
| 185 |
+
(19, 1.3, 'POST #2', 'darkgreen'),
|
| 186 |
+
(16, 0.85, 'Rest/Create', 'gray'),
|
| 187 |
+
]
|
| 188 |
+
for x, y, text, color in actions:
|
| 189 |
+
ax4.annotate(text, (x, y), fontsize=9, fontweight='bold',
|
| 190 |
+
color=color, ha='center',
|
| 191 |
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
|
| 192 |
+
|
| 193 |
+
ax4.set_xlabel('Hour of Day')
|
| 194 |
+
ax4.set_ylabel('Engagement Multiplier')
|
| 195 |
+
ax4.set_title('Optimal Daily Schedule (Peak Weekday: Tue-Thu)', fontweight='bold')
|
| 196 |
+
ax4.set_xlim(0, 24)
|
| 197 |
+
ax4.set_ylim(0, 1.6)
|
| 198 |
+
ax4.set_xticks(range(0, 25, 3))
|
| 199 |
+
ax4.set_xticklabels([f'{h:02d}:00' for h in range(0, 25, 3)])
|
| 200 |
+
ax4.legend(loc='lower right', fontsize=8)
|
| 201 |
+
ax4.grid(alpha=0.3)
|
| 202 |
+
|
| 203 |
+
plt.tight_layout(rect=[0, 0, 1, 0.96])
|
| 204 |
+
|
| 205 |
+
# Save figure
|
| 206 |
+
plt.savefig('optimal_posting_guide.png', dpi=150, bbox_inches='tight',
|
| 207 |
+
facecolor='white', edgecolor='none')
|
| 208 |
+
print("Saved: optimal_posting_guide.png")
|
| 209 |
+
|
| 210 |
+
# Create second figure with strategy summary
|
| 211 |
+
fig2, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 212 |
+
fig2.suptitle('Strategy Recommendations', fontsize=14, fontweight='bold')
|
| 213 |
+
|
| 214 |
+
# Left: Energy vs Posts tradeoff
|
| 215 |
+
ax5 = axes[0]
|
| 216 |
+
posts_per_day = np.arange(0, 6)
|
| 217 |
+
|
| 218 |
+
# Calculate energy remaining after N posts of each type
|
| 219 |
+
for ct in content_types:
|
| 220 |
+
cost = CONTENT_ENERGY_COST[ct]
|
| 221 |
+
energy_remaining = [max(0, 1.0 - n * cost) for n in posts_per_day]
|
| 222 |
+
ax5.plot(posts_per_day, energy_remaining, '-o', label=ct.replace('_', ' ').title(), linewidth=2)
|
| 223 |
+
|
| 224 |
+
ax5.axhline(y=0.4, color='orange', linestyle='--', label='Safe threshold')
|
| 225 |
+
ax5.axhline(y=0.2, color='red', linestyle='--', label='Burnout risk')
|
| 226 |
+
|
| 227 |
+
ax5.set_xlabel('Posts Per Day')
|
| 228 |
+
ax5.set_ylabel('Energy Remaining')
|
| 229 |
+
ax5.set_title('Energy Drain by Content Type', fontweight='bold')
|
| 230 |
+
ax5.legend(loc='upper right')
|
| 231 |
+
ax5.grid(alpha=0.3)
|
| 232 |
+
ax5.set_xlim(0, 5)
|
| 233 |
+
ax5.set_ylim(0, 1.1)
|
| 234 |
+
|
| 235 |
+
# Right: Effective Engagement Score
|
| 236 |
+
ax6 = axes[1]
|
| 237 |
+
|
| 238 |
+
# Calculate total effective engagement for different strategies
|
| 239 |
+
strategies = [
|
| 240 |
+
('2 Reels/day', 2 * BASE_ENGAGEMENT['reel'] * REACH_MULT['reel'], 2 * CONTENT_ENERGY_COST['reel']),
|
| 241 |
+
('2 Carousels/day', 2 * BASE_ENGAGEMENT['carousel'] * REACH_MULT['carousel'], 2 * CONTENT_ENERGY_COST['carousel']),
|
| 242 |
+
('1 Reel + 1 Carousel', BASE_ENGAGEMENT['reel'] * REACH_MULT['reel'] + BASE_ENGAGEMENT['carousel'] * REACH_MULT['carousel'],
|
| 243 |
+
CONTENT_ENERGY_COST['reel'] + CONTENT_ENERGY_COST['carousel']),
|
| 244 |
+
('3 Stories/day', 3 * BASE_ENGAGEMENT['story'] * REACH_MULT['story'], 3 * CONTENT_ENERGY_COST['story']),
|
| 245 |
+
('4 Text Posts/day', 4 * BASE_ENGAGEMENT['text_post'] * REACH_MULT['text_post'], 4 * CONTENT_ENERGY_COST['text_post']),
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
names = [s[0] for s in strategies]
|
| 249 |
+
engagement = [s[1] for s in strategies]
|
| 250 |
+
energy_cost = [s[2] for s in strategies]
|
| 251 |
+
efficiency = [e/c for e, c in zip(engagement, energy_cost)]
|
| 252 |
+
|
| 253 |
+
x = np.arange(len(names))
|
| 254 |
+
width = 0.35
|
| 255 |
+
|
| 256 |
+
bars1 = ax6.bar(x - width/2, engagement, width, label='Total Engagement', color='steelblue')
|
| 257 |
+
bars2 = ax6.bar(x + width/2, energy_cost, width, label='Energy Cost', color='coral')
|
| 258 |
+
|
| 259 |
+
ax6.set_ylabel('Value')
|
| 260 |
+
ax6.set_title('Daily Strategy Comparison', fontweight='bold')
|
| 261 |
+
ax6.set_xticks(x)
|
| 262 |
+
ax6.set_xticklabels(names, rotation=15, ha='right')
|
| 263 |
+
ax6.legend()
|
| 264 |
+
ax6.grid(axis='y', alpha=0.3)
|
| 265 |
+
|
| 266 |
+
# Add efficiency labels
|
| 267 |
+
for i, eff in enumerate(efficiency):
|
| 268 |
+
ax6.annotate(f'Eff: {eff:.1f}', (i, max(engagement[i], energy_cost[i]) + 0.1),
|
| 269 |
+
ha='center', fontsize=9, color='green', fontweight='bold')
|
| 270 |
+
|
| 271 |
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
| 272 |
+
plt.savefig('strategy_comparison.png', dpi=150, bbox_inches='tight',
|
| 273 |
+
facecolor='white', edgecolor='none')
|
| 274 |
+
print("Saved: strategy_comparison.png")
|
| 275 |
+
|
| 276 |
+
plt.show()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def print_summary():
|
| 280 |
+
"""Print text summary of optimal strategies."""
|
| 281 |
+
print("\n" + "="*70)
|
| 282 |
+
print("OPTIMAL POSTING STRATEGY SUMMARY")
|
| 283 |
+
print("="*70)
|
| 284 |
+
|
| 285 |
+
print("\n📅 BEST DAYS:")
|
| 286 |
+
print(" • Tuesday, Wednesday, Thursday (peak engagement)")
|
| 287 |
+
print(" • Weekend has 30% penalty")
|
| 288 |
+
|
| 289 |
+
print("\n⏰ BEST HOURS:")
|
| 290 |
+
print(" • 12:00-15:00 on Tue/Wed/Thu (+40% engagement)")
|
| 291 |
+
print(" • 09:00-12:00 any weekday (+30%)")
|
| 292 |
+
print(" • 18:00-20:00 evening (+25%)")
|
| 293 |
+
print(" • AVOID: 23:00-06:00 (-50%)")
|
| 294 |
+
|
| 295 |
+
print("\n📱 CONTENT TYPES (by reach efficiency):")
|
| 296 |
+
for ct in ['reel', 'carousel', 'text_post', 'story']:
|
| 297 |
+
eff = (BASE_ENGAGEMENT[ct] * REACH_MULT[ct]) / CONTENT_ENERGY_COST[ct]
|
| 298 |
+
print(f" • {ct.replace('_', ' ').title():12} - "
|
| 299 |
+
f"Reach: {REACH_MULT[ct]:.2f}x, Energy: {CONTENT_ENERGY_COST[ct]:.0%}, "
|
| 300 |
+
f"Efficiency: {eff:.1f}")
|
| 301 |
+
|
| 302 |
+
print("\n😴 SLEEP SCHEDULE:")
|
| 303 |
+
print(f" • No quality impact for first {SLEEP_OPTIMAL_AWAKE} hours awake")
|
| 304 |
+
print(" • Quality halves every 10 hours beyond that")
|
| 305 |
+
print(" • At 24h awake: 50% quality")
|
| 306 |
+
print(" • Rest during 23:00-07:00 to maintain quality")
|
| 307 |
+
|
| 308 |
+
print("\n🎯 RECOMMENDED DAILY ROUTINE:")
|
| 309 |
+
print(" 07:00 - Wake up (2h buffer before posting)")
|
| 310 |
+
print(" 09:00-12:00 - Post #1 (morning peak)")
|
| 311 |
+
print(" 12:00-15:00 - Post #2 (midday peak on Tue-Thu)")
|
| 312 |
+
print(" 15:00-18:00 - Rest or create content")
|
| 313 |
+
print(" 18:00-20:00 - Optional Post #3 (evening)")
|
| 314 |
+
print(" 23:00 - Sleep (rest actions)")
|
| 315 |
+
|
| 316 |
+
print("\n⚡ ENERGY MANAGEMENT:")
|
| 317 |
+
print(" • Stay above 0.4 energy (quality drops below 0.5)")
|
| 318 |
+
print(" • 2 reels/day = 50% energy (sustainable)")
|
| 319 |
+
print(" • Use content queue for 50% energy discount")
|
| 320 |
+
print(" • Rest recovers 12% energy + 2h sleep credit")
|
| 321 |
+
|
| 322 |
+
print("\n" + "="*70)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def run_all_scenarios() -> List[Dict[str, Any]]:
|
| 326 |
+
"""Run all 61 scenarios and collect results."""
|
| 327 |
+
from server.viraltest_environment import ViraltestEnvironment
|
| 328 |
+
from models import ViraltestAction
|
| 329 |
+
from test_scenarios import SCENARIOS, TASKS
|
| 330 |
+
|
| 331 |
+
# Import reset functions
|
| 332 |
+
from test_scenarios import (
|
| 333 |
+
_reset_smart_state, _reset_queue_state, _reset_burst_state,
|
| 334 |
+
_reset_tag_explorer_state, _reset_balanced_state, _reset_queue_heavy_state,
|
| 335 |
+
_reset_alternating_state, _reset_content_creator_state, _reset_nap_state
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
def _reset_all():
|
| 339 |
+
_reset_smart_state()
|
| 340 |
+
_reset_queue_state()
|
| 341 |
+
_reset_burst_state()
|
| 342 |
+
_reset_tag_explorer_state()
|
| 343 |
+
_reset_balanced_state()
|
| 344 |
+
_reset_queue_heavy_state()
|
| 345 |
+
_reset_alternating_state()
|
| 346 |
+
_reset_content_creator_state()
|
| 347 |
+
_reset_nap_state()
|
| 348 |
+
|
| 349 |
+
SEED = 42
|
| 350 |
+
results = []
|
| 351 |
+
|
| 352 |
+
for scenario_name, agent_fn, description in SCENARIOS:
|
| 353 |
+
scenario_results = {
|
| 354 |
+
'name': scenario_name,
|
| 355 |
+
'description': description,
|
| 356 |
+
'scores': {},
|
| 357 |
+
'details': {}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
for task in TASKS:
|
| 361 |
+
_reset_all()
|
| 362 |
+
env = ViraltestEnvironment()
|
| 363 |
+
obs = env.reset(task=task, seed=SEED)
|
| 364 |
+
|
| 365 |
+
rewards = []
|
| 366 |
+
actions = []
|
| 367 |
+
min_energy = 1.0
|
| 368 |
+
max_sleep_debt = 0.0
|
| 369 |
+
burned_out = False
|
| 370 |
+
|
| 371 |
+
for step in range(1, 169):
|
| 372 |
+
action = agent_fn(obs, step)
|
| 373 |
+
obs = env.step(action)
|
| 374 |
+
r = obs.reward if obs.reward is not None else 0.0
|
| 375 |
+
rewards.append(r)
|
| 376 |
+
actions.append(action.action_type)
|
| 377 |
+
min_energy = min(min_energy, obs.creator_energy)
|
| 378 |
+
max_sleep_debt = max(max_sleep_debt, obs.sleep_debt)
|
| 379 |
+
if obs.done and obs.creator_energy <= 0:
|
| 380 |
+
burned_out = True
|
| 381 |
+
if obs.done:
|
| 382 |
+
break
|
| 383 |
+
|
| 384 |
+
score = (obs.metadata or {}).get("grader_score", 0.0)
|
| 385 |
+
action_counts = Counter(actions)
|
| 386 |
+
|
| 387 |
+
scenario_results['scores'][task] = score
|
| 388 |
+
scenario_results['details'][task] = {
|
| 389 |
+
'steps': len(rewards),
|
| 390 |
+
'burned_out': burned_out,
|
| 391 |
+
'min_energy': min_energy,
|
| 392 |
+
'max_sleep_debt': max_sleep_debt,
|
| 393 |
+
'final_energy': obs.creator_energy,
|
| 394 |
+
'followers': obs.follower_count,
|
| 395 |
+
'follower_delta': obs.follower_count - 10000,
|
| 396 |
+
'engagement_rate': obs.engagement_rate,
|
| 397 |
+
'posts': action_counts.get('post', 0),
|
| 398 |
+
'rests': action_counts.get('rest', 0),
|
| 399 |
+
'creates': action_counts.get('create_content', 0),
|
| 400 |
+
'total_reward': sum(rewards),
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
results.append(scenario_results)
|
| 404 |
+
|
| 405 |
+
return results
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def create_scenario_visualizations(results: List[Dict[str, Any]]):
|
| 409 |
+
"""Create visualizations for all scenario results."""
|
| 410 |
+
|
| 411 |
+
# Extract data
|
| 412 |
+
names = [r['name'].replace('SCENARIO ', 'S') for r in results]
|
| 413 |
+
short_names = [n.split(':')[0] for n in names] # Just "S1", "S2", etc.
|
| 414 |
+
|
| 415 |
+
engage_scores = [r['scores']['weekly_engage'] for r in results]
|
| 416 |
+
strategic_scores = [r['scores']['weekly_strategic'] for r in results]
|
| 417 |
+
competitive_scores = [r['scores']['weekly_competitive'] for r in results]
|
| 418 |
+
|
| 419 |
+
# Figure 1: Score comparison bar chart
|
| 420 |
+
fig1, axes = plt.subplots(3, 1, figsize=(18, 12))
|
| 421 |
+
fig1.suptitle('All 61 Scenarios - Performance Scores by Task', fontsize=14, fontweight='bold')
|
| 422 |
+
|
| 423 |
+
x = np.arange(len(results))
|
| 424 |
+
|
| 425 |
+
# Color based on score
|
| 426 |
+
def get_colors(scores):
|
| 427 |
+
colors = []
|
| 428 |
+
for s in scores:
|
| 429 |
+
if s >= 0.7:
|
| 430 |
+
colors.append('green')
|
| 431 |
+
elif s >= 0.4:
|
| 432 |
+
colors.append('orange')
|
| 433 |
+
elif s > 0:
|
| 434 |
+
colors.append('coral')
|
| 435 |
+
else:
|
| 436 |
+
colors.append('red')
|
| 437 |
+
return colors
|
| 438 |
+
|
| 439 |
+
# Weekly Engage
|
| 440 |
+
ax1 = axes[0]
|
| 441 |
+
ax1.bar(x, engage_scores, color=get_colors(engage_scores), edgecolor='black', linewidth=0.5)
|
| 442 |
+
ax1.set_ylabel('Score')
|
| 443 |
+
ax1.set_title('Weekly Engage Task', fontweight='bold')
|
| 444 |
+
ax1.set_xticks(x)
|
| 445 |
+
ax1.set_xticklabels(short_names, rotation=90, fontsize=7)
|
| 446 |
+
ax1.axhline(y=0.7, color='green', linestyle='--', alpha=0.5, label='Good (0.7+)')
|
| 447 |
+
ax1.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5, label='Medium (0.4+)')
|
| 448 |
+
ax1.set_ylim(0, 1.1)
|
| 449 |
+
ax1.legend(loc='upper right')
|
| 450 |
+
ax1.grid(axis='y', alpha=0.3)
|
| 451 |
+
|
| 452 |
+
# Weekly Strategic
|
| 453 |
+
ax2 = axes[1]
|
| 454 |
+
ax2.bar(x, strategic_scores, color=get_colors(strategic_scores), edgecolor='black', linewidth=0.5)
|
| 455 |
+
ax2.set_ylabel('Score')
|
| 456 |
+
ax2.set_title('Weekly Strategic Task', fontweight='bold')
|
| 457 |
+
ax2.set_xticks(x)
|
| 458 |
+
ax2.set_xticklabels(short_names, rotation=90, fontsize=7)
|
| 459 |
+
ax2.axhline(y=0.7, color='green', linestyle='--', alpha=0.5)
|
| 460 |
+
ax2.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5)
|
| 461 |
+
ax2.set_ylim(0, 1.1)
|
| 462 |
+
ax2.grid(axis='y', alpha=0.3)
|
| 463 |
+
|
| 464 |
+
# Weekly Competitive
|
| 465 |
+
ax3 = axes[2]
|
| 466 |
+
ax3.bar(x, competitive_scores, color=get_colors(competitive_scores), edgecolor='black', linewidth=0.5)
|
| 467 |
+
ax3.set_ylabel('Score')
|
| 468 |
+
ax3.set_title('Weekly Competitive Task', fontweight='bold')
|
| 469 |
+
ax3.set_xticks(x)
|
| 470 |
+
ax3.set_xticklabels(short_names, rotation=90, fontsize=7)
|
| 471 |
+
ax3.axhline(y=0.7, color='green', linestyle='--', alpha=0.5)
|
| 472 |
+
ax3.axhline(y=0.4, color='orange', linestyle='--', alpha=0.5)
|
| 473 |
+
ax3.set_ylim(0, 1.1)
|
| 474 |
+
ax3.grid(axis='y', alpha=0.3)
|
| 475 |
+
|
| 476 |
+
plt.tight_layout(rect=[0, 0, 1, 0.97])
|
| 477 |
+
plt.savefig('scenario_scores.png', dpi=150, bbox_inches='tight', facecolor='white')
|
| 478 |
+
print("Saved: scenario_scores.png")
|
| 479 |
+
|
| 480 |
+
# Figure 2: Top 15 scenarios
|
| 481 |
+
fig2, ax = plt.subplots(figsize=(14, 8))
|
| 482 |
+
fig2.suptitle('Top 15 Scenarios by Average Score', fontsize=14, fontweight='bold')
|
| 483 |
+
|
| 484 |
+
# Calculate average scores
|
| 485 |
+
avg_scores = [(r['name'],
|
| 486 |
+
(r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3,
|
| 487 |
+
r['scores']['weekly_engage'],
|
| 488 |
+
r['scores']['weekly_strategic'],
|
| 489 |
+
r['scores']['weekly_competitive'])
|
| 490 |
+
for r in results]
|
| 491 |
+
avg_scores.sort(key=lambda x: x[1], reverse=True)
|
| 492 |
+
top15 = avg_scores[:15]
|
| 493 |
+
|
| 494 |
+
y = np.arange(len(top15))
|
| 495 |
+
width = 0.25
|
| 496 |
+
|
| 497 |
+
names_top = [t[0].replace('SCENARIO ', '').split(':')[1].strip()[:25] for t in top15]
|
| 498 |
+
engage_top = [t[2] for t in top15]
|
| 499 |
+
strategic_top = [t[3] for t in top15]
|
| 500 |
+
competitive_top = [t[4] for t in top15]
|
| 501 |
+
|
| 502 |
+
bars1 = ax.barh(y + width, engage_top, width, label='Engage', color='steelblue')
|
| 503 |
+
bars2 = ax.barh(y, strategic_top, width, label='Strategic', color='seagreen')
|
| 504 |
+
bars3 = ax.barh(y - width, competitive_top, width, label='Competitive', color='coral')
|
| 505 |
+
|
| 506 |
+
ax.set_xlabel('Score')
|
| 507 |
+
ax.set_ylabel('Scenario')
|
| 508 |
+
ax.set_yticks(y)
|
| 509 |
+
ax.set_yticklabels(names_top)
|
| 510 |
+
ax.legend(loc='lower right')
|
| 511 |
+
ax.set_xlim(0, 1.1)
|
| 512 |
+
ax.grid(axis='x', alpha=0.3)
|
| 513 |
+
ax.invert_yaxis()
|
| 514 |
+
|
| 515 |
+
# Add average score labels
|
| 516 |
+
for i, (name, avg, e, s, c) in enumerate(top15):
|
| 517 |
+
ax.text(1.02, i, f'Avg: {avg:.2f}', va='center', fontsize=9, fontweight='bold')
|
| 518 |
+
|
| 519 |
+
plt.tight_layout(rect=[0, 0, 0.95, 0.97])
|
| 520 |
+
plt.savefig('top_scenarios.png', dpi=150, bbox_inches='tight', facecolor='white')
|
| 521 |
+
print("Saved: top_scenarios.png")
|
| 522 |
+
|
| 523 |
+
# Figure 3: Sleep-related scenarios comparison
|
| 524 |
+
fig3, axes = plt.subplots(1, 2, figsize=(14, 6))
|
| 525 |
+
fig3.suptitle('Sleep-Related Scenarios Analysis', fontsize=14, fontweight='bold')
|
| 526 |
+
|
| 527 |
+
sleep_scenarios = [r for r in results if any(kw in r['name'].lower() or kw in r['description'].lower()
|
| 528 |
+
for kw in ['sleep', 'night', 'rest', 'marathon', 'nap'])]
|
| 529 |
+
|
| 530 |
+
# Left: Scores comparison
|
| 531 |
+
ax_left = axes[0]
|
| 532 |
+
sleep_names = [r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:20] for r in sleep_scenarios]
|
| 533 |
+
sleep_engage = [r['scores']['weekly_engage'] for r in sleep_scenarios]
|
| 534 |
+
sleep_strategic = [r['scores']['weekly_strategic'] for r in sleep_scenarios]
|
| 535 |
+
sleep_competitive = [r['scores']['weekly_competitive'] for r in sleep_scenarios]
|
| 536 |
+
|
| 537 |
+
y = np.arange(len(sleep_scenarios))
|
| 538 |
+
width = 0.25
|
| 539 |
+
|
| 540 |
+
ax_left.barh(y + width, sleep_engage, width, label='Engage', color='steelblue')
|
| 541 |
+
ax_left.barh(y, sleep_strategic, width, label='Strategic', color='seagreen')
|
| 542 |
+
ax_left.barh(y - width, sleep_competitive, width, label='Competitive', color='coral')
|
| 543 |
+
|
| 544 |
+
ax_left.set_xlabel('Score')
|
| 545 |
+
ax_left.set_ylabel('Scenario')
|
| 546 |
+
ax_left.set_yticks(y)
|
| 547 |
+
ax_left.set_yticklabels(sleep_names)
|
| 548 |
+
ax_left.legend(loc='lower right')
|
| 549 |
+
ax_left.set_xlim(0, 1.1)
|
| 550 |
+
ax_left.grid(axis='x', alpha=0.3)
|
| 551 |
+
ax_left.set_title('Sleep Scenario Scores')
|
| 552 |
+
ax_left.invert_yaxis()
|
| 553 |
+
|
| 554 |
+
# Right: Sleep debt and energy analysis
|
| 555 |
+
ax_right = axes[1]
|
| 556 |
+
|
| 557 |
+
# Get sleep debt and min energy for strategic task
|
| 558 |
+
sleep_debt_vals = [r['details']['weekly_strategic']['max_sleep_debt'] for r in sleep_scenarios]
|
| 559 |
+
min_energy_vals = [r['details']['weekly_strategic']['min_energy'] for r in sleep_scenarios]
|
| 560 |
+
burned_out = [r['details']['weekly_strategic']['burned_out'] for r in sleep_scenarios]
|
| 561 |
+
|
| 562 |
+
x = np.arange(len(sleep_scenarios))
|
| 563 |
+
width = 0.35
|
| 564 |
+
|
| 565 |
+
bars1 = ax_right.bar(x - width/2, sleep_debt_vals, width, label='Max Sleep Debt', color='purple', alpha=0.7)
|
| 566 |
+
bars2 = ax_right.bar(x + width/2, min_energy_vals, width, label='Min Energy', color='orange', alpha=0.7)
|
| 567 |
+
|
| 568 |
+
# Mark burned out scenarios
|
| 569 |
+
for i, bo in enumerate(burned_out):
|
| 570 |
+
if bo:
|
| 571 |
+
ax_right.annotate('💀', (i, max(sleep_debt_vals[i], min_energy_vals[i]) + 0.05),
|
| 572 |
+
ha='center', fontsize=12)
|
| 573 |
+
|
| 574 |
+
ax_right.set_xlabel('Scenario')
|
| 575 |
+
ax_right.set_ylabel('Value')
|
| 576 |
+
ax_right.set_xticks(x)
|
| 577 |
+
ax_right.set_xticklabels(sleep_names, rotation=45, ha='right', fontsize=8)
|
| 578 |
+
ax_right.legend(loc='upper right')
|
| 579 |
+
ax_right.set_ylim(0, 1.2)
|
| 580 |
+
ax_right.grid(axis='y', alpha=0.3)
|
| 581 |
+
ax_right.set_title('Sleep Debt vs Min Energy (💀 = burned out)')
|
| 582 |
+
|
| 583 |
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
| 584 |
+
plt.savefig('sleep_scenarios.png', dpi=150, bbox_inches='tight', facecolor='white')
|
| 585 |
+
print("Saved: sleep_scenarios.png")
|
| 586 |
+
|
| 587 |
+
# Figure 4: Scenario categories heatmap
|
| 588 |
+
fig4, ax = plt.subplots(figsize=(16, 10))
|
| 589 |
+
fig4.suptitle('All Scenarios - Score Heatmap', fontsize=14, fontweight='bold')
|
| 590 |
+
|
| 591 |
+
# Create matrix for heatmap
|
| 592 |
+
all_scores = np.array([[r['scores']['weekly_engage'],
|
| 593 |
+
r['scores']['weekly_strategic'],
|
| 594 |
+
r['scores']['weekly_competitive']] for r in results])
|
| 595 |
+
|
| 596 |
+
im = ax.imshow(all_scores, aspect='auto', cmap='RdYlGn', vmin=0, vmax=1)
|
| 597 |
+
|
| 598 |
+
ax.set_yticks(range(len(results)))
|
| 599 |
+
ax.set_yticklabels([r['name'].replace('SCENARIO ', '') for r in results], fontsize=7)
|
| 600 |
+
ax.set_xticks([0, 1, 2])
|
| 601 |
+
ax.set_xticklabels(['Engage', 'Strategic', 'Competitive'])
|
| 602 |
+
|
| 603 |
+
# Add score text
|
| 604 |
+
for i in range(len(results)):
|
| 605 |
+
for j in range(3):
|
| 606 |
+
score = all_scores[i, j]
|
| 607 |
+
color = 'white' if score < 0.5 else 'black'
|
| 608 |
+
ax.text(j, i, f'{score:.2f}', ha='center', va='center', fontsize=6, color=color)
|
| 609 |
+
|
| 610 |
+
cbar = plt.colorbar(im, ax=ax, shrink=0.8)
|
| 611 |
+
cbar.set_label('Score')
|
| 612 |
+
|
| 613 |
+
plt.tight_layout(rect=[0, 0, 1, 0.97])
|
| 614 |
+
plt.savefig('scenario_heatmap.png', dpi=150, bbox_inches='tight', facecolor='white')
|
| 615 |
+
print("Saved: scenario_heatmap.png")
|
| 616 |
+
|
| 617 |
+
# Figure 5: Action distribution for top performers
|
| 618 |
+
fig5, axes = plt.subplots(2, 3, figsize=(15, 10))
|
| 619 |
+
fig5.suptitle('Action Distribution - Top 6 Strategies', fontsize=14, fontweight='bold')
|
| 620 |
+
|
| 621 |
+
top6 = avg_scores[:6]
|
| 622 |
+
top6_results = [r for r in results if r['name'] in [t[0] for t in top6]]
|
| 623 |
+
top6_results.sort(key=lambda r: next(t[1] for t in top6 if t[0] == r['name']), reverse=True)
|
| 624 |
+
|
| 625 |
+
for idx, r in enumerate(top6_results):
|
| 626 |
+
ax = axes[idx // 3, idx % 3]
|
| 627 |
+
details = r['details']['weekly_strategic']
|
| 628 |
+
|
| 629 |
+
actions = ['Posts', 'Rests', 'Creates']
|
| 630 |
+
counts = [details['posts'], details['rests'], details['creates']]
|
| 631 |
+
colors = ['steelblue', 'seagreen', 'coral']
|
| 632 |
+
|
| 633 |
+
wedges, texts, autotexts = ax.pie(counts, labels=actions, autopct='%1.0f%%',
|
| 634 |
+
colors=colors, startangle=90)
|
| 635 |
+
ax.set_title(r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:25], fontsize=10)
|
| 636 |
+
|
| 637 |
+
# Add stats
|
| 638 |
+
avg = (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3
|
| 639 |
+
ax.text(0, -1.3, f"Avg Score: {avg:.2f} | Energy: {details['final_energy']:.2f}",
|
| 640 |
+
ha='center', fontsize=8)
|
| 641 |
+
|
| 642 |
+
plt.tight_layout(rect=[0, 0.02, 1, 0.95])
|
| 643 |
+
plt.savefig('top_actions.png', dpi=150, bbox_inches='tight', facecolor='white')
|
| 644 |
+
print("Saved: top_actions.png")
|
| 645 |
+
|
| 646 |
+
return results
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def print_scenario_summary(results: List[Dict[str, Any]]):
|
| 650 |
+
"""Print summary table of all scenarios."""
|
| 651 |
+
print("\n" + "="*100)
|
| 652 |
+
print("ALL 61 SCENARIOS - SIMULATION RESULTS")
|
| 653 |
+
print("="*100)
|
| 654 |
+
|
| 655 |
+
# Calculate averages and sort
|
| 656 |
+
scored_results = []
|
| 657 |
+
for r in results:
|
| 658 |
+
avg = (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3
|
| 659 |
+
scored_results.append((r, avg))
|
| 660 |
+
scored_results.sort(key=lambda x: x[1], reverse=True)
|
| 661 |
+
|
| 662 |
+
print(f"\n{'Rank':<5} {'Scenario':<45} {'Engage':>8} {'Strategic':>10} {'Competitive':>12} {'Avg':>8}")
|
| 663 |
+
print("-" * 100)
|
| 664 |
+
|
| 665 |
+
for rank, (r, avg) in enumerate(scored_results, 1):
|
| 666 |
+
name = r['name'].replace('SCENARIO ', '')[:43]
|
| 667 |
+
e = r['scores']['weekly_engage']
|
| 668 |
+
s = r['scores']['weekly_strategic']
|
| 669 |
+
c = r['scores']['weekly_competitive']
|
| 670 |
+
|
| 671 |
+
# Add indicator for top performers
|
| 672 |
+
indicator = "🏆" if rank <= 3 else "⭐" if rank <= 10 else " "
|
| 673 |
+
print(f"{indicator}{rank:<3} {name:<45} {e:>8.4f} {s:>10.4f} {c:>12.4f} {avg:>8.4f}")
|
| 674 |
+
|
| 675 |
+
print("\n" + "="*100)
|
| 676 |
+
print("TOP 10 DETAILED ANALYSIS")
|
| 677 |
+
print("="*100)
|
| 678 |
+
|
| 679 |
+
for rank, (r, avg) in enumerate(scored_results[:10], 1):
|
| 680 |
+
print(f"\n#{rank} {r['name']}")
|
| 681 |
+
print(f" Description: {r['description']}")
|
| 682 |
+
|
| 683 |
+
for task in ['weekly_engage', 'weekly_strategic', 'weekly_competitive']:
|
| 684 |
+
d = r['details'][task]
|
| 685 |
+
print(f" {task}: Score={r['scores'][task]:.4f} | "
|
| 686 |
+
f"Posts={d['posts']} Rests={d['rests']} Creates={d['creates']} | "
|
| 687 |
+
f"Energy={d['final_energy']:.2f} | Followers={d['follower_delta']:+d}")
|
| 688 |
+
|
| 689 |
+
# Sleep scenario analysis
|
| 690 |
+
print("\n" + "="*100)
|
| 691 |
+
print("SLEEP MECHANICS ANALYSIS")
|
| 692 |
+
print("="*100)
|
| 693 |
+
|
| 694 |
+
sleep_keywords = ['sleep', 'night', 'rest', 'marathon', 'nap', 'awake']
|
| 695 |
+
sleep_results = [(r, (r['scores']['weekly_engage'] + r['scores']['weekly_strategic'] + r['scores']['weekly_competitive']) / 3)
|
| 696 |
+
for r in results
|
| 697 |
+
if any(kw in r['name'].lower() or kw in r['description'].lower() for kw in sleep_keywords)]
|
| 698 |
+
sleep_results.sort(key=lambda x: x[1], reverse=True)
|
| 699 |
+
|
| 700 |
+
print(f"\n{'Scenario':<40} {'Avg Score':>10} {'Max Sleep Debt':>15} {'Burned Out':>12}")
|
| 701 |
+
print("-" * 80)
|
| 702 |
+
|
| 703 |
+
for r, avg in sleep_results:
|
| 704 |
+
name = r['name'].replace('SCENARIO ', '').split(':')[1].strip()[:38]
|
| 705 |
+
debt = r['details']['weekly_strategic']['max_sleep_debt']
|
| 706 |
+
bo = "YES 💀" if r['details']['weekly_strategic']['burned_out'] else "No"
|
| 707 |
+
print(f"{name:<40} {avg:>10.4f} {debt:>15.3f} {bo:>12}")
|
| 708 |
+
|
| 709 |
+
print("\n" + "="*100)
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
if __name__ == "__main__":
|
| 713 |
+
print("Generating optimal posting visualizations...")
|
| 714 |
+
print_summary()
|
| 715 |
+
create_visualizations()
|
| 716 |
+
|
| 717 |
+
print("\n" + "="*70)
|
| 718 |
+
print("Running all 61 scenarios...")
|
| 719 |
+
print("="*70)
|
| 720 |
+
|
| 721 |
+
results = run_all_scenarios()
|
| 722 |
+
print_scenario_summary(results)
|
| 723 |
+
create_scenario_visualizations(results)
|
| 724 |
+
|
| 725 |
+
print("\n✅ All visualizations generated!")
|
| 726 |
+
print(" - optimal_posting_guide.png")
|
| 727 |
+
print(" - strategy_comparison.png")
|
| 728 |
+
print(" - scenario_scores.png")
|
| 729 |
+
print(" - top_scenarios.png")
|
| 730 |
+
print(" - sleep_scenarios.png")
|
| 731 |
+
print(" - scenario_heatmap.png")
|
| 732 |
+
print(" - top_actions.png")
|