Ajeya95 commited on
Commit
f3afef3
·
verified ·
1 Parent(s): 5cab1d2

Deploy LifeChoice Simulator from Codex-built repository

Browse files
app.py CHANGED
@@ -16,8 +16,10 @@ from lifechoice_engine import (
16
  SimulationSession,
17
  choose,
18
  current_node,
 
19
  environment_image,
20
  environment_state,
 
21
  split_dilemma,
22
  start_session,
23
  )
@@ -280,6 +282,29 @@ footer { display: none !important; }
280
  transform: translate(-50%, -50%);
281
  z-index: 4;
282
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  .cascade-banner b { display: block; font: 900 .7rem ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
284
  .cascade-banner span { font-size: .76rem; }
285
  .report-card {
@@ -336,6 +361,7 @@ footer { display: none !important; }
336
  }
337
  #reset-button:hover { border-color: rgba(255,200,87,.45) !important; color: var(--gold) !important; }
338
  @keyframes slide-in { from { opacity: 0; transform: translate(-50%, -42%); } to { opacity: 1; transform: translate(-50%, -50%); } }
 
339
  @media (max-width: 760px) {
340
  .gradio-container { padding: 12px !important; }
341
  .app-header { align-items: flex-start; flex-direction: column; }
@@ -373,9 +399,20 @@ def begin(dilemma: str, chosen_key: str, calibration: str, persona: str):
373
  raise gr.Error("Enter a real dilemma first.")
374
  if not calibration.strip():
375
  raise gr.Error("Add one concrete pressure, constraint, or proof point.")
376
- session = start_session(dilemma, chosen_key or "A", calibration, persona)
 
 
 
 
 
 
 
 
 
 
 
377
  node = current_node(session)
378
- return (
379
  session,
380
  game_html(session, node),
381
  gr.Radio(choices=_choice_options(node), value=None, label="Choose your move"),
@@ -383,8 +420,10 @@ def begin(dilemma: str, chosen_key: str, calibration: str, persona: str):
383
  gr.Group(visible=False),
384
  gr.Column(visible=True),
385
  gr.Group(visible=False),
386
- gr.Button(interactive=True),
 
387
  )
 
388
 
389
 
390
  def make_choice(session: SimulationSession, choice_value: str | None, custom_choice: str):
@@ -437,10 +476,25 @@ def reset():
437
  gr.Group(visible=False),
438
  gr.Column(visible=False),
439
  gr.Group(visible=True),
 
440
  gr.Button(interactive=True),
441
  )
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  def game_html(
445
  session: SimulationSession,
446
  node: dict[str, Any] | None = None,
@@ -449,10 +503,11 @@ def game_html(
449
  report: dict[str, Any] | None = None,
450
  ) -> str:
451
  state = environment_state(session.world_state)
 
452
  background = image_data_uri(environment_image(session))
453
  sprite = image_data_uri(str(ROOT / "assets" / "characters" / "character.png"))
454
  portrait = image_data_uri(str(PERSONA_ASSETS[session.persona]))
455
- sprite_position = {"thriving": "100%", "stable": "33.333%", "struggling": "66.666%"}[state]
456
  node = node or {
457
  "month_label": "Future report",
458
  "node_theme": "reflection",
@@ -501,7 +556,7 @@ def game_html(
501
  <div class="stage-bottom">
502
  {persona_markup}
503
  <div class="player-wrap">
504
- <span class="state-pill">World: {state}</span>
505
  <div class="player-sprite" style="background-image:url('{sprite}');background-position:{sprite_position} 0"></div>
506
  </div>
507
  </div>
@@ -654,8 +709,9 @@ with gr.Blocks(css=CSS, theme=theme, title="LifeChoice Simulator") as demo:
654
  begin_btn.click(
655
  begin,
656
  [dilemma, path_choice, calibration, persona],
657
- [session_state, game_board, choices, custom, report_group, play_column, setup_group, choose_btn],
658
- ).then(lambda: gr.Group(visible=True), outputs=game_group, show_progress="hidden")
 
659
  choose_btn.click(
660
  make_choice,
661
  [session_state, choices, custom],
@@ -663,9 +719,9 @@ with gr.Blocks(css=CSS, theme=theme, title="LifeChoice Simulator") as demo:
663
  )
664
  reset_btn.click(
665
  reset,
666
- outputs=[session_state, game_board, choices, custom, report_group, play_column, setup_group, choose_btn],
667
  show_progress="hidden",
668
- ).then(lambda: gr.Group(visible=False), outputs=game_group, show_progress="hidden")
669
 
670
  if __name__ == "__main__":
671
  demo.queue(default_concurrency_limit=8).launch()
 
16
  SimulationSession,
17
  choose,
18
  current_node,
19
+ character_expression,
20
  environment_image,
21
  environment_state,
22
+ prefetch_node,
23
  split_dilemma,
24
  start_session,
25
  )
 
282
  transform: translate(-50%, -50%);
283
  z-index: 4;
284
  }
285
+ .loading-stage {
286
+ align-items: center;
287
+ background:
288
+ radial-gradient(circle at 50% 35%, rgba(92,225,230,.10), transparent 30%),
289
+ linear-gradient(145deg, #11182a, #070a12);
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 14px;
293
+ justify-content: center;
294
+ min-height: 530px;
295
+ text-align: center;
296
+ }
297
+ .loading-orbit {
298
+ animation: orbit 1.05s linear infinite;
299
+ border: 3px solid rgba(255,255,255,.11);
300
+ border-radius: 50%;
301
+ border-top-color: var(--gold);
302
+ height: 52px;
303
+ width: 52px;
304
+ }
305
+ .loading-stage h2 { color: #fff !important; font: 1.7rem "Arial Black", Impact, sans-serif; margin: 0; text-transform: uppercase; }
306
+ .loading-stage p { color: var(--muted) !important; margin: 0; max-width: 460px; }
307
+ .loading-steps { color: var(--cyan); font: 700 .67rem ui-monospace, monospace; letter-spacing: .09em; text-transform: uppercase; }
308
  .cascade-banner b { display: block; font: 900 .7rem ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
309
  .cascade-banner span { font-size: .76rem; }
310
  .report-card {
 
361
  }
362
  #reset-button:hover { border-color: rgba(255,200,87,.45) !important; color: var(--gold) !important; }
363
  @keyframes slide-in { from { opacity: 0; transform: translate(-50%, -42%); } to { opacity: 1; transform: translate(-50%, -50%); } }
364
+ @keyframes orbit { to { transform: rotate(360deg); } }
365
  @media (max-width: 760px) {
366
  .gradio-container { padding: 12px !important; }
367
  .app-header { align-items: flex-start; flex-direction: column; }
 
399
  raise gr.Error("Enter a real dilemma first.")
400
  if not calibration.strip():
401
  raise gr.Error("Add one concrete pressure, constraint, or proof point.")
402
+ yield (
403
+ None,
404
+ loading_html(dilemma, calibration),
405
+ gr.Radio(choices=[], value=None),
406
+ "",
407
+ gr.Group(visible=False),
408
+ gr.Column(visible=False),
409
+ gr.Group(visible=False),
410
+ gr.Group(visible=True),
411
+ gr.Button(interactive=False, value="Building your world..."),
412
+ )
413
+ session = start_session(dilemma, chosen_key or "A", calibration, persona, prefetch=False)
414
  node = current_node(session)
415
+ yield (
416
  session,
417
  game_html(session, node),
418
  gr.Radio(choices=_choice_options(node), value=None, label="Choose your move"),
 
420
  gr.Group(visible=False),
421
  gr.Column(visible=True),
422
  gr.Group(visible=False),
423
+ gr.Group(visible=True),
424
+ gr.Button(interactive=True, value="Commit choice"),
425
  )
426
+ prefetch_node(session, 1)
427
 
428
 
429
  def make_choice(session: SimulationSession, choice_value: str | None, custom_choice: str):
 
476
  gr.Group(visible=False),
477
  gr.Column(visible=False),
478
  gr.Group(visible=True),
479
+ gr.Group(visible=False),
480
  gr.Button(interactive=True),
481
  )
482
 
483
 
484
+ def loading_html(dilemma: str, calibration: str) -> str:
485
+ path_a, path_b = split_dilemma(dilemma)
486
+ detail = " ".join(calibration.split())[:150]
487
+ return f"""
488
+ <div class="game-frame loading-stage">
489
+ <div class="loading-orbit"></div>
490
+ <div class="eyebrow">Building a personalized causal world</div>
491
+ <h2>{html.escape(path_a)} / {html.escape(path_b)}</h2>
492
+ <p>Connecting your real constraint to the opening scene: {html.escape(detail)}</p>
493
+ <div class="loading-steps">Calibrating state / placing consequences / preparing choices</div>
494
+ </div>
495
+ """
496
+
497
+
498
  def game_html(
499
  session: SimulationSession,
500
  node: dict[str, Any] | None = None,
 
503
  report: dict[str, Any] | None = None,
504
  ) -> str:
505
  state = environment_state(session.world_state)
506
+ expression = character_expression(session.world_state)
507
  background = image_data_uri(environment_image(session))
508
  sprite = image_data_uri(str(ROOT / "assets" / "characters" / "character.png"))
509
  portrait = image_data_uri(str(PERSONA_ASSETS[session.persona]))
510
+ sprite_position = {"neutral": "0%", "stressed": "33.333%", "confident": "66.666%"}[expression]
511
  node = node or {
512
  "month_label": "Future report",
513
  "node_theme": "reflection",
 
556
  <div class="stage-bottom">
557
  {persona_markup}
558
  <div class="player-wrap">
559
+ <span class="state-pill">World: {state} / You: {expression}</span>
560
  <div class="player-sprite" style="background-image:url('{sprite}');background-position:{sprite_position} 0"></div>
561
  </div>
562
  </div>
 
709
  begin_btn.click(
710
  begin,
711
  [dilemma, path_choice, calibration, persona],
712
+ [session_state, game_board, choices, custom, report_group, play_column, setup_group, game_group, choose_btn],
713
+ show_progress="hidden",
714
+ )
715
  choose_btn.click(
716
  make_choice,
717
  [session_state, choices, custom],
 
719
  )
720
  reset_btn.click(
721
  reset,
722
+ outputs=[session_state, game_board, choices, custom, report_group, play_column, setup_group, game_group, choose_btn],
723
  show_progress="hidden",
724
+ )
725
 
726
  if __name__ == "__main__":
727
  demo.queue(default_concurrency_limit=8).launch()
docs/lifechoice-demo.mp4 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:8e21579f0cc2a5bd22a36aaf456315ae09fb255452eea5f1663253ad04eba5a7
3
- size 696040
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e42b89611a55ae62c52f809ffb498c3dd2b237f50540eef3a805bd4b7ea6b78
3
+ size 694479
docs/screenshots/simulation.png CHANGED

Git LFS Details

  • SHA256: 80f4a32d45e62802a4410af1b6667cda834b20e5d73aaa1276c5777490228b6d
  • Pointer size: 131 Bytes
  • Size of remote file: 702 kB

Git LFS Details

  • SHA256: 349f5db8e5e365970c6aca1bee967ef9b4609b62d38f49cd4f300f02098d82c4
  • Pointer size: 131 Bytes
  • Size of remote file: 677 kB
lifechoice_engine.py CHANGED
@@ -67,6 +67,7 @@ def start_session(
67
  chosen_key: str,
68
  calibration: str,
69
  persona: str,
 
70
  ) -> SimulationSession:
71
  path_a, path_b = split_dilemma(dilemma)
72
  chosen, unchosen = (path_b, path_a) if chosen_key == "B" else (path_a, path_b)
@@ -81,7 +82,8 @@ def start_session(
81
  characters=_characters(chosen, unchosen),
82
  )
83
  session.generated_nodes[0] = build_node(session, 0, use_llm=False)
84
- prefetch_node(session, 1)
 
85
  return session
86
 
87
 
@@ -176,22 +178,23 @@ def deterministic_node(context: dict[str, Any], index: int) -> dict[str, Any]:
176
  unchosen = context["unchosen_path"]
177
  state = context["world_state"]
178
  pressure = state_facts(state)
 
179
  prior = context.get("recent_choices", [])
180
  prior_text = prior[-1]["choice"] if prior else f"step into {chosen}"
181
  facts = context.get("facts") or ["your first commitment"]
182
  fact = facts[-1]
183
 
184
  scenarios = (
185
- f"A real opening appears on the {chosen} path. Taking it makes your decision visible to people who still expect {unchosen}. {pressure[0]}",
186
- f"The first bill and deadline arrive together. Your choice to {prior_text.lower()} now has a practical cost. {pressure[0]}",
187
- f"A peer posts a polished update from {unchosen} while your progress stays private. {pressure[1]}",
188
- f"An easier offer would protect your routine but blur the identity you wanted from {chosen}. {pressure[0]}",
189
- f"Your family asks for proof that {chosen} can become sustainable. The conversation is shaped by {fact}. {pressure[1]}",
190
- f"An earlier commitment returns as an opportunity with conditions attached. You cannot accept it without paying a cost created months ago. {pressure[0]}",
191
- f"You are no longer choosing only between paths. You are choosing what structure makes {chosen} survivable. {pressure[1]}",
192
- f"Ten months in, the outside decision is clearer. The final choice is whether your version of {chosen} can carry both meaning and responsibility. {pressure[0]}",
193
  )
194
- choices = _choice_templates(chosen, unchosen, index)
195
  return {
196
  "month_label": MONTHS[index],
197
  "node_theme": THEMES[index],
@@ -201,22 +204,27 @@ def deterministic_node(context: dict[str, Any], index: int) -> dict[str, Any]:
201
  }
202
 
203
 
204
- def _choice_templates(chosen: str, unchosen: str, index: int) -> list[dict[str, Any]]:
 
 
 
 
 
205
  templates = (
206
  (
207
- (f"Accept the first concrete {chosen} opportunity and tell people you are committed", _delta(-7, 12, 5, 9, -3), ["publicly_committed"], ["prove_progress"], []),
208
- (f"Negotiate a smaller {chosen} commitment while keeping income or study stability", _delta(5, 5, 1, 2, 3), ["built_a_safety_floor"], ["maintain_two_tracks"], []),
209
- (f"Delay the move and keep {unchosen} fully available for another month", _delta(6, -7, -3, 7, 4), ["delayed_commitment"], ["make_a_deadline"], []),
210
  ),
211
  (
212
- ("Cut optional spending and protect time for the path you chose", _delta(-4, 8, -2, 7, -4), ["protected_the_bold_path"], ["tight_budget"], []),
213
- ("Take paid side work and reduce the pace of the main path", _delta(10, -3, 3, 5, 4), ["added_paid_work"], ["less_time_for_core_path"], []),
214
- (f"Pause {chosen} until savings recover, even if momentum disappears", _delta(12, -11, 4, -5, 7), ["paused_for_money"], [], ["current_momentum"]),
215
  ),
216
  (
217
- ("Share unfinished work publicly and accept comparison as part of growth", _delta(-1, 6, 11, 8, -1), ["became_visible"], ["respond_to_feedback"], []),
218
- ("Ignore the comparison and protect a private building phase", _delta(1, 8, -5, 2, 0), ["chose_private_progress"], [], []),
219
- (f"Ask someone on {unchosen} for an honest comparison of both paths", _delta(3, 1, 5, 4, 3), ["sought_cross_path_advice"], ["face_comparison"], []),
220
  ),
221
  (
222
  ("Reject the easier offer because it changes what the work means to you", _delta(-10, 13, -2, 8, -3), ["protected_identity"], ["replace_lost_income"], ["easy_offer"]),
@@ -258,6 +266,90 @@ def _choice_templates(chosen: str, unchosen: str, index: int) -> list[dict[str,
258
  return result
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def validate_node(data: Any, fallback: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
262
  if not isinstance(data, dict) or not isinstance(data.get("choices"), list) or len(data["choices"]) != 3:
263
  return fallback
@@ -395,6 +487,14 @@ def environment_state(state: dict[str, int]) -> str:
395
  return "stable"
396
 
397
 
 
 
 
 
 
 
 
 
398
  def infer_environment(path: str) -> str:
399
  text = path.lower()
400
  mapping = (
@@ -450,6 +550,8 @@ def _consume_or_build(session: SimulationSession, index: int) -> dict[str, Any]:
450
 
451
  def _generate_node_with_hf(context: dict[str, Any], index: int) -> Any:
452
  token = os.getenv("HF_TOKEN")
 
 
453
  try:
454
  from huggingface_hub import InferenceClient
455
 
@@ -460,6 +562,11 @@ def _generate_node_with_hf(context: dict[str, Any], index: int) -> Any:
460
  "Exactly three materially different choices.",
461
  "Each choice must create a fact and use all five integer delta keys from -15 to 15.",
462
  "Respect metric constraints and causal facts. Never contradict closed options.",
 
 
 
 
 
463
  "No advice, diagnosis, certainty, or prediction.",
464
  ],
465
  "month": MONTHS[index],
@@ -555,5 +662,9 @@ def _label(value: str) -> str:
555
  return " ".join(word if word.isupper() else word.capitalize() for word in cleaned.strip(" ?.,").split())
556
 
557
 
 
 
 
 
558
  def _clamp(value: int) -> int:
559
  return max(0, min(100, int(value)))
 
67
  chosen_key: str,
68
  calibration: str,
69
  persona: str,
70
+ prefetch: bool = True,
71
  ) -> SimulationSession:
72
  path_a, path_b = split_dilemma(dilemma)
73
  chosen, unchosen = (path_b, path_a) if chosen_key == "B" else (path_a, path_b)
 
82
  characters=_characters(chosen, unchosen),
83
  )
84
  session.generated_nodes[0] = build_node(session, 0, use_llm=False)
85
+ if prefetch:
86
+ prefetch_node(session, 1)
87
  return session
88
 
89
 
 
178
  unchosen = context["unchosen_path"]
179
  state = context["world_state"]
180
  pressure = state_facts(state)
181
+ profile = personalization_profile(context)
182
  prior = context.get("recent_choices", [])
183
  prior_text = prior[-1]["choice"] if prior else f"step into {chosen}"
184
  facts = context.get("facts") or ["your first commitment"]
185
  fact = facts[-1]
186
 
187
  scenarios = (
188
+ f"{profile['opening']} You said this is real because {_lower_first(context['calibration'].rstrip('.'))}. Choosing it makes {profile['proof']} visible, while {unchosen} still offers {profile['alternative_pull']}.",
189
+ f"{profile['money_event']} Your earlier decision to {prior_text.lower()} now affects both your calendar and your bank balance. {pressure[0]}",
190
+ f"{profile['comparison_event']} Your progress on {chosen} is real but less visible. {pressure[1]}",
191
+ f"{profile['identity_event']} The offer protects one part of your life while weakening the reason you chose {chosen}. {pressure[0]}",
192
+ f"{profile['family_event']} They want evidence such as {profile['milestone']}, not another promise. The conversation is also shaped by {fact}.",
193
+ f"{profile['return_event']} Accepting it now means honoring obligations created by your earlier choices. {pressure[0]}",
194
+ f"The question is no longer simply {chosen} versus {unchosen}. You need a structure built around {profile['safety_floor']} that keeps the chosen path livable. {pressure[1]}",
195
+ f"Ten months in, you have evidence from {profile['milestone']} and the costs recorded in your choices. Decide whether {chosen} can carry meaning, money, health, and relationships together.",
196
  )
197
+ choices = _choice_templates(chosen, unchosen, index, profile)
198
  return {
199
  "month_label": MONTHS[index],
200
  "node_theme": THEMES[index],
 
204
  }
205
 
206
 
207
+ def _choice_templates(
208
+ chosen: str,
209
+ unchosen: str,
210
+ index: int,
211
+ profile: dict[str, str],
212
+ ) -> list[dict[str, Any]]:
213
  templates = (
214
  (
215
+ (profile["opening_bold"], _delta(-7, 12, 5, 9, -3), ["publicly_committed"], ["prove_progress"], []),
216
+ (profile["opening_balanced"], _delta(5, 5, 1, 2, 3), ["built_a_safety_floor"], ["maintain_two_tracks"], []),
217
+ (profile["opening_safe"], _delta(6, -7, -3, 7, 4), ["delayed_commitment"], ["make_a_deadline"], []),
218
  ),
219
  (
220
+ (f"Cut optional spending for three months and protect weekly time for {profile['milestone']}", _delta(-4, 8, -2, 7, -4), ["protected_the_bold_path"], ["tight_budget"], []),
221
+ (f"Use {profile['income_floor']} to support yourself, but reduce the pace of {chosen}", _delta(10, -3, 3, 5, 4), ["added_paid_work"], ["less_time_for_core_path"], []),
222
+ (f"Pause {chosen} until you have {profile['safety_floor']}, even if {profile['proof']} loses momentum", _delta(12, -11, 4, -5, 7), ["paused_for_money"], [], ["current_momentum"]),
223
  ),
224
  (
225
+ (f"Share an unfinished {profile['work_sample']} and ask for feedback instead of hiding behind comparison", _delta(-1, 6, 11, 8, -1), ["became_visible"], ["respond_to_feedback"], []),
226
+ (f"Keep the {profile['work_sample']} private until it reaches the milestone you defined", _delta(1, 8, -5, 2, 0), ["chose_private_progress"], [], []),
227
+ (f"Ask someone already on {unchosen} to compare daily work, money, and growth honestly", _delta(3, 1, 5, 4, 3), ["sought_cross_path_advice"], ["face_comparison"], []),
228
  ),
229
  (
230
  ("Reject the easier offer because it changes what the work means to you", _delta(-10, 13, -2, 8, -3), ["protected_identity"], ["replace_lost_income"], ["easy_offer"]),
 
266
  return result
267
 
268
 
269
+ def personalization_profile(context: dict[str, Any]) -> dict[str, str]:
270
+ chosen = context["chosen_path"]
271
+ unchosen = context["unchosen_path"]
272
+ calibration = " ".join(str(context.get("calibration", "")).split())
273
+ text = f"{chosen} {unchosen} {calibration}".lower()
274
+
275
+ if any(word in text for word in ("mtech", "masters", "research", "professor", "phd")):
276
+ professor = "the professor who showed interest" if "professor" in text else "a potential research mentor"
277
+ return {
278
+ "opening": f"{professor.capitalize()} asks whether you can commit to a defined project this semester.",
279
+ "proof": "a research direction and mentor support",
280
+ "alternative_pull": "an immediate salary and a clearer timeline",
281
+ "money_event": "Fees, living costs, and the delay before a dependable stipend become concrete.",
282
+ "comparison_event": f"A college peer shares a joining date and salary update from {unchosen}.",
283
+ "identity_event": "A placement opportunity arrives just as the research begins to feel like your own work.",
284
+ "family_event": "Your family asks how long they must wait before this path contributes income.",
285
+ "return_event": f"The professor offers a larger role, but it overlaps with interviews and income plans for {unchosen}.",
286
+ "milestone": "a funded project, publication milestone, or credible placement outcome",
287
+ "safety_floor": "a fees-and-living-cost plan with a firm review date",
288
+ "income_floor": "paid tutoring, assistantship work, or a limited freelance commitment",
289
+ "work_sample": "research proposal or early project result",
290
+ "opening_bold": "Accept the research project, but ask for scope, stipend timing, and a three-month milestone",
291
+ "opening_balanced": f"Commit to a smaller research milestone while continuing selected {unchosen} interviews",
292
+ "opening_safe": f"Take the {unchosen} route now and ask the professor to keep a limited research role open",
293
+ }
294
+
295
+ if any(word in text for word in ("artist", "design", "music", "writer", "creative", "film")):
296
+ return {
297
+ "opening": "A real client or showcase opportunity asks for a committed delivery date.",
298
+ "proof": "your portfolio and response from real people",
299
+ "alternative_pull": "a predictable salary and daily structure",
300
+ "money_event": "Rent, software costs, and an uneven client pipeline arrive in the same month.",
301
+ "comparison_event": f"A friend on {unchosen} posts a promotion while your strongest work is unfinished.",
302
+ "identity_event": "A commercial brief pays well but pushes your work away from the style you wanted to build.",
303
+ "family_event": "Your family asks whether the next client and payment are actually confirmed.",
304
+ "return_event": "A previous client returns with a larger project, a short deadline, and restrictive terms.",
305
+ "milestone": "three paying clients or a portfolio that consistently creates leads",
306
+ "safety_floor": "six months of runway and a monthly client target",
307
+ "income_floor": "a retainer client or part-time design contract",
308
+ "work_sample": "portfolio piece",
309
+ "opening_bold": "Accept the project, quote a real price, and publish the finished work as portfolio proof",
310
+ "opening_balanced": "Negotiate a smaller paid scope while keeping a limited stable-work schedule",
311
+ "opening_safe": f"Prioritize {unchosen} and reserve fixed weekly hours for one serious portfolio project",
312
+ }
313
+
314
+ if any(word in text for word in ("startup", "founder", "business", "revenue", "users")):
315
+ return {
316
+ "opening": "A potential customer asks for a feature and a delivery date before agreeing to pay.",
317
+ "proof": "customer use and repeatable revenue",
318
+ "alternative_pull": "salary, benefits, and predictable hours",
319
+ "money_event": "Runway shortens while customer requests expand beyond the original product.",
320
+ "comparison_event": f"A peer accepts a strong {unchosen} offer while your traction remains uneven.",
321
+ "identity_event": "An investor-friendly pivot could improve growth but weakens the problem you wanted to solve.",
322
+ "family_event": "Your family asks how much runway remains and what result would make you stop.",
323
+ "return_event": "An early customer returns with revenue, but demands priority and custom work.",
324
+ "milestone": "repeat customers and a monthly revenue target",
325
+ "safety_floor": "a runway threshold and a written stop-or-pivot date",
326
+ "income_floor": "consulting work capped at two days per week",
327
+ "work_sample": "customer pilot",
328
+ "opening_bold": "Commit to the pilot and define payment, scope, and a four-week success metric",
329
+ "opening_balanced": "Run the pilot while preserving two days of paid consulting each week",
330
+ "opening_safe": f"Take {unchosen} and test the product with customers on a fixed evening schedule",
331
+ }
332
+
333
+ concrete = calibration.rstrip(".") or f"you have a real reason to consider {chosen}"
334
+ return {
335
+ "opening": f"A concrete opportunity forces the decision forward: {concrete}.",
336
+ "proof": f"whether {chosen} works under your real constraint",
337
+ "alternative_pull": f"the protections you associate with {unchosen}",
338
+ "money_event": "The first deadline and financial consequence arrive together.",
339
+ "comparison_event": f"Someone close to you shows visible progress on {unchosen}.",
340
+ "identity_event": f"An easier option appears, but it changes what {chosen} would mean in daily life.",
341
+ "family_event": f"People affected by the decision ask for a concrete plan for {chosen}.",
342
+ "return_event": "The first opportunity returns with better upside and stricter conditions.",
343
+ "milestone": "a measurable result with a deadline",
344
+ "safety_floor": "a money, time, and health boundary",
345
+ "income_floor": "limited paid work",
346
+ "work_sample": "work-in-progress result",
347
+ "opening_bold": f"Accept the first {chosen} opportunity and define a measurable result before committing further",
348
+ "opening_balanced": f"Test {chosen} for three months while preserving a specific fallback on {unchosen}",
349
+ "opening_safe": f"Choose {unchosen} now and keep one bounded experiment on {chosen}",
350
+ }
351
+
352
+
353
  def validate_node(data: Any, fallback: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
354
  if not isinstance(data, dict) or not isinstance(data.get("choices"), list) or len(data["choices"]) != 3:
355
  return fallback
 
487
  return "stable"
488
 
489
 
490
+ def character_expression(state: dict[str, int]) -> str:
491
+ if state["stress"] > 70:
492
+ return "stressed"
493
+ if state["creative_fulfillment"] > 70 and state["stress"] < 40:
494
+ return "confident"
495
+ return "neutral"
496
+
497
+
498
  def infer_environment(path: str) -> str:
499
  text = path.lower()
500
  mapping = (
 
550
 
551
  def _generate_node_with_hf(context: dict[str, Any], index: int) -> Any:
552
  token = os.getenv("HF_TOKEN")
553
+ if not token:
554
+ return None
555
  try:
556
  from huggingface_hub import InferenceClient
557
 
 
562
  "Exactly three materially different choices.",
563
  "Each choice must create a fact and use all five integer delta keys from -15 to 15.",
564
  "Respect metric constraints and causal facts. Never contradict closed options.",
565
+ "Write in second person and keep the scenario under 320 characters.",
566
+ "Mention at least one concrete detail from calibration, current state, or recent history.",
567
+ "Choices must be specific actions with a concrete object, person, amount, deadline, or milestone.",
568
+ "Every choice must expose a real tradeoff with the unchosen path; no generic advice.",
569
+ "Ground relevant career dilemmas in realistic Indian student or young-professional details.",
570
  "No advice, diagnosis, certainty, or prediction.",
571
  ],
572
  "month": MONTHS[index],
 
662
  return " ".join(word if word.isupper() else word.capitalize() for word in cleaned.strip(" ?.,").split())
663
 
664
 
665
+ def _lower_first(value: str) -> str:
666
+ return value[:1].lower() + value[1:] if value else value
667
+
668
+
669
  def _clamp(value: int) -> int:
670
  return max(0, min(100, int(value)))