anuragredbus commited on
Commit
7a5c462
·
1 Parent(s): 1a2a407
server/simulation_history.json CHANGED
@@ -1 +1,344 @@
1
- []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "2026-04-26T07:28:15.437942+00:00",
4
+ "scenario": "Always Rest",
5
+ "scenario_id": "always_rest",
6
+ "description": "Never posts. Tests follower decay.",
7
+ "task": "monthly_competitive",
8
+ "score": 0.035,
9
+ "total_steps": 15,
10
+ "total_posts": 0,
11
+ "avg_reward": 0.15,
12
+ "final": {
13
+ "energy": 1.0,
14
+ "hours_since_sleep": 1,
15
+ "sleep_debt": 0.0,
16
+ "followers": 2424,
17
+ "engagement_rate": 0.0,
18
+ "burned_out": false
19
+ }
20
+ },
21
+ {
22
+ "id": "2026-04-26T07:28:15.483124+00:00",
23
+ "scenario": "Always Rest",
24
+ "scenario_id": "always_rest",
25
+ "description": "Never posts. Tests follower decay.",
26
+ "task": "monthly_strategic",
27
+ "score": 0.175,
28
+ "total_steps": 15,
29
+ "total_posts": 0,
30
+ "avg_reward": 0.15,
31
+ "final": {
32
+ "energy": 1.0,
33
+ "hours_since_sleep": 1,
34
+ "sleep_debt": 0.0,
35
+ "followers": 2424,
36
+ "engagement_rate": 0.0,
37
+ "burned_out": false
38
+ }
39
+ },
40
+ {
41
+ "id": "2026-04-26T07:28:15.524180+00:00",
42
+ "scenario": "Minimal Poster",
43
+ "scenario_id": "minimal",
44
+ "description": "1 carousel per day at noon.",
45
+ "task": "monthly_competitive",
46
+ "score": 0.216,
47
+ "total_steps": 15,
48
+ "total_posts": 0,
49
+ "avg_reward": 0.1995,
50
+ "final": {
51
+ "energy": 1.0,
52
+ "hours_since_sleep": 1,
53
+ "sleep_debt": 0.0,
54
+ "followers": 11034,
55
+ "engagement_rate": 0.6671,
56
+ "burned_out": false
57
+ }
58
+ },
59
+ {
60
+ "id": "2026-04-26T07:28:15.561308+00:00",
61
+ "scenario": "Minimal Poster",
62
+ "scenario_id": "minimal",
63
+ "description": "1 carousel per day at noon.",
64
+ "task": "monthly_strategic",
65
+ "score": 0.369,
66
+ "total_steps": 15,
67
+ "total_posts": 0,
68
+ "avg_reward": 0.1995,
69
+ "final": {
70
+ "energy": 1.0,
71
+ "hours_since_sleep": 1,
72
+ "sleep_debt": 0.0,
73
+ "followers": 11034,
74
+ "engagement_rate": 0.6671,
75
+ "burned_out": false
76
+ }
77
+ },
78
+ {
79
+ "id": "2026-04-26T07:28:15.599781+00:00",
80
+ "scenario": "Smart Agent",
81
+ "scenario_id": "smart",
82
+ "description": "Optimal: peak hours, trending, varied types+intents.",
83
+ "task": "monthly_competitive",
84
+ "score": 0.8288,
85
+ "total_steps": 15,
86
+ "total_posts": 0,
87
+ "avg_reward": 0.2158,
88
+ "final": {
89
+ "energy": 1.0,
90
+ "hours_since_sleep": 1,
91
+ "sleep_debt": 0.0,
92
+ "followers": 11705,
93
+ "engagement_rate": 0.3706,
94
+ "burned_out": false
95
+ }
96
+ },
97
+ {
98
+ "id": "2026-04-26T07:28:15.635465+00:00",
99
+ "scenario": "Smart Agent",
100
+ "scenario_id": "smart",
101
+ "description": "Optimal: peak hours, trending, varied types+intents.",
102
+ "task": "monthly_strategic",
103
+ "score": 0.8314,
104
+ "total_steps": 15,
105
+ "total_posts": 0,
106
+ "avg_reward": 0.2158,
107
+ "final": {
108
+ "energy": 1.0,
109
+ "hours_since_sleep": 1,
110
+ "sleep_debt": 0.0,
111
+ "followers": 11705,
112
+ "engagement_rate": 0.3706,
113
+ "burned_out": false
114
+ }
115
+ },
116
+ {
117
+ "id": "2026-04-26T07:28:15.670869+00:00",
118
+ "scenario": "Spam Post",
119
+ "scenario_id": "spam",
120
+ "description": "Same reel every hour. Burns out fast.",
121
+ "task": "monthly_competitive",
122
+ "score": 0.0,
123
+ "total_steps": 1,
124
+ "total_posts": 0,
125
+ "avg_reward": 0.0452,
126
+ "final": {
127
+ "energy": 0.0,
128
+ "hours_since_sleep": 6,
129
+ "sleep_debt": 0.0,
130
+ "followers": 10074,
131
+ "engagement_rate": 0.1872,
132
+ "burned_out": true
133
+ }
134
+ },
135
+ {
136
+ "id": "2026-04-26T07:28:15.705343+00:00",
137
+ "scenario": "Spam Post",
138
+ "scenario_id": "spam",
139
+ "description": "Same reel every hour. Burns out fast.",
140
+ "task": "monthly_strategic",
141
+ "score": 0.0075,
142
+ "total_steps": 1,
143
+ "total_posts": 0,
144
+ "avg_reward": 0.0452,
145
+ "final": {
146
+ "energy": 0.0,
147
+ "hours_since_sleep": 6,
148
+ "sleep_debt": 0.0,
149
+ "followers": 10074,
150
+ "engagement_rate": 0.1872,
151
+ "burned_out": true
152
+ }
153
+ },
154
+ {
155
+ "id": "2026-04-26T07:28:15.740315+00:00",
156
+ "scenario": "Random Actor",
157
+ "scenario_id": "random",
158
+ "description": "Random actions. Baseline test.",
159
+ "task": "monthly_competitive",
160
+ "score": 0.7174,
161
+ "total_steps": 15,
162
+ "total_posts": 0,
163
+ "avg_reward": 0.2102,
164
+ "final": {
165
+ "energy": 1.0,
166
+ "hours_since_sleep": 1,
167
+ "sleep_debt": 0.0,
168
+ "followers": 11789,
169
+ "engagement_rate": 0.4013,
170
+ "burned_out": false
171
+ }
172
+ },
173
+ {
174
+ "id": "2026-04-26T07:28:15.774122+00:00",
175
+ "scenario": "Random Actor",
176
+ "scenario_id": "random",
177
+ "description": "Random actions. Baseline test.",
178
+ "task": "monthly_strategic",
179
+ "score": 0.6533,
180
+ "total_steps": 15,
181
+ "total_posts": 0,
182
+ "avg_reward": 0.2102,
183
+ "final": {
184
+ "energy": 1.0,
185
+ "hours_since_sleep": 1,
186
+ "sleep_debt": 0.0,
187
+ "followers": 11789,
188
+ "engagement_rate": 0.4013,
189
+ "burned_out": false
190
+ }
191
+ },
192
+ {
193
+ "id": "2026-04-26T07:28:15.810794+00:00",
194
+ "scenario": "Collab Same-Niche Low Overlap",
195
+ "scenario_id": "collab_same_low",
196
+ "description": "Same-niche partner with <20% overlap. Best-case collab reward path.",
197
+ "task": "monthly_competitive",
198
+ "score": 0.2777,
199
+ "total_steps": 15,
200
+ "total_posts": 0,
201
+ "avg_reward": 0.2077,
202
+ "final": {
203
+ "energy": 1.0,
204
+ "hours_since_sleep": 1,
205
+ "sleep_debt": 0.0,
206
+ "followers": 13855,
207
+ "engagement_rate": 1.3307,
208
+ "burned_out": false
209
+ }
210
+ },
211
+ {
212
+ "id": "2026-04-26T07:28:15.849698+00:00",
213
+ "scenario": "Collab Same-Niche Low Overlap",
214
+ "scenario_id": "collab_same_low",
215
+ "description": "Same-niche partner with <20% overlap. Best-case collab reward path.",
216
+ "task": "monthly_strategic",
217
+ "score": 0.5363,
218
+ "total_steps": 15,
219
+ "total_posts": 0,
220
+ "avg_reward": 0.2077,
221
+ "final": {
222
+ "energy": 1.0,
223
+ "hours_since_sleep": 1,
224
+ "sleep_debt": 0.0,
225
+ "followers": 13855,
226
+ "engagement_rate": 1.3307,
227
+ "burned_out": false
228
+ }
229
+ },
230
+ {
231
+ "id": "2026-04-26T07:28:15.883259+00:00",
232
+ "scenario": "Collab Diff-Niche High Overlap",
233
+ "scenario_id": "collab_diff_high",
234
+ "description": "Diff-niche partner with >40% overlap. Penalty path (audience mismatch).",
235
+ "task": "monthly_competitive",
236
+ "score": 0.2688,
237
+ "total_steps": 15,
238
+ "total_posts": 0,
239
+ "avg_reward": 0.2069,
240
+ "final": {
241
+ "energy": 1.0,
242
+ "hours_since_sleep": 1,
243
+ "sleep_debt": 0.0,
244
+ "followers": 12371,
245
+ "engagement_rate": 1.2522,
246
+ "burned_out": false
247
+ }
248
+ },
249
+ {
250
+ "id": "2026-04-26T07:28:15.917593+00:00",
251
+ "scenario": "Collab Diff-Niche High Overlap",
252
+ "scenario_id": "collab_diff_high",
253
+ "description": "Diff-niche partner with >40% overlap. Penalty path (audience mismatch).",
254
+ "task": "monthly_strategic",
255
+ "score": 0.5123,
256
+ "total_steps": 15,
257
+ "total_posts": 0,
258
+ "avg_reward": 0.2069,
259
+ "final": {
260
+ "energy": 1.0,
261
+ "hours_since_sleep": 1,
262
+ "sleep_debt": 0.0,
263
+ "followers": 12371,
264
+ "engagement_rate": 1.2522,
265
+ "burned_out": false
266
+ }
267
+ },
268
+ {
269
+ "id": "2026-04-26T07:28:15.955439+00:00",
270
+ "scenario": "Interact Balanced",
271
+ "scenario_id": "interact_balanced",
272
+ "description": "Healthy on-niche likes/comments and audience replies.",
273
+ "task": "monthly_competitive",
274
+ "score": 0.2816,
275
+ "total_steps": 15,
276
+ "total_posts": 0,
277
+ "avg_reward": 0.2199,
278
+ "final": {
279
+ "energy": 1.0,
280
+ "hours_since_sleep": 1,
281
+ "sleep_debt": 0.0,
282
+ "followers": 12252,
283
+ "engagement_rate": 1.4081,
284
+ "burned_out": false
285
+ }
286
+ },
287
+ {
288
+ "id": "2026-04-26T07:28:15.995659+00:00",
289
+ "scenario": "Interact Balanced",
290
+ "scenario_id": "interact_balanced",
291
+ "description": "Healthy on-niche likes/comments and audience replies.",
292
+ "task": "monthly_strategic",
293
+ "score": 0.5463,
294
+ "total_steps": 15,
295
+ "total_posts": 0,
296
+ "avg_reward": 0.2199,
297
+ "final": {
298
+ "energy": 1.0,
299
+ "hours_since_sleep": 1,
300
+ "sleep_debt": 0.0,
301
+ "followers": 12252,
302
+ "engagement_rate": 1.4081,
303
+ "burned_out": false
304
+ }
305
+ },
306
+ {
307
+ "id": "2026-04-26T07:28:16.104107+00:00",
308
+ "scenario": "Interact Spam",
309
+ "scenario_id": "interact_spam",
310
+ "description": "80 likes + 40 comments \u2014 spam path triggers shadowban_risk.",
311
+ "task": "monthly_competitive",
312
+ "score": 0.2448,
313
+ "total_steps": 15,
314
+ "total_posts": 0,
315
+ "avg_reward": 0.2142,
316
+ "final": {
317
+ "energy": 1.0,
318
+ "hours_since_sleep": 1,
319
+ "sleep_debt": 0.0,
320
+ "followers": 11781,
321
+ "engagement_rate": 0.7259,
322
+ "burned_out": false
323
+ }
324
+ },
325
+ {
326
+ "id": "2026-04-26T07:28:16.156456+00:00",
327
+ "scenario": "Interact Spam",
328
+ "scenario_id": "interact_spam",
329
+ "description": "80 likes + 40 comments \u2014 spam path triggers shadowban_risk.",
330
+ "task": "monthly_strategic",
331
+ "score": 0.315,
332
+ "total_steps": 15,
333
+ "total_posts": 0,
334
+ "avg_reward": 0.2142,
335
+ "final": {
336
+ "energy": 1.0,
337
+ "hours_since_sleep": 1,
338
+ "sleep_debt": 0.0,
339
+ "followers": 11781,
340
+ "engagement_rate": 0.7259,
341
+ "burned_out": false
342
+ }
343
+ }
344
+ ]
server/viraltest_environment.py CHANGED
@@ -175,7 +175,11 @@ TREND_DEFAULT_HALFLIFE_HOURS = 60
175
  TREND_MATCH_STOPWORDS = {"tips", "guide", "review", "routine", "ideas", "hacks", "tutorial", "the", "a", "an", "and", "of", "for", "to"}
176
  # Collab reward shaping (Later 2023 reach study, HypeAuditor 2024 niche affinity, Rival IQ 2025 overlap patterns,
177
  # Cen et al. 2024 disengagement model for diminishing returns instead of a hard cap).
178
- COLLAB_PARTNER_REPEAT_PENALTY = 0.7 # discount on multipliers when partner reused this brand
 
 
 
 
179
  COLLAB_FATIGUE_K = 0.3 # per-collab diminishing-returns factor: 1/(1+K*prior_collabs_this_episode)
180
 
181
  # Niche-aware tiered shaping (overlap = Jaccard intersection fraction).
@@ -201,17 +205,28 @@ COLLAB_DIFF_LOW_GROWTH = (1.30, 1.55)
201
  COLLAB_DIFF_HIGH_ENG = 0.75
202
  COLLAB_DIFF_HIGH_GROWTH = 0.80
203
 
 
 
 
 
 
 
 
 
 
 
 
204
  # Interaction (likes/comments/replies) tunables
205
  INTERACT_ENERGY_LIKE = 0.005
206
  INTERACT_ENERGY_COMMENT = 0.012
207
  INTERACT_ENERGY_REPLY = 0.018
208
  INTERACT_HEALTHY_LIKES = (5, 20)
209
  INTERACT_HEALTHY_COMMENTS = (3, 10)
210
- INTERACT_LIKE_REACH_BUFF = 0.04
211
- INTERACT_COMMENT_REACH_BUFF = 0.08
212
- INTERACT_REPLY_REWARD_PER = 0.01
213
- INTERACT_REPLY_REWARD_CAP = 0.15
214
- INTERACT_DAILY_REWARD_CAP = 0.15
215
  INTERACT_SPAM_LIKES = 30
216
  INTERACT_SPAM_COMMENTS = 20
217
  INTERACT_SPAM_REACH_PENALTY = 0.85
@@ -308,7 +323,7 @@ TOOL_CATALOG = {
308
  "parameters": {},
309
  },
310
  "propose_collab": {
311
- "description": "Propose a collab post with a competitor at a specific hour. The post you schedule at that hour will be co-authored. Reward shaping: same-niche + low overlap = HIGH; same-niche + high overlap = LOW; diff-niche always capped below same-niche-low. Guardrail violations apply a 0.7x engagement / 0.6x growth penalty AND surface in the JudgeReport.",
312
  "parameters": {
313
  "partner_id": {"type": "string"},
314
  "content_type": {"type": "string", "enum": ["reel", "story", "carousel", "text_post"]},
@@ -363,6 +378,8 @@ class ViraltestEnvironment(Environment):
363
  self._collab_history: List[str] = []
364
  self._active_collab: Optional[CollabProposal] = None
365
  self._collab_violations: List[str] = [] # collab guardrail breaches this step
 
 
366
  self._user_niche: str = _NICHE_BY_ARCHETYPE.get("user_creator", "generic")
367
 
368
  # Interaction state
@@ -568,6 +585,40 @@ class ViraltestEnvironment(Environment):
568
  def _partner_followers(self, partner_id: str) -> int:
569
  return _FOLLOWERS_BY_ARCHETYPE.get(partner_id, 0)
570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  @staticmethod
572
  def _interp(span: Tuple[float, float], t: float) -> float:
573
  """Linear interp from span[0] (t=0) to span[1] (t=1)."""
@@ -659,12 +710,13 @@ class ViraltestEnvironment(Environment):
659
  eng_mult = tier_eng
660
  growth_mult = tier_growth
661
 
662
- # Repeat-partner discount (existing behavior preserved).
663
- if partner_id in self._collab_history[:-1]:
664
- eng_mult *= COLLAB_PARTNER_REPEAT_PENALTY
665
- growth_mult *= COLLAB_PARTNER_REPEAT_PENALTY
 
666
 
667
- # Diminishing returns across the episode (Cen 2024).
668
  prior = max(0, self._collabs_this_month - 1)
669
  fatigue = 1.0 / (1.0 + COLLAB_FATIGUE_K * prior)
670
  eng_mult *= fatigue
@@ -686,6 +738,8 @@ class ViraltestEnvironment(Environment):
686
  "tier_growth_mult": round(tier_growth, 3),
687
  "eng_mult": round(eng_mult, 3),
688
  "growth_mult": round(growth_mult, 3),
 
 
689
  }
690
 
691
  def _collab_multipliers(self, partner_id: str) -> Tuple[float, float]:
@@ -953,6 +1007,8 @@ class ViraltestEnvironment(Environment):
953
  "reason": ev.get("reason"),
954
  "expected_eng_mult": ev.get("eng_mult"),
955
  "expected_growth_mult": ev.get("growth_mult"),
 
 
956
  })
957
  return ToolResult(
958
  name=tool.name,
@@ -981,6 +1037,8 @@ class ViraltestEnvironment(Environment):
981
  "intersection_size": ev["intersection_size"],
982
  "expected_eng_mult": ev["eng_mult"],
983
  "expected_growth_mult": ev["growth_mult"],
 
 
984
  },
985
  budget_remaining=self._api_budget,
986
  )
@@ -1194,6 +1252,22 @@ class ViraltestEnvironment(Environment):
1194
  continue
1195
  schedule[sa.hour] = sa
1196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1197
  daily_engagement = 0.0
1198
  daily_reward = 0.0
1199
  daily_posts = 0
@@ -1237,6 +1311,10 @@ class ViraltestEnvironment(Environment):
1237
  if self._day % 7 == 0 and self._day > 0:
1238
  self._total_posts_this_week = 0
1239
 
 
 
 
 
1240
  # Burnout risk tracking
1241
  if energy_min < 0.2:
1242
  self._low_energy_days += 1
@@ -1297,6 +1375,7 @@ class ViraltestEnvironment(Environment):
1297
  signals = None
1298
 
1299
  collab_growth_mult = 1.0
 
1300
 
1301
  if sa.action_type == "post":
1302
  cost = CONTENT_ENERGY_COST.get(sa.content_type, 0.1)
@@ -1339,10 +1418,29 @@ class ViraltestEnvironment(Environment):
1339
  # Multiplicative on engagement; capped at 0.5 floor inside _process_interactions.
1340
  engagement *= getattr(self, "_pending_reach_mult", 1.0)
1341
 
 
 
 
 
 
1342
  if self._active_collab is not None and self._active_collab.hour == sa.hour:
 
1343
  eng_m, growth_m = self._collab_multipliers(self._active_collab.partner_id)
1344
  engagement *= eng_m
1345
  collab_growth_mult = growth_m
 
 
 
 
 
 
 
 
 
 
 
 
 
1346
 
1347
  engagement = min(engagement, 5.0)
1348
 
@@ -1374,6 +1472,8 @@ class ViraltestEnvironment(Environment):
1374
 
1375
  if engagement > 0:
1376
  self._followers += int(engagement * 100 * collab_growth_mult)
 
 
1377
 
1378
  elif sa.action_type == "create_content":
1379
  self._energy = max(0.0, self._energy - CREATE_CONTENT_COST)
 
175
  TREND_MATCH_STOPWORDS = {"tips", "guide", "review", "routine", "ideas", "hacks", "tutorial", "the", "a", "an", "and", "of", "for", "to"}
176
  # Collab reward shaping (Later 2023 reach study, HypeAuditor 2024 niche affinity, Rival IQ 2025 overlap patterns,
177
  # Cen et al. 2024 disengagement model for diminishing returns instead of a hard cap).
178
+ # Per-partner exhaustion: each collab with the SAME partner re-exposes the user to the same set of followers,
179
+ # so spillover and reward multipliers should decay sharply. First 1-2 collabs deliver most of the gain.
180
+ # Floor at 0.05 keeps a tiny residual signal so the curve is monotonic but effectively saturates.
181
+ COLLAB_PARTNER_REPEAT_DECAY = {0: 1.0, 1: 0.70, 2: 0.35, 3: 0.15}
182
+ COLLAB_PARTNER_REPEAT_FLOOR = 0.05
183
  COLLAB_FATIGUE_K = 0.3 # per-collab diminishing-returns factor: 1/(1+K*prior_collabs_this_episode)
184
 
185
  # Niche-aware tiered shaping (overlap = Jaccard intersection fraction).
 
205
  COLLAB_DIFF_HIGH_ENG = 0.75
206
  COLLAB_DIFF_HIGH_GROWTH = 0.80
207
 
208
+ # Collab is the canonical "wider reach in one shot" lever. On top of the engagement
209
+ # multiplier (which only affects the collab-day post), apply two extra mechanisms:
210
+ # 1) One-shot follower spillover: partner_followers x (1 - overlap) x growth_mult x K_SPILLOVER.
211
+ # Models the partner's audience getting exposed to the user — net new followers, not just engagement.
212
+ # 2) Sustained reach buff: 2-3 days post-collab, all posts get a small algorithm boost
213
+ # because the collab signal lifts the user's overall recommendability.
214
+ COLLAB_SPILLOVER_K = 0.05 # follower spike fraction (0.05 = 5% of partner audience reach if overlap=0)
215
+ COLLAB_REACH_CARRYOVER_DAYS = 2 # days the post-collab reach buff persists
216
+ COLLAB_REACH_CARRYOVER_MULT = 1.20 # +20% engagement on each post during carryover window
217
+ COLLAB_BLOCKED_SPILLOVER_K = 0.01 # forced/guardrail-blocked collabs get only ~20% of normal spillover
218
+
219
  # Interaction (likes/comments/replies) tunables
220
  INTERACT_ENERGY_LIKE = 0.005
221
  INTERACT_ENERGY_COMMENT = 0.012
222
  INTERACT_ENERGY_REPLY = 0.018
223
  INTERACT_HEALTHY_LIKES = (5, 20)
224
  INTERACT_HEALTHY_COMMENTS = (3, 10)
225
+ INTERACT_LIKE_REACH_BUFF = 0.02 # was 0.04 — interactions are a sustaining lever, not a growth lever
226
+ INTERACT_COMMENT_REACH_BUFF = 0.04 # was 0.08
227
+ INTERACT_REPLY_REWARD_PER = 0.005 # was 0.01
228
+ INTERACT_REPLY_REWARD_CAP = 0.08
229
+ INTERACT_DAILY_REWARD_CAP = 0.10
230
  INTERACT_SPAM_LIKES = 30
231
  INTERACT_SPAM_COMMENTS = 20
232
  INTERACT_SPAM_REACH_PENALTY = 0.85
 
323
  "parameters": {},
324
  },
325
  "propose_collab": {
326
+ "description": "Propose a collab post with a competitor at a specific hour. The post you schedule at that hour will be co-authored (or auto-injected if absent — collab always pays a post's energy + counts as a post). Reward shaping: same-niche + low overlap = HIGH; same-niche + high overlap = LOW; diff-niche always capped below same-niche-low. Per-partner exhaustion: 1st collab full reward, 2nd 0.70x, 3rd 0.35x, 4th 0.15x, 5th+ 0.05x — partner audiences are tapped once. Guardrail violations apply a 0.7x engagement / 0.6x growth penalty AND surface in the JudgeReport.",
327
  "parameters": {
328
  "partner_id": {"type": "string"},
329
  "content_type": {"type": "string", "enum": ["reel", "story", "carousel", "text_post"]},
 
378
  self._collab_history: List[str] = []
379
  self._active_collab: Optional[CollabProposal] = None
380
  self._collab_violations: List[str] = [] # collab guardrail breaches this step
381
+ self._collab_carryover_days_remaining: int = 0
382
+ self._last_collab_spillover: int = 0 # diagnostic: followers gained from the most recent collab
383
  self._user_niche: str = _NICHE_BY_ARCHETYPE.get("user_creator", "generic")
384
 
385
  # Interaction state
 
585
  def _partner_followers(self, partner_id: str) -> int:
586
  return _FOLLOWERS_BY_ARCHETYPE.get(partner_id, 0)
587
 
588
+ def _partner_prior_count(self, partner_id: str) -> int:
589
+ """How many times we've already collabed with this partner THIS episode (excludes current entry)."""
590
+ if not self._collab_history:
591
+ return 0
592
+ return self._collab_history[:-1].count(partner_id)
593
+
594
+ def _partner_repeat_decay(self, partner_id: str) -> float:
595
+ """Multiplier in [floor, 1.0] that scales spillover + tier multipliers based on prior count.
596
+
597
+ First collab = 1.0x (no decay), 2nd = 0.70, 3rd = 0.35, 4th = 0.15, 5th+ = 0.05.
598
+ """
599
+ n = self._partner_prior_count(partner_id)
600
+ return COLLAB_PARTNER_REPEAT_DECAY.get(n, COLLAB_PARTNER_REPEAT_FLOOR)
601
+
602
+ def _collab_default_topic(self, partner_niche: str) -> str:
603
+ """Pick a topic for an auto-injected collab post.
604
+
605
+ Prefer the user's niche (so the post lands in user-niche distribution); fall back to
606
+ the partner's niche; final fallback is the first known topic.
607
+ """
608
+ for niche in (self._user_niche, partner_niche):
609
+ topics = TOPIC_CATEGORIES.get(niche)
610
+ if topics:
611
+ return topics[0]
612
+ # Fallback: first topic of any known niche.
613
+ for topics in TOPIC_CATEGORIES.values():
614
+ if topics:
615
+ return topics[0]
616
+ return "collaboration"
617
+
618
+ def _collab_default_tags(self, partner_niche: str) -> List[str]:
619
+ # Pull a couple of trending tags so the auto-post benefits from the existing tag boost.
620
+ return list(self._trending_tags[:2]) if self._trending_tags else []
621
+
622
  @staticmethod
623
  def _interp(span: Tuple[float, float], t: float) -> float:
624
  """Linear interp from span[0] (t=0) to span[1] (t=1)."""
 
710
  eng_mult = tier_eng
711
  growth_mult = tier_growth
712
 
713
+ # Per-partner exhaustion: spillover + tier mults degrade per repeat with this same partner.
714
+ # 1st = 1.0, 2nd = 0.70, 3rd = 0.35, 4th = 0.15, 5th+ = 0.05.
715
+ repeat_decay = self._partner_repeat_decay(partner_id)
716
+ eng_mult *= repeat_decay
717
+ growth_mult *= repeat_decay
718
 
719
+ # Diminishing returns across the episode (Cen 2024) — applies regardless of partner.
720
  prior = max(0, self._collabs_this_month - 1)
721
  fatigue = 1.0 / (1.0 + COLLAB_FATIGUE_K * prior)
722
  eng_mult *= fatigue
 
738
  "tier_growth_mult": round(tier_growth, 3),
739
  "eng_mult": round(eng_mult, 3),
740
  "growth_mult": round(growth_mult, 3),
741
+ "prior_count_with_partner": self._partner_prior_count(partner_id),
742
+ "repeat_decay": round(repeat_decay, 3),
743
  }
744
 
745
  def _collab_multipliers(self, partner_id: str) -> Tuple[float, float]:
 
1007
  "reason": ev.get("reason"),
1008
  "expected_eng_mult": ev.get("eng_mult"),
1009
  "expected_growth_mult": ev.get("growth_mult"),
1010
+ "prior_collabs_with_partner": ev.get("prior_count_with_partner"),
1011
+ "repeat_decay": ev.get("repeat_decay"),
1012
  })
1013
  return ToolResult(
1014
  name=tool.name,
 
1037
  "intersection_size": ev["intersection_size"],
1038
  "expected_eng_mult": ev["eng_mult"],
1039
  "expected_growth_mult": ev["growth_mult"],
1040
+ "prior_collabs_with_partner": ev["prior_count_with_partner"],
1041
+ "repeat_decay": ev["repeat_decay"],
1042
  },
1043
  budget_remaining=self._api_budget,
1044
  )
 
1252
  continue
1253
  schedule[sa.hour] = sa
1254
 
1255
+ # Collab requires a post at collab.hour (energy + post-count come from posting itself).
1256
+ # If the agent didn't schedule one, auto-inject a co-authored post using the collab's
1257
+ # content_type. This guarantees collab pays energy and counts as a post — no free multiplier.
1258
+ if self._active_collab is not None:
1259
+ chour = self._active_collab.hour
1260
+ existing = schedule.get(chour)
1261
+ if existing is None or existing.action_type != "post":
1262
+ ct = self._active_collab.content_type or "reel"
1263
+ partner_niche = self._partner_niche(self._active_collab.partner_id)
1264
+ topic = self._collab_default_topic(partner_niche)
1265
+ schedule[chour] = ScheduledAction(
1266
+ hour=chour, action_type="post", content_type=ct,
1267
+ topic=topic, tags=self._collab_default_tags(partner_niche),
1268
+ intent="watch_bait" if ct in ("reel", "story") else "save_bait",
1269
+ )
1270
+
1271
  daily_engagement = 0.0
1272
  daily_reward = 0.0
1273
  daily_posts = 0
 
1311
  if self._day % 7 == 0 and self._day > 0:
1312
  self._total_posts_this_week = 0
1313
 
1314
+ # Tick down the post-collab algorithm carryover (one day per step).
1315
+ if self._collab_carryover_days_remaining > 0:
1316
+ self._collab_carryover_days_remaining -= 1
1317
+
1318
  # Burnout risk tracking
1319
  if energy_min < 0.2:
1320
  self._low_energy_days += 1
 
1375
  signals = None
1376
 
1377
  collab_growth_mult = 1.0
1378
+ collab_spillover_followers = 0
1379
 
1380
  if sa.action_type == "post":
1381
  cost = CONTENT_ENERGY_COST.get(sa.content_type, 0.1)
 
1418
  # Multiplicative on engagement; capped at 0.5 floor inside _process_interactions.
1419
  engagement *= getattr(self, "_pending_reach_mult", 1.0)
1420
 
1421
+ # Sustained post-collab algorithm reach buff (applies to every post in the carryover window).
1422
+ if getattr(self, "_collab_carryover_days_remaining", 0) > 0:
1423
+ engagement *= COLLAB_REACH_CARRYOVER_MULT
1424
+
1425
+ collab_spillover_followers = 0
1426
  if self._active_collab is not None and self._active_collab.hour == sa.hour:
1427
+ ev = self._collab_evaluation(self._active_collab.partner_id)
1428
  eng_m, growth_m = self._collab_multipliers(self._active_collab.partner_id)
1429
  engagement *= eng_m
1430
  collab_growth_mult = growth_m
1431
+ # One-shot follower spillover: partner audience gets exposed to user.
1432
+ # Scales with (1 - overlap) — disjoint audiences = more new followers.
1433
+ # repeat_decay flows through growth_m (already baked in by _collab_evaluation),
1434
+ # so we don't multiply by it again here.
1435
+ overlap = ev.get("overlap") or 0.0
1436
+ partner_followers = ev.get("partner_followers") or 0
1437
+ spill_k = COLLAB_BLOCKED_SPILLOVER_K if not ev.get("recommended") else COLLAB_SPILLOVER_K
1438
+ collab_spillover_followers = int(
1439
+ partner_followers * (1.0 - overlap) * growth_m * spill_k
1440
+ )
1441
+ self._last_collab_spillover = collab_spillover_followers
1442
+ # Arm the post-collab carryover window for the next N days.
1443
+ self._collab_carryover_days_remaining = COLLAB_REACH_CARRYOVER_DAYS
1444
 
1445
  engagement = min(engagement, 5.0)
1446
 
 
1472
 
1473
  if engagement > 0:
1474
  self._followers += int(engagement * 100 * collab_growth_mult)
1475
+ if collab_spillover_followers > 0:
1476
+ self._followers += collab_spillover_followers
1477
 
1478
  elif sa.action_type == "create_content":
1479
  self._energy = max(0.0, self._energy - CREATE_CONTENT_COST)