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

Deploy LifeChoice Simulator from Codex-built repository

Browse files
Files changed (40) hide show
  1. .gitattributes +23 -0
  2. MODEL_MANIFEST.json +12 -0
  3. README.md +176 -5
  4. app.py +671 -0
  5. assets/characters/character.png +0 -0
  6. assets/environments/.gitkeep +1 -0
  7. assets/environments/campus_academic_stable.png +3 -0
  8. assets/environments/campus_academic_struggling.png +3 -0
  9. assets/environments/campus_academic_thriving.png +3 -0
  10. assets/environments/corporate_stable.png +3 -0
  11. assets/environments/corporate_struggling.png +3 -0
  12. assets/environments/corporate_thriving.png +3 -0
  13. assets/environments/medical_clinical_stable.png +3 -0
  14. assets/environments/medical_clinical_struggling.png +3 -0
  15. assets/environments/medical_clinical_thriving.png +3 -0
  16. assets/environments/startup_chaotic_stable.png +3 -0
  17. assets/environments/startup_chaotic_struggling.png +3 -0
  18. assets/environments/startup_chaotic_thriving.png +3 -0
  19. assets/environments/studio_creative_stable.png +3 -0
  20. assets/environments/studio_creative_struggling.png +3 -0
  21. assets/environments/studio_creative_thriving.png +3 -0
  22. assets/environments/tech_office_stable.png +3 -0
  23. assets/environments/tech_office_struggling.png +3 -0
  24. assets/environments/tech_office_thriving.png +3 -0
  25. assets/personas/family.webp +0 -0
  26. assets/personas/friend.webp +0 -0
  27. assets/personas/mentor.webp +0 -0
  28. docs/__pycache__/create_demo_video.cpython-312.pyc +0 -0
  29. docs/architecture.svg +54 -0
  30. docs/compliance-checklist.md +42 -0
  31. docs/create_demo_video.py +78 -0
  32. docs/demo-script.md +20 -0
  33. docs/lifechoice-demo.mp4 +3 -0
  34. docs/screenshots/cascade.png +3 -0
  35. docs/screenshots/onboarding.png +3 -0
  36. docs/screenshots/report.png +3 -0
  37. docs/screenshots/simulation.png +3 -0
  38. docs/social-post.md +16 -0
  39. lifechoice_engine.py +559 -0
  40. requirements.txt +3 -0
.gitattributes CHANGED
@@ -33,3 +33,26 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/environments/campus_academic_stable.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/environments/campus_academic_struggling.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/environments/campus_academic_thriving.png filter=lfs diff=lfs merge=lfs -text
39
+ assets/environments/corporate_stable.png filter=lfs diff=lfs merge=lfs -text
40
+ assets/environments/corporate_struggling.png filter=lfs diff=lfs merge=lfs -text
41
+ assets/environments/corporate_thriving.png filter=lfs diff=lfs merge=lfs -text
42
+ assets/environments/medical_clinical_stable.png filter=lfs diff=lfs merge=lfs -text
43
+ assets/environments/medical_clinical_struggling.png filter=lfs diff=lfs merge=lfs -text
44
+ assets/environments/medical_clinical_thriving.png filter=lfs diff=lfs merge=lfs -text
45
+ assets/environments/startup_chaotic_stable.png filter=lfs diff=lfs merge=lfs -text
46
+ assets/environments/startup_chaotic_struggling.png filter=lfs diff=lfs merge=lfs -text
47
+ assets/environments/startup_chaotic_thriving.png filter=lfs diff=lfs merge=lfs -text
48
+ assets/environments/studio_creative_stable.png filter=lfs diff=lfs merge=lfs -text
49
+ assets/environments/studio_creative_struggling.png filter=lfs diff=lfs merge=lfs -text
50
+ assets/environments/studio_creative_thriving.png filter=lfs diff=lfs merge=lfs -text
51
+ assets/environments/tech_office_stable.png filter=lfs diff=lfs merge=lfs -text
52
+ assets/environments/tech_office_struggling.png filter=lfs diff=lfs merge=lfs -text
53
+ assets/environments/tech_office_thriving.png filter=lfs diff=lfs merge=lfs -text
54
+ docs/lifechoice-demo.mp4 filter=lfs diff=lfs merge=lfs -text
55
+ docs/screenshots/cascade.png filter=lfs diff=lfs merge=lfs -text
56
+ docs/screenshots/onboarding.png filter=lfs diff=lfs merge=lfs -text
57
+ docs/screenshots/report.png filter=lfs diff=lfs merge=lfs -text
58
+ docs/screenshots/simulation.png filter=lfs diff=lfs merge=lfs -text
MODEL_MANIFEST.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "models": [
3
+ {
4
+ "id": "Qwen/Qwen2.5-7B-Instruct",
5
+ "parameters_billions": 7.616,
6
+ "purpose": "Optional bounded decision-node enrichment",
7
+ "required": false
8
+ }
9
+ ],
10
+ "maximum_allowed_parameters_billions": 31.999,
11
+ "fallback": "Deterministic Python simulation content; no fallback model"
12
+ }
README.md CHANGED
@@ -1,13 +1,184 @@
1
  ---
2
  title: LifeChoice Simulator
3
- emoji:
4
  colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: LifeChoice Simulator
3
+ emoji: 🧭
4
  colorFrom: purple
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 5.50.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ models:
12
+ - Qwen/Qwen2.5-7B-Instruct
13
+ tags:
14
+ - build-small-hackathon
15
+ - gradio
16
+ - agent
17
+ - decision-support
18
+ - thousand-token-wood
19
+ - best-agent
20
+ - best-demo
21
+ - openai
22
  ---
23
 
24
+ # LifeChoice Simulator
25
+
26
+ LifeChoice Simulator turns a difficult real-life dilemma into a short, consequential future you can play through. It is designed for reflection, not prediction or advice.
27
+
28
+ ![Architecture](docs/architecture.svg)
29
+
30
+ ## Live Demo
31
+
32
+ - Hugging Face Space target: `build-small-hackathon/LifeChoice-Simulator` (organization authorization pending)
33
+ - Public demo video: [watch the 22-second walkthrough](https://github.com/Ajey95/LifeChoice-Simulator-Hackathon/raw/main/docs/lifechoice-demo.mp4)
34
+ - Social post: publishing pending
35
+
36
+ The repository includes a longer [demo script](docs/demo-script.md) and [social post draft](docs/social-post.md) ready for public publishing.
37
+
38
+ ## Why It Is Not Just a Chatbot
39
+
40
+ A chatbot responds turn by turn with prose. LifeChoice runs a stateful simulation engine:
41
+
42
+ - Every choice changes five deterministic metrics.
43
+ - Choices create durable facts, obligations, and closed options.
44
+ - Later scenes must respect those causal records and metric thresholds.
45
+ - Earlier choices return through three cascade moments.
46
+ - The visual environment changes among `thriving`, `stable`, and `struggling`.
47
+ - A bounded context packet prevents token growth across the simulation.
48
+ - The final report is computed from actual behavior, not a conversational impression.
49
+
50
+ The language model enriches bounded decision nodes. It does not own arithmetic, state transitions, safety limits, or simulation completion.
51
+
52
+ ## Product Design
53
+
54
+ LifeChoice is designed around fast entry, bounded generation, and consequences that remain visible:
55
+
56
+ | Capability | Design |
57
+ |---|---|
58
+ | Fast onboarding | Dilemma, path, one concrete calibration answer, and one persona selection |
59
+ | Immediate play | The opening node is deterministic and available without model latency |
60
+ | Efficient generation | One future node is enriched at a time and prefetched in the background |
61
+ | Bounded context | Last 3 choices, 8 facts, 5 obligations, and 5 closed options only |
62
+ | Stable characters | Characters remain static session data |
63
+ | Consistent world state | Threshold facts and narrative validation enforce visible pressure |
64
+ | Meaningful choices | Every choice mutates causal facts, obligations, closed options, and metrics |
65
+ | Persona continuity | Reactions appear at opening, nodes 1/3/5/7, and critical thresholds |
66
+ | Delayed consequences | Small echo at node 2, major consequence at node 5, final payoff at node 7 |
67
+ | Reactive visuals | Environment image and metric panel update after every decision |
68
+
69
+ ## Model Compliance
70
+
71
+ Only one model is configured:
72
+
73
+ | Model | Parameters | Purpose |
74
+ |---|---:|---|
75
+ | [`Qwen/Qwen2.5-7B-Instruct`](https://huggingface.co/Qwen/Qwen2.5-7B-Instruct) | 7.616B | Optional bounded node enrichment |
76
+
77
+ Hugging Face repository metadata reports `7,615,616,512` parameters. No secondary model is configured, and no model at or above 32B is used. If hosted inference is unavailable, the simulation uses deterministic authored nodes and remains fully playable.
78
+
79
+ Run the compliance test:
80
+
81
+ ```bash
82
+ pytest tests/test_model_compliance.py
83
+ ```
84
+
85
+ ## Architecture
86
+
87
+ 1. Gradio captures a dilemma, chosen path, one calibration fact, and persona.
88
+ 2. The engine returns an immediate deterministic opening scene.
89
+ 3. A background worker optionally enriches future nodes with the 7B model.
90
+ 4. The deterministic state engine applies deltas and updates the causal ledger.
91
+ 5. Narrative validation rejects generated scenes that contradict critical metrics.
92
+ 6. The environment derives its visual state from all five metrics.
93
+ 7. Cascades and the final report use the causal ledger.
94
+
95
+ ## Safety
96
+
97
+ LifeChoice is a reflective simulation, not medical, legal, financial, mental-health, or career advice. Its futures are fictional hypotheses rather than predictions. Users should not enter secrets or sensitive personal data. High-stakes decisions should be discussed with qualified people who understand the real situation.
98
+
99
+ Safety controls include:
100
+
101
+ - Deterministic metric arithmetic and clamping to `0..100`
102
+ - Bounded model context
103
+ - Strict model-output schema validation
104
+ - Deterministic fallback for outages or invalid output
105
+ - No autonomous real-world action
106
+ - No recommendation of a “correct” path
107
+ - Explicit uncertainty and disclaimer text in the UI
108
+
109
+ ## Tech Stack
110
+
111
+ - Python 3.10+
112
+ - Gradio 5
113
+ - Hugging Face Hub `InferenceClient`
114
+ - `Qwen/Qwen2.5-7B-Instruct` (7B)
115
+ - Thread-pool prefetching
116
+ - Pytest
117
+ - Pixel-art environment assets
118
+
119
+ ## Run Locally
120
+
121
+ ```bash
122
+ python -m venv .venv
123
+ .\.venv\Scripts\activate
124
+ pip install -r requirements.txt
125
+ python app.py
126
+ ```
127
+
128
+ `HF_TOKEN` is optional. Without it, deterministic fallback content is used.
129
+
130
+ ### Deploy The Organization Space
131
+
132
+ Authenticate specifically as `Ajeya95`, with write permission in `build-small-hackathon`, then run:
133
+
134
+ ```bash
135
+ hf auth login
136
+ python scripts/deploy_space.py
137
+ ```
138
+
139
+ The deployment script checks both the username and organization role before creating or updating the public Space.
140
+
141
+ ## Tests
142
+
143
+ ```bash
144
+ pytest -q
145
+ ```
146
+
147
+ The suite covers model-size compliance, bounded context, branch divergence, world-state clamping, state-to-narrative consistency, cascades, persona cadence, and metric-driven environment changes.
148
+
149
+ ## Hackathon Categories
150
+
151
+ - Thousand Token Wood
152
+ - Best Agent
153
+ - Best Demo
154
+ - OpenAI Prize
155
+
156
+ This submission does not claim Tiny Titan, OpenBMB, NVIDIA, Modal credits, or Off Brand unless the implementation is later changed to meet those category-specific requirements.
157
+
158
+ ## Built With Codex
159
+
160
+ Codex built the simulation architecture, Gradio interface, tests, documentation, and deployment workflow. Codex-attributed commits use:
161
+
162
+ ```text
163
+ Author: Codex <codex@openai.com>
164
+ ```
165
+
166
+ ## Screenshots
167
+
168
+ ### Immediate First Scene
169
+
170
+ ![Immediate first scene](docs/screenshots/simulation.png)
171
+
172
+ ### Cascade
173
+
174
+ ![Earlier decision returning](docs/screenshots/cascade.png)
175
+
176
+ ### Final Report
177
+
178
+ ![Causal final report](docs/screenshots/report.png)
179
+
180
+ ## Submission Status
181
+
182
+ Code, tests, Space metadata, model compliance, safety documentation, architecture, and publishing assets are included. Public URL fields are updated only after the authenticated GitHub repository, Hugging Face organization Space, video, and social post exist.
183
+
184
+ See the dated [compliance checklist](docs/compliance-checklist.md) for requirement-by-requirement evidence and external blockers.
app.py ADDED
@@ -0,0 +1,671 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import html
5
+ import json
6
+ import mimetypes
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import gradio as gr
12
+
13
+ from lifechoice_engine import (
14
+ METRICS,
15
+ MODEL_ID,
16
+ SimulationSession,
17
+ choose,
18
+ current_node,
19
+ environment_image,
20
+ environment_state,
21
+ split_dilemma,
22
+ start_session,
23
+ )
24
+
25
+ ROOT = Path(__file__).parent
26
+ PERSONA_ASSETS = {
27
+ "Family": ROOT / "assets" / "personas" / "family.webp",
28
+ "Friend": ROOT / "assets" / "personas" / "friend.webp",
29
+ "Mentor": ROOT / "assets" / "personas" / "mentor.webp",
30
+ "Partner": ROOT / "assets" / "personas" / "friend.webp",
31
+ "Inner Voice": ROOT / "assets" / "personas" / "mentor.webp",
32
+ }
33
+
34
+ CSS = """
35
+ :root {
36
+ --night: #070a12;
37
+ --panel: rgba(9, 13, 24, .90);
38
+ --line: rgba(255, 255, 255, .14);
39
+ --cream: #fff7df;
40
+ --muted: #bbb7aa;
41
+ --gold: #ffc857;
42
+ --coral: #ff6b5f;
43
+ --cyan: #5ce1e6;
44
+ --green: #74e59b;
45
+ }
46
+ * { box-sizing: border-box; }
47
+ body, .gradio-container {
48
+ background:
49
+ radial-gradient(circle at 12% -8%, rgba(65, 71, 150, .34), transparent 32rem),
50
+ linear-gradient(180deg, #11162a 0%, var(--night) 48%, #05070c 100%) !important;
51
+ color: var(--cream) !important;
52
+ }
53
+ .gradio-container {
54
+ max-width: 1180px !important;
55
+ padding: 22px 22px 34px !important;
56
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
57
+ }
58
+ footer { display: none !important; }
59
+ .app-header {
60
+ display: flex;
61
+ align-items: flex-end;
62
+ justify-content: space-between;
63
+ gap: 24px;
64
+ margin: 5px 2px 18px;
65
+ }
66
+ .brand-lockup h1 {
67
+ color: #fff !important;
68
+ font-family: "Arial Black", Impact, sans-serif;
69
+ font-size: clamp(2rem, 6vw, 4.7rem);
70
+ line-height: .84;
71
+ letter-spacing: -.075em;
72
+ margin: 7px 0 9px;
73
+ text-transform: uppercase;
74
+ }
75
+ .brand-lockup h1 span { color: var(--gold); }
76
+ .brand-lockup p { color: var(--muted) !important; margin: 0; max-width: 670px; }
77
+ .eyebrow {
78
+ color: var(--cyan) !important;
79
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
80
+ font-size: .72rem;
81
+ font-weight: 800;
82
+ letter-spacing: .14em;
83
+ text-transform: uppercase;
84
+ }
85
+ .model-chip {
86
+ border: 1px solid rgba(92, 225, 230, .4);
87
+ border-radius: 999px;
88
+ color: var(--cyan);
89
+ flex: 0 0 auto;
90
+ font: 700 .72rem ui-monospace, monospace;
91
+ padding: 9px 12px;
92
+ }
93
+ .setup-card {
94
+ background: linear-gradient(145deg, rgba(22, 27, 48, .98), rgba(8, 11, 21, .98));
95
+ border: 1px solid var(--line);
96
+ border-radius: 22px;
97
+ box-shadow: 0 24px 80px rgba(0, 0, 0, .36);
98
+ overflow: hidden;
99
+ padding: 10px;
100
+ }
101
+ .setup-intro {
102
+ min-height: 100%;
103
+ padding: 26px 22px;
104
+ background:
105
+ linear-gradient(180deg, rgba(7, 10, 18, .08), rgba(7, 10, 18, .92)),
106
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Cpath d='M0 40h80M40 0v80' stroke='%23ffffff' stroke-opacity='.035'/%3E%3C/svg%3E");
107
+ border-radius: 15px;
108
+ }
109
+ .setup-intro h2 {
110
+ color: #fff !important;
111
+ font-family: "Arial Black", Impact, sans-serif;
112
+ font-size: clamp(1.8rem, 4vw, 3.2rem);
113
+ letter-spacing: -.05em;
114
+ line-height: .95;
115
+ margin: 24px 0 14px;
116
+ text-transform: uppercase;
117
+ }
118
+ .setup-intro p { color: var(--muted) !important; line-height: 1.65; }
119
+ .feature-list { display: grid; gap: 11px; margin-top: 26px; }
120
+ .feature {
121
+ align-items: center;
122
+ border-top: 1px solid rgba(255,255,255,.09);
123
+ color: #ddd8ca;
124
+ display: flex;
125
+ font-size: .88rem;
126
+ gap: 10px;
127
+ padding-top: 11px;
128
+ }
129
+ .feature b { color: var(--gold); font: 800 .72rem ui-monospace, monospace; }
130
+ .form-panel { padding: 18px 14px 12px; }
131
+ .gradio-container label span { color: #d9d5c8 !important; font-weight: 700 !important; }
132
+ .gradio-container textarea,
133
+ .gradio-container input {
134
+ color: #fff !important;
135
+ }
136
+ .gradio-container .block {
137
+ border-color: rgba(255,255,255,.12) !important;
138
+ }
139
+ #begin-button, #commit-button {
140
+ background: linear-gradient(135deg, var(--gold), #ff9f43) !important;
141
+ border: 0 !important;
142
+ box-shadow: 0 10px 30px rgba(255, 159, 67, .22);
143
+ color: #17120a !important;
144
+ font-family: "Arial Black", Impact, sans-serif !important;
145
+ letter-spacing: .04em;
146
+ min-height: 50px;
147
+ text-transform: uppercase;
148
+ }
149
+ #begin-button:hover, #commit-button:hover { filter: brightness(1.08); transform: translateY(-1px); }
150
+ .microcopy { color: #8f8b80 !important; font-size: .75rem; line-height: 1.5; margin: 9px 2px 0; }
151
+ .game-frame {
152
+ aspect-ratio: 16 / 9;
153
+ background: #111;
154
+ border: 1px solid rgba(255,255,255,.18);
155
+ border-radius: 22px;
156
+ box-shadow: 0 26px 90px rgba(0,0,0,.52);
157
+ isolation: isolate;
158
+ min-height: 530px;
159
+ overflow: hidden;
160
+ position: relative;
161
+ }
162
+ .world-bg {
163
+ background-position: center;
164
+ background-size: cover;
165
+ filter: saturate(.9) contrast(1.04);
166
+ inset: 0;
167
+ position: absolute;
168
+ transform: scale(1.015);
169
+ z-index: -3;
170
+ }
171
+ .world-shade {
172
+ background:
173
+ linear-gradient(90deg, rgba(3,5,10,.88) 0%, rgba(3,5,10,.34) 57%, rgba(3,5,10,.72) 100%),
174
+ linear-gradient(0deg, rgba(3,5,10,.94) 0%, transparent 38%, rgba(3,5,10,.25) 100%);
175
+ inset: 0;
176
+ position: absolute;
177
+ z-index: -2;
178
+ }
179
+ .scanlines {
180
+ background: repeating-linear-gradient(0deg, transparent 0, transparent 3px, rgba(0,0,0,.08) 4px);
181
+ inset: 0;
182
+ pointer-events: none;
183
+ position: absolute;
184
+ z-index: 5;
185
+ }
186
+ .stage-top {
187
+ align-items: flex-start;
188
+ display: flex;
189
+ justify-content: space-between;
190
+ padding: clamp(16px, 3vw, 28px);
191
+ }
192
+ .scenario-card {
193
+ backdrop-filter: blur(12px);
194
+ background: var(--panel);
195
+ border: 1px solid rgba(255,255,255,.17);
196
+ border-left: 4px solid var(--gold);
197
+ border-radius: 6px 16px 16px 6px;
198
+ box-shadow: 0 18px 55px rgba(0,0,0,.34);
199
+ max-width: 64%;
200
+ padding: clamp(15px, 2.5vw, 25px);
201
+ }
202
+ .scene-meta {
203
+ color: var(--cyan);
204
+ font: 800 .68rem ui-monospace, monospace;
205
+ letter-spacing: .12em;
206
+ text-transform: uppercase;
207
+ }
208
+ .scenario-card h2 {
209
+ color: #fff !important;
210
+ font-family: "Arial Black", Impact, sans-serif;
211
+ font-size: clamp(1.25rem, 3vw, 2.15rem);
212
+ letter-spacing: -.035em;
213
+ line-height: 1;
214
+ margin: 9px 0 12px;
215
+ text-transform: uppercase;
216
+ }
217
+ .scenario-card p { color: #f0ecdf !important; font-size: clamp(.87rem, 1.5vw, 1rem); line-height: 1.58; margin: 0; }
218
+ .source-line { color: #8f8b80; display: block; font: .64rem ui-monospace, monospace; margin-top: 14px; }
219
+ .hud {
220
+ backdrop-filter: blur(10px);
221
+ background: rgba(7,10,18,.84);
222
+ border: 1px solid rgba(255,255,255,.14);
223
+ border-radius: 14px;
224
+ min-width: 210px;
225
+ padding: 13px;
226
+ width: 25%;
227
+ }
228
+ .hud-title { color: var(--gold); font: 900 .68rem ui-monospace, monospace; letter-spacing: .12em; margin-bottom: 10px; }
229
+ .metric-row { margin: 8px 0; }
230
+ .metric-label { color: #d6d2c7; display: flex; font: 700 .62rem ui-monospace, monospace; justify-content: space-between; text-transform: uppercase; }
231
+ .metric-track { background: rgba(255,255,255,.11); height: 5px; margin-top: 5px; overflow: hidden; }
232
+ .metric-fill { background: linear-gradient(90deg, var(--cyan), var(--green)); height: 100%; }
233
+ .metric-row.stress .metric-fill { background: linear-gradient(90deg, #ffb347, var(--coral)); }
234
+ .stage-bottom { bottom: 0; display: flex; justify-content: space-between; left: 0; padding: 0 27px 24px; position: absolute; right: 0; }
235
+ .persona-bubble {
236
+ align-self: flex-end;
237
+ backdrop-filter: blur(10px);
238
+ background: rgba(10,13,22,.91);
239
+ border: 1px solid rgba(255,255,255,.16);
240
+ border-radius: 14px;
241
+ display: flex;
242
+ gap: 12px;
243
+ max-width: 58%;
244
+ padding: 10px 13px;
245
+ }
246
+ .persona-bubble img { border: 2px solid var(--cyan); border-radius: 8px; height: 58px; object-fit: cover; width: 58px; }
247
+ .persona-copy b { color: var(--cyan); display: block; font: 800 .68rem ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
248
+ .persona-copy p { color: #eee9dc !important; font-size: .8rem; line-height: 1.4; margin: 5px 0 0; }
249
+ .player-wrap { align-items: flex-end; display: flex; gap: 12px; }
250
+ .state-pill {
251
+ background: rgba(7,10,18,.82);
252
+ border: 1px solid rgba(255,255,255,.15);
253
+ border-radius: 999px;
254
+ color: var(--cream);
255
+ font: 800 .63rem ui-monospace, monospace;
256
+ margin-bottom: 8px;
257
+ padding: 7px 10px;
258
+ text-transform: uppercase;
259
+ }
260
+ .player-sprite {
261
+ background-repeat: no-repeat;
262
+ background-size: 400% 100%;
263
+ filter: drop-shadow(0 16px 9px rgba(0,0,0,.55));
264
+ height: 142px;
265
+ image-rendering: pixelated;
266
+ width: 126px;
267
+ }
268
+ .cascade-banner {
269
+ animation: slide-in .28s ease-out;
270
+ background: linear-gradient(90deg, rgba(255,107,95,.96), rgba(118,52,83,.94));
271
+ border: 1px solid rgba(255,255,255,.2);
272
+ box-shadow: 0 10px 35px rgba(0,0,0,.35);
273
+ color: #fff;
274
+ left: 50%;
275
+ max-width: 70%;
276
+ padding: 10px 18px;
277
+ position: absolute;
278
+ text-align: center;
279
+ top: 49%;
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 {
286
+ backdrop-filter: blur(15px);
287
+ background: rgba(7,10,18,.94);
288
+ border: 1px solid rgba(255,255,255,.18);
289
+ border-top: 4px solid var(--gold);
290
+ border-radius: 16px;
291
+ inset: 6%;
292
+ overflow: auto;
293
+ padding: clamp(20px, 4vw, 42px);
294
+ position: absolute;
295
+ z-index: 4;
296
+ }
297
+ .report-card h2 { color: var(--gold) !important; font: clamp(1.8rem, 5vw, 3.7rem)/.95 "Arial Black", Impact, sans-serif; letter-spacing: -.055em; margin: 8px 0 18px; text-transform: uppercase; }
298
+ .report-card p { color: #e7e2d5 !important; line-height: 1.62; max-width: 850px; }
299
+ .report-card details { color: #aaa59a; margin-top: 18px; }
300
+ .report-card pre { white-space: pre-wrap; }
301
+ .choices-shell {
302
+ background: rgba(8,11,20,.78);
303
+ border: 1px solid rgba(255,255,255,.12);
304
+ border-radius: 18px;
305
+ margin-top: 14px;
306
+ padding: 14px;
307
+ }
308
+ .choice-heading { align-items: center; display: flex; justify-content: space-between; margin: 2px 2px 11px; }
309
+ .choice-heading b { color: #fff; font-family: "Arial Black", Impact, sans-serif; letter-spacing: .02em; text-transform: uppercase; }
310
+ .choice-heading span { color: #827e74; font: .66rem ui-monospace, monospace; }
311
+ #choice-cards label {
312
+ background: rgba(255,255,255,.035) !important;
313
+ border: 1px solid rgba(255,255,255,.12) !important;
314
+ border-radius: 10px !important;
315
+ margin: 6px 0 !important;
316
+ padding: 11px 12px !important;
317
+ transition: .16s ease;
318
+ }
319
+ #choice-cards label:hover { background: rgba(255,200,87,.08) !important; border-color: rgba(255,200,87,.55) !important; transform: translateX(3px); }
320
+ #choice-cards label:has(input:checked) { background: rgba(255,200,87,.13) !important; border-color: var(--gold) !important; }
321
+ #path-options label, #persona-options label {
322
+ background: rgba(255,255,255,.045) !important;
323
+ border: 1px solid rgba(255,255,255,.11) !important;
324
+ color: #e6e1d4 !important;
325
+ }
326
+ #path-options label:has(input:checked), #persona-options label:has(input:checked) {
327
+ background: rgba(255,200,87,.12) !important;
328
+ border-color: rgba(255,200,87,.7) !important;
329
+ }
330
+ #detected-paths { color: var(--cyan) !important; opacity: .74; }
331
+ #reset-button {
332
+ background: transparent !important;
333
+ border: 1px solid rgba(255,255,255,.15) !important;
334
+ color: #aaa59a !important;
335
+ margin-top: 11px;
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; }
342
+ .game-frame { aspect-ratio: auto; min-height: 660px; }
343
+ .stage-top { display: block; }
344
+ .scenario-card { max-width: 100%; }
345
+ .hud { margin-top: 10px; width: 100%; }
346
+ .metric-row { display: inline-block; margin: 6px 1.2%; width: 46%; }
347
+ .stage-bottom { padding: 0 15px 17px; }
348
+ .persona-bubble { max-width: 74%; }
349
+ .player-sprite { height: 105px; width: 93px; }
350
+ .state-pill { display: none; }
351
+ }
352
+ """
353
+
354
+
355
+ @lru_cache(maxsize=32)
356
+ def image_data_uri(path_text: str) -> str:
357
+ path = Path(path_text)
358
+ mime = mimetypes.guess_type(path.name)[0] or "image/png"
359
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
360
+ return f"data:{mime};base64,{encoded}"
361
+
362
+
363
+ def parse_paths(dilemma: str) -> tuple[gr.Radio, str]:
364
+ path_a, path_b = split_dilemma(dilemma)
365
+ return (
366
+ gr.Radio(choices=[(path_a, "A"), (path_b, "B")], value="A", label="Choose the future to enter"),
367
+ f"PATHS DETECTED: {path_a.upper()} / {path_b.upper()}",
368
+ )
369
+
370
+
371
+ def begin(dilemma: str, chosen_key: str, calibration: str, persona: str):
372
+ if len(dilemma.strip()) < 5:
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"),
382
+ "",
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):
391
+ if session is None:
392
+ raise gr.Error("Start a simulation first.")
393
+ custom = custom_choice.strip()
394
+ if not custom and choice_value is None:
395
+ raise gr.Error("Choose an option or write your own move.")
396
+ result = choose(session, int(choice_value or 0), custom)
397
+ if result["complete"]:
398
+ return (
399
+ session,
400
+ game_html(
401
+ session,
402
+ reaction=result.get("reaction", ""),
403
+ cascade=result.get("cascade"),
404
+ report=result["report"],
405
+ ),
406
+ gr.Radio(choices=[], value=None),
407
+ "",
408
+ gr.Group(visible=True),
409
+ gr.Column(visible=False),
410
+ gr.Group(visible=False),
411
+ gr.Button(interactive=False),
412
+ )
413
+ node = result["node"]
414
+ return (
415
+ session,
416
+ game_html(
417
+ session,
418
+ node,
419
+ reaction=result.get("reaction", ""),
420
+ cascade=result.get("cascade"),
421
+ ),
422
+ gr.Radio(choices=_choice_options(node), value=None, label="Choose your move"),
423
+ "",
424
+ gr.Group(visible=False),
425
+ gr.Column(visible=True),
426
+ gr.Group(visible=False),
427
+ gr.Button(interactive=True),
428
+ )
429
+
430
+
431
+ def reset():
432
+ return (
433
+ None,
434
+ "",
435
+ gr.Radio(choices=[], value=None),
436
+ "",
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,
447
+ reaction: str = "",
448
+ cascade: dict[str, str] | None = None,
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",
459
+ "scenario": "",
460
+ "generation_source": "causal ledger",
461
+ }
462
+
463
+ cascade_markup = ""
464
+ if cascade:
465
+ cascade_markup = f"""
466
+ <div class="cascade-banner">
467
+ <b>{html.escape(cascade['label'])}</b>
468
+ <span>{html.escape(cascade['memory'])} {html.escape(cascade['effect'])}</span>
469
+ </div>
470
+ """
471
+
472
+ persona_text = reaction or (
473
+ f"I'll stay with you through this version of {session.chosen_path}. "
474
+ "Pay attention to what each choice costs, not only what it promises."
475
+ )
476
+ persona_markup = f"""
477
+ <div class="persona-bubble">
478
+ <img src="{portrait}" alt="{html.escape(session.persona)}">
479
+ <div class="persona-copy">
480
+ <b>{html.escape(session.persona)}</b>
481
+ <p>{html.escape(persona_text)}</p>
482
+ </div>
483
+ </div>
484
+ """
485
+
486
+ report_markup = report_html(report) if report else ""
487
+ return f"""
488
+ <div class="game-frame">
489
+ <div class="world-bg" style="background-image:url('{background}')"></div>
490
+ <div class="world-shade"></div>
491
+ <div class="stage-top">
492
+ <section class="scenario-card">
493
+ <div class="scene-meta">{html.escape(node['month_label'])} / {html.escape(node['node_theme'])}</div>
494
+ <h2>{html.escape(session.chosen_path)}</h2>
495
+ <p>{html.escape(node['scenario'])}</p>
496
+ <small class="source-line">SOURCE: {html.escape(node.get('generation_source', 'deterministic')).upper()}</small>
497
+ </section>
498
+ {metrics_html(session)}
499
+ </div>
500
+ {cascade_markup}
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>
508
+ {report_markup}
509
+ <div class="scanlines"></div>
510
+ </div>
511
+ """
512
+
513
+
514
+ def metrics_html(session: SimulationSession) -> str:
515
+ labels = {
516
+ "financial_security": "Financial",
517
+ "creative_fulfillment": "Fulfillment",
518
+ "social_validation": "Validation",
519
+ "stress": "Stress",
520
+ "family_satisfaction": "Family",
521
+ }
522
+ rows = []
523
+ for key in METRICS:
524
+ value = session.world_state[key]
525
+ rows.append(
526
+ f"""
527
+ <div class="metric-row {'stress' if key == 'stress' else ''}">
528
+ <div class="metric-label"><span>{labels[key]}</span><span>{value}</span></div>
529
+ <div class="metric-track"><div class="metric-fill" style="width:{value}%"></div></div>
530
+ </div>
531
+ """
532
+ )
533
+ return f"<aside class='hud'><div class='hud-title'>LIVE WORLD STATE</div>{''.join(rows)}</aside>"
534
+
535
+
536
+ def report_html(report: dict[str, Any]) -> str:
537
+ ledger = html.escape(
538
+ json.dumps(
539
+ {"facts": report["facts"], "obligations": report["obligations"]},
540
+ indent=2,
541
+ )
542
+ )
543
+ return f"""
544
+ <section class="report-card">
545
+ <div class="eyebrow">Simulation complete / causal reflection</div>
546
+ <h2>{html.escape(report['archetype'])}</h2>
547
+ <p>{html.escape(report['summary'])}</p>
548
+ <p>{html.escape(report['honest_mirror'])}</p>
549
+ <details><summary>Open the causal ledger</summary><pre>{ledger}</pre></details>
550
+ </section>
551
+ """
552
+
553
+
554
+ def _choice_options(node: dict[str, Any]) -> list[tuple[str, str]]:
555
+ return [(choice["text"], str(index)) for index, choice in enumerate(node["choices"])]
556
+
557
+
558
+ theme = gr.themes.Base(
559
+ primary_hue="orange",
560
+ neutral_hue="slate",
561
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
562
+ ).set(
563
+ body_background_fill="#070a12",
564
+ block_background_fill="#0d1120",
565
+ block_border_color="rgba(255,255,255,.12)",
566
+ input_background_fill="#080b15",
567
+ input_border_color="rgba(255,255,255,.14)",
568
+ )
569
+
570
+ with gr.Blocks(css=CSS, theme=theme, title="LifeChoice Simulator") as demo:
571
+ session_state = gr.State()
572
+ gr.HTML(
573
+ f"""
574
+ <header class="app-header">
575
+ <div class="brand-lockup">
576
+ <div class="eyebrow">A causal future simulator</div>
577
+ <h1>Life<span>Choice</span></h1>
578
+ <p>Enter one road not taken. Make decisions inside it. Watch the world remember.</p>
579
+ </div>
580
+ <div class="model-chip">{MODEL_ID} / 7B</div>
581
+ </header>
582
+ """
583
+ )
584
+
585
+ with gr.Group(elem_classes="setup-card") as setup_group:
586
+ with gr.Row(equal_height=True):
587
+ with gr.Column(scale=5):
588
+ gr.HTML(
589
+ """
590
+ <section class="setup-intro">
591
+ <div class="eyebrow">New simulation</div>
592
+ <h2>Try the future<br>before it happens.</h2>
593
+ <p>This is not a chatbot. It is an eight-node causal simulation with persistent metrics, delayed consequences, and a world that changes with your decisions.</p>
594
+ <div class="feature-list">
595
+ <div class="feature"><b>01</b> Immediate first scene</div>
596
+ <div class="feature"><b>02</b> Choices produce distinct state changes</div>
597
+ <div class="feature"><b>03</b> Your persona remembers what happened</div>
598
+ </div>
599
+ </section>
600
+ """
601
+ )
602
+ with gr.Column(scale=6, elem_classes="form-panel"):
603
+ dilemma = gr.Textbox(
604
+ label="The fork in the road",
605
+ placeholder="Artist vs Software Engineer",
606
+ value="MTech vs Software Job",
607
+ )
608
+ path_choice = gr.Radio(
609
+ choices=[("MTech", "A"), ("Software Job", "B")],
610
+ value="A",
611
+ label="Choose the future to enter",
612
+ elem_id="path-options",
613
+ )
614
+ detected = gr.Markdown(
615
+ "PATHS DETECTED: MTECH / SOFTWARE JOB",
616
+ elem_classes="eyebrow",
617
+ elem_id="detected-paths",
618
+ )
619
+ calibration = gr.Textbox(
620
+ label="What makes this decision real?",
621
+ placeholder="A pressure, deadline, responsibility, or proof point...",
622
+ value="My family needs income soon, but a professor is already interested in my research.",
623
+ lines=2,
624
+ )
625
+ persona = gr.Radio(
626
+ choices=["Family", "Friend", "Mentor", "Partner", "Inner Voice"],
627
+ value="Mentor",
628
+ label="Choose the voice that follows you",
629
+ elem_id="persona-options",
630
+ )
631
+ begin_btn = gr.Button("Enter this future", variant="primary", elem_id="begin-button")
632
+ gr.HTML(
633
+ "<p class='microcopy'>Reflective simulation only. Not medical, legal, financial, or professional advice. The first scene starts immediately while later scenes prepare in the background.</p>"
634
+ )
635
+
636
+ with gr.Group(visible=False) as game_group:
637
+ game_board = gr.HTML()
638
+ with gr.Column(visible=False, elem_classes="choices-shell") as play_column:
639
+ gr.HTML("<div class='choice-heading'><b>What do you do?</b><span>YOUR DECISION CHANGES THE STATE</span></div>")
640
+ choices = gr.Radio(choices=[], show_label=False, elem_id="choice-cards")
641
+ custom = gr.Textbox(
642
+ label="Or make your own move",
643
+ placeholder="Describe one specific action...",
644
+ lines=1,
645
+ )
646
+ choose_btn = gr.Button("Commit choice", variant="primary", elem_id="commit-button")
647
+ with gr.Group(visible=False) as report_group:
648
+ gr.Markdown(
649
+ "The report reflects patterns in your causal ledger. It does not predict your future or tell you what to do."
650
+ )
651
+ reset_btn = gr.Button("Start a different simulation", variant="secondary", elem_id="reset-button")
652
+
653
+ dilemma.change(parse_paths, dilemma, [path_choice, detected], show_progress="hidden")
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],
662
+ [session_state, game_board, choices, custom, report_group, play_column, setup_group, choose_btn],
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()
assets/characters/character.png ADDED
assets/environments/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
assets/environments/campus_academic_stable.png ADDED

Git LFS Details

  • SHA256: 4fb0179e2259245180939ff4fde677a2b155b75e370092ea604f869dddf34083
  • Pointer size: 132 Bytes
  • Size of remote file: 2.82 MB
assets/environments/campus_academic_struggling.png ADDED

Git LFS Details

  • SHA256: 807a1bf15d9a3604c68e06b860506ec20f31dcb9ee3ae83c6945bebc49a5ff62
  • Pointer size: 132 Bytes
  • Size of remote file: 2.53 MB
assets/environments/campus_academic_thriving.png ADDED

Git LFS Details

  • SHA256: bd61be2c231c5542b0325f4ffd46cf787bc63ae60803fc59fc8ea54a3157cb46
  • Pointer size: 132 Bytes
  • Size of remote file: 2.87 MB
assets/environments/corporate_stable.png ADDED

Git LFS Details

  • SHA256: 383403e43f4923b39bb99bfd6ec7f7ffa6458d262bf4d77b1a5198687269f65e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.39 MB
assets/environments/corporate_struggling.png ADDED

Git LFS Details

  • SHA256: cc3dfaec67024e8d30ceb8a6cf4377766b72a84a8380a0209b51dfd16b4cd95d
  • Pointer size: 132 Bytes
  • Size of remote file: 2.01 MB
assets/environments/corporate_thriving.png ADDED

Git LFS Details

  • SHA256: 8c0378204817d1326b015f3477edd9b808cde7cb779ba03135c5d54ed0415e7b
  • Pointer size: 132 Bytes
  • Size of remote file: 2.52 MB
assets/environments/medical_clinical_stable.png ADDED

Git LFS Details

  • SHA256: 3087df7bf3016cf6b4c588b1413438a536be0ef08dfb30f292ce8d3c2f661fa1
  • Pointer size: 132 Bytes
  • Size of remote file: 2.33 MB
assets/environments/medical_clinical_struggling.png ADDED

Git LFS Details

  • SHA256: 6a5cb416c91556914d80689a80769620b17515fefb47874042055daadf5715c0
  • Pointer size: 132 Bytes
  • Size of remote file: 2.33 MB
assets/environments/medical_clinical_thriving.png ADDED

Git LFS Details

  • SHA256: 30a14c6e937281ae19706055df33b61818490fd7535c52c5dd49873cccf06a7b
  • Pointer size: 132 Bytes
  • Size of remote file: 2.51 MB
assets/environments/startup_chaotic_stable.png ADDED

Git LFS Details

  • SHA256: 0771d90ca4db8ef062ae5398f1138572cc633b27657e67823bd523ae419b9726
  • Pointer size: 132 Bytes
  • Size of remote file: 2.41 MB
assets/environments/startup_chaotic_struggling.png ADDED

Git LFS Details

  • SHA256: 3888f0327bccbb774ac17ae1bc05d0cc515ca5b7e96ea65df88a547811be5036
  • Pointer size: 132 Bytes
  • Size of remote file: 1.77 MB
assets/environments/startup_chaotic_thriving.png ADDED

Git LFS Details

  • SHA256: d8981ba14b1df02e6c8357b4fa23cb11f81dac7fd5cc83395165402b2dd9322d
  • Pointer size: 132 Bytes
  • Size of remote file: 2.61 MB
assets/environments/studio_creative_stable.png ADDED

Git LFS Details

  • SHA256: 1e3f4cb18ea86ccd7e6c0853cb96941a3ffeab3bcdd1ba46d43d2d7268c96ee8
  • Pointer size: 132 Bytes
  • Size of remote file: 2.02 MB
assets/environments/studio_creative_struggling.png ADDED

Git LFS Details

  • SHA256: 6a4b47d6bae5e91b2778e4438056d5b8d8949d5d76b8a040e09aa77ded1ae38b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.85 MB
assets/environments/studio_creative_thriving.png ADDED

Git LFS Details

  • SHA256: cda2c4bbf92181355722d7ade410401ae1e118e52138982fe6ddef5accdbfd65
  • Pointer size: 132 Bytes
  • Size of remote file: 2.17 MB
assets/environments/tech_office_stable.png ADDED

Git LFS Details

  • SHA256: 874c281c4639b225ff7044b621f8679ad7a3981400a8114229fbd0c51fe9f977
  • Pointer size: 132 Bytes
  • Size of remote file: 2.21 MB
assets/environments/tech_office_struggling.png ADDED

Git LFS Details

  • SHA256: a6ac13544dbff81ccf7473b79f6d9727bc40d0de140b6126bc4ec618d56f7de7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.7 MB
assets/environments/tech_office_thriving.png ADDED

Git LFS Details

  • SHA256: f0a4b1eb0576bb916f97b9c8bf7e44bd08e3a4a02c20c569c83f8b744fa3a893
  • Pointer size: 132 Bytes
  • Size of remote file: 2.47 MB
assets/personas/family.webp ADDED
assets/personas/friend.webp ADDED
assets/personas/mentor.webp ADDED
docs/__pycache__/create_demo_video.cpython-312.pyc ADDED
Binary file (5.79 kB). View file
 
docs/architecture.svg ADDED
docs/compliance-checklist.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Small Hackathon Compliance
2
+
3
+ Verified on June 13, 2026.
4
+
5
+ | Requirement | Status | Evidence |
6
+ |---|---|---|
7
+ | Use a model under 32B | Complete | Runtime model is `Qwen/Qwen2.5-7B-Instruct` at 7.616B parameters |
8
+ | Keep every fallback under 32B | Complete | No secondary model is configured; fallback behavior is deterministic Python |
9
+ | Every model is under 32B | Complete | Hub metadata: 7,615,616,512 parameters; `MODEL_MANIFEST.json`; compliance tests |
10
+ | Deploy as Gradio Space | Blocked externally | Gradio app and guarded deploy script are complete |
11
+ | Host under `build-small-hackathon` | Blocked externally | `Ajeya95` lacks an active writable CLI login/organization Space-creation permission |
12
+ | Public without local setup | Pending Space deployment | Space frontmatter, dependencies, and deterministic no-token mode are complete |
13
+ | Public demo video | Complete | `docs/lifechoice-demo.mp4`, publicly served from GitHub |
14
+ | Public social media post | Blocked externally | Final copy is in `docs/social-post.md`; installed connectors cannot publish |
15
+ | Demo video link in README | Complete | README links the public GitHub-hosted MP4 |
16
+ | Social post link in README | Pending publication | Placeholder remains explicit |
17
+ | Hackathon frontmatter tags | Complete | README YAML frontmatter |
18
+ | Short top description | Complete | First paragraph under title |
19
+ | Screenshots/GIFs | Complete | Four browser-verified screenshots |
20
+ | Architecture diagram image | Complete | `docs/architecture.svg` |
21
+ | Built with Codex section | Complete | README section |
22
+ | Codex-attributed commits | Complete | Commits `6929152` and `a340a52` |
23
+ | AI safety/disclaimer | Complete | UI and README |
24
+ | Explain why not a chatbot | Complete | README architecture explanation |
25
+ | Models and stack documented | Complete | README and model manifest |
26
+ | Test full Space incognito | Pending Space deployment | Full local browser flow verified; public Space cannot yet exist under target org |
27
+ | Verify no model violates rule | Complete | Automated source scan and manifest tests |
28
+ | Target requested categories | Complete | README lists Thousand Token Wood, Best Agent, Best Demo, OpenAI Prize |
29
+ | Modal credits | Not targeted | No claim made |
30
+ | Off Brand | Not targeted | Custom UI exists, but no category claim made |
31
+ | Tiny Titan | Not targeted | 7.616B model, so no claim made |
32
+ | OpenBMB | Not targeted | No MiniCPM claim |
33
+ | NVIDIA | Not targeted | No Nemotron claim |
34
+
35
+ ## Verification Commands
36
+
37
+ ```bash
38
+ pytest -q
39
+ python scripts/deploy_space.py
40
+ ```
41
+
42
+ The deployment command intentionally refuses to use any Hub identity other than `Ajeya95`.
docs/create_demo_video.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import imageio.v2 as imageio
4
+ import numpy as np
5
+ from PIL import Image, ImageDraw, ImageFont
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parent
9
+ OUT = ROOT / "lifechoice-demo.mp4"
10
+ SIZE = (1280, 720)
11
+ FPS = 12
12
+
13
+
14
+ def font(size: int, bold: bool = False):
15
+ names = [
16
+ "C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf",
17
+ "C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf",
18
+ ]
19
+ for name in names:
20
+ if Path(name).exists():
21
+ return ImageFont.truetype(name, size)
22
+ return ImageFont.load_default()
23
+
24
+
25
+ def title_card(title: str, subtitle: str) -> Image.Image:
26
+ image = Image.new("RGB", SIZE, "#080812")
27
+ draw = ImageDraw.Draw(image)
28
+ draw.rounded_rectangle((80, 90, 1200, 630), radius=32, fill="#141128", outline="#7568ff", width=3)
29
+ draw.text((130, 155), "BUILD SMALL HACKATHON", fill="#a99fff", font=font(24, True))
30
+ draw.text((130, 245), title, fill="white", font=font(62, True))
31
+ draw.multiline_text((130, 350), subtitle, fill="#d3cee7", font=font(30), spacing=12)
32
+ return image
33
+
34
+
35
+ def screenshot_card(filename: str, heading: str, caption: str, crop_top: int = 0) -> Image.Image:
36
+ source = Image.open(ROOT / "screenshots" / filename).convert("RGB")
37
+ source = source.crop((0, crop_top, source.width, min(source.height, crop_top + int(source.width * 0.56))))
38
+ source.thumbnail((1120, 540), Image.Resampling.LANCZOS)
39
+ image = Image.new("RGB", SIZE, "#080812")
40
+ draw = ImageDraw.Draw(image)
41
+ draw.text((70, 34), heading, fill="white", font=font(34, True))
42
+ x = (SIZE[0] - source.width) // 2
43
+ image.paste(source, (x, 100))
44
+ draw.rounded_rectangle((60, 640, 1220, 700), radius=16, fill="#18152e")
45
+ draw.text((90, 656), caption, fill="#eeeaff", font=font(22))
46
+ return image
47
+
48
+
49
+ def write_frames(writer, image: Image.Image, seconds: float) -> None:
50
+ frame = np.asarray(image)
51
+ for _ in range(round(FPS * seconds)):
52
+ writer.append_data(frame)
53
+
54
+
55
+ def main() -> None:
56
+ cards = [
57
+ (title_card("LifeChoice Simulator", "A causal decision simulator, not another chatbot.\nQwen2.5-7B + Gradio + deterministic state."), 3.0),
58
+ (screenshot_card("onboarding.png", "Start in seconds", "One concrete calibration answer takes you directly into the simulation."), 4.0),
59
+ (screenshot_card("simulation.png", "Immediate first scene", "The opening is deterministic; later nodes prefetch in the background."), 4.0),
60
+ (screenshot_card("cascade.png", "Choices return later", "Facts, obligations, and closed options make branches materially different.", 500), 4.0),
61
+ (screenshot_card("report.png", "A report grounded in behavior", "Five metrics, three cascade moments, and a bounded causal ledger.", 500), 4.0),
62
+ (title_card("Small model. Real state.", "7.616B parameters. No 32B+ fallback.\nBuilt with Codex for the Build Small Hackathon."), 3.0),
63
+ ]
64
+ with imageio.get_writer(
65
+ OUT,
66
+ fps=FPS,
67
+ codec="libx264",
68
+ quality=8,
69
+ pixelformat="yuv420p",
70
+ macro_block_size=1,
71
+ ) as writer:
72
+ for card, seconds in cards:
73
+ write_frames(writer, card, seconds)
74
+ print(OUT)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
docs/demo-script.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Public Demo Video Script
2
+
3
+ Target length: 75-90 seconds.
4
+
5
+ 1. Open the public Hugging Face Space and show that no local setup is required.
6
+ 2. Enter `MTech vs Software Job`.
7
+ 3. Highlight the single calibration answer and immediate first scene.
8
+ 4. Choose a high-risk option and show all five metrics and the environment update.
9
+ 5. Explain the causal ledger: facts, obligations, and closed options.
10
+ 6. Advance to the node-2 echo and node-5 delayed consequence.
11
+ 7. Show a different choice changing the later scene.
12
+ 8. Finish on the report and safety disclaimer.
13
+ 9. Close with: “One 7B model, bounded context, deterministic state, and a playable future rather than another chatbot.”
14
+
15
+ Suggested title: `LifeChoice Simulator | Build Small Hackathon Demo`
16
+
17
+ Suggested description:
18
+
19
+ > LifeChoice Simulator turns a real dilemma into a causal, metric-driven future. Built with Gradio, Qwen2.5-7B-Instruct, Hugging Face, and Codex for the Build Small Hackathon.
20
+
docs/lifechoice-demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8e21579f0cc2a5bd22a36aaf456315ae09fb255452eea5f1663253ad04eba5a7
3
+ size 696040
docs/screenshots/cascade.png ADDED

Git LFS Details

  • SHA256: b8aa89b0f9b20d60ef1148fed8eaa825a302d3bb3652d22b9eaa28922c64249f
  • Pointer size: 131 Bytes
  • Size of remote file: 705 kB
docs/screenshots/onboarding.png ADDED

Git LFS Details

  • SHA256: 3cecd4803196804ae9fac3e294e6fec2f0bc4b569316f08809676297a953344a
  • Pointer size: 131 Bytes
  • Size of remote file: 329 kB
docs/screenshots/report.png ADDED

Git LFS Details

  • SHA256: ac328333911dd328a6d2f72564b66be902cd8d5035ec55fddbe4f4476259ad6d
  • Pointer size: 131 Bytes
  • Size of remote file: 383 kB
docs/screenshots/simulation.png ADDED

Git LFS Details

  • SHA256: 80f4a32d45e62802a4410af1b6667cda834b20e5d73aaa1276c5777490228b6d
  • Pointer size: 131 Bytes
  • Size of remote file: 702 kB
docs/social-post.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Social Post Draft
2
+
3
+ I built **LifeChoice Simulator** for the Hugging Face Build Small Hackathon.
4
+
5
+ Instead of chatting about a difficult decision, you step into one path and play through eight consequential moments. Every choice changes five metrics, creates durable facts and obligations, alters the visual world, and can return later as a cascade.
6
+
7
+ The agent uses **Qwen2.5-7B-Instruct**, bounded context, strict validation, and deterministic fallbacks. No model is 32B or larger.
8
+
9
+ Built with Gradio, Hugging Face, and Codex.
10
+
11
+ Live Space: [ADD PUBLIC SPACE URL]
12
+ Code: [ADD PUBLIC GITHUB URL]
13
+ Demo: [ADD PUBLIC VIDEO URL]
14
+
15
+ #BuildSmall #HuggingFace #Gradio #AI #Codex
16
+
lifechoice_engine.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ import re
7
+ import threading
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"
14
+ MODEL_PARAMETERS_B = 7
15
+ METRICS = (
16
+ "financial_security",
17
+ "creative_fulfillment",
18
+ "social_validation",
19
+ "stress",
20
+ "family_satisfaction",
21
+ )
22
+ MONTHS = ("Month 1", "Month 2", "Month 3", "Month 4", "Month 5", "Month 6", "Month 8", "Month 10")
23
+ THEMES = (
24
+ "first commitment",
25
+ "money pressure",
26
+ "social comparison",
27
+ "identity friction",
28
+ "family pressure",
29
+ "delayed consequence",
30
+ "survival versus meaning",
31
+ "final reckoning",
32
+ )
33
+ EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="lifechoice")
34
+
35
+
36
+ @dataclass
37
+ class SimulationSession:
38
+ dilemma: str
39
+ chosen_path: str
40
+ unchosen_path: str
41
+ calibration: str
42
+ persona: str
43
+ environment_category: str
44
+ world_state: dict[str, int]
45
+ characters: dict[str, dict[str, str]]
46
+ current_node: int = 0
47
+ history: list[dict[str, Any]] = field(default_factory=list)
48
+ facts: list[str] = field(default_factory=list)
49
+ obligations: list[str] = field(default_factory=list)
50
+ closed_options: list[str] = field(default_factory=list)
51
+ generated_nodes: dict[int, dict[str, Any]] = field(default_factory=dict)
52
+ pending_nodes: dict[int, Future] = field(default_factory=dict, repr=False)
53
+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
54
+
55
+
56
+ def split_dilemma(dilemma: str) -> tuple[str, str]:
57
+ text = " ".join(dilemma.strip().split())
58
+ for pattern in (r"\s+vs\.?\s+", r"\s+versus\s+", r"\s*/\s*", r"\s+or\s+"):
59
+ parts = re.split(pattern, text, maxsplit=1, flags=re.IGNORECASE)
60
+ if len(parts) == 2 and all(parts):
61
+ return _label(parts[0]), _label(parts[1])
62
+ return _label(text) or "Path A", "Safer Alternative"
63
+
64
+
65
+ def start_session(
66
+ dilemma: str,
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)
73
+ session = SimulationSession(
74
+ dilemma=dilemma.strip(),
75
+ chosen_path=chosen,
76
+ unchosen_path=unchosen,
77
+ calibration=calibration.strip(),
78
+ persona=persona,
79
+ environment_category=infer_environment(chosen),
80
+ world_state=derive_initial_world_state(dilemma, calibration),
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
+
88
+ def choose(session: SimulationSession, choice_index: int, custom_text: str = "") -> dict[str, Any]:
89
+ node = current_node(session)
90
+ if custom_text.strip():
91
+ choice = _custom_choice(node, custom_text.strip())
92
+ choice_index = -1
93
+ else:
94
+ choice_index = max(0, min(choice_index, len(node["choices"]) - 1))
95
+ choice = node["choices"][choice_index]
96
+
97
+ for key in METRICS:
98
+ session.world_state[key] = _clamp(session.world_state[key] + int(choice["delta"].get(key, 0)))
99
+
100
+ record = {
101
+ "node_index": session.current_node,
102
+ "month_label": node["month_label"],
103
+ "theme": node["node_theme"],
104
+ "scenario": node["scenario"],
105
+ "choice_index": choice_index,
106
+ "choice_text": choice["text"],
107
+ "delta": copy.deepcopy(choice["delta"]),
108
+ "world_state_after": copy.deepcopy(session.world_state),
109
+ }
110
+ session.history.append(record)
111
+ _merge_unique(session.facts, choice.get("facts_created", []), limit=10)
112
+ _merge_unique(session.obligations, choice.get("obligations", []), limit=6)
113
+ _merge_unique(session.closed_options, choice.get("closed_options", []), limit=6)
114
+ session.current_node += 1
115
+
116
+ reaction = persona_reaction(session, record)
117
+ cascade = cascade_for(session, record)
118
+ if session.current_node >= len(MONTHS):
119
+ return {"complete": True, "record": record, "reaction": reaction, "cascade": cascade, "report": build_report(session)}
120
+
121
+ next_node = _consume_or_build(session, session.current_node)
122
+ prefetch_node(session, session.current_node + 1)
123
+ return {
124
+ "complete": False,
125
+ "record": record,
126
+ "reaction": reaction,
127
+ "cascade": cascade,
128
+ "node": next_node,
129
+ }
130
+
131
+
132
+ def current_node(session: SimulationSession) -> dict[str, Any]:
133
+ return session.generated_nodes[session.current_node]
134
+
135
+
136
+ def prefetch_node(session: SimulationSession, index: int) -> None:
137
+ if index >= len(MONTHS) or index in session.generated_nodes or index in session.pending_nodes:
138
+ return
139
+ snapshot = compact_context(session)
140
+ session.pending_nodes[index] = EXECUTOR.submit(build_node_from_context, snapshot, index, True)
141
+
142
+
143
+ def compact_context(session: SimulationSession) -> dict[str, Any]:
144
+ return {
145
+ "dilemma": session.dilemma,
146
+ "chosen_path": session.chosen_path,
147
+ "unchosen_path": session.unchosen_path,
148
+ "calibration": session.calibration[:360],
149
+ "persona": session.persona,
150
+ "world_state": copy.deepcopy(session.world_state),
151
+ "characters": copy.deepcopy(session.characters),
152
+ "facts": session.facts[-8:],
153
+ "obligations": session.obligations[-5:],
154
+ "closed_options": session.closed_options[-5:],
155
+ "recent_choices": [
156
+ {"month": item["month_label"], "choice": item["choice_text"]}
157
+ for item in session.history[-3:]
158
+ ],
159
+ }
160
+
161
+
162
+ def build_node(session: SimulationSession, index: int, use_llm: bool = True) -> dict[str, Any]:
163
+ return build_node_from_context(compact_context(session), index, use_llm)
164
+
165
+
166
+ def build_node_from_context(context: dict[str, Any], index: int, use_llm: bool = True) -> dict[str, Any]:
167
+ fallback = deterministic_node(context, index)
168
+ if not use_llm:
169
+ return fallback
170
+ generated = _generate_node_with_hf(context, index)
171
+ return validate_node(generated, fallback, context)
172
+
173
+
174
+ def deterministic_node(context: dict[str, Any], index: int) -> dict[str, Any]:
175
+ chosen = context["chosen_path"]
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],
198
+ "scenario": scenarios[index],
199
+ "choices": choices,
200
+ "generation_source": "deterministic",
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"]),
223
+ ("Accept the compromise and use the stability to recover", _delta(13, -7, 5, -6, 7), ["accepted_compromise"], [], []),
224
+ ("Negotiate narrower terms that preserve both income and ownership", _delta(6, 6, 3, 4, 2), ["negotiated_middle_path"], ["deliver_negotiated_terms"], []),
225
+ ),
226
+ (
227
+ ("Show your family the real plan, including risks and deadlines", _delta(2, 2, 3, -3, 11), ["shared_full_plan"], ["report_progress"], []),
228
+ ("Hide the uncertainty until results become visible", _delta(0, 4, -2, 9, -9), ["hid_the_messy_parts"], ["maintain_the_story"], []),
229
+ ("Let family concern change the next move toward stability", _delta(8, -8, 5, -3, 12), ["family_changed_direction"], [], ["unrestricted_risk"]),
230
+ ),
231
+ (
232
+ ("Accept the returning opportunity and absorb its delayed cost", _delta(-11, 13, 9, 12, -4), ["accepted_delayed_payoff"], ["pay_delayed_cost"], []),
233
+ ("Decline because the earlier choice made the cost unsustainable", _delta(8, -10, -2, -7, 6), ["declined_delayed_payoff"], [], ["returning_opportunity"]),
234
+ ("Redesign the opportunity around the obligations you already created", _delta(4, 7, 5, 3, 4), ["redesigned_consequence"], ["honor_existing_obligations"], []),
235
+ ),
236
+ (
237
+ ("Build a financial and time floor around the meaningful work", _delta(12, 6, 2, -9, 5), ["built_sustainable_structure"], ["follow_weekly_limits"], []),
238
+ ("Push harder for visible progress and accept another intense season", _delta(-6, 10, 8, 14, -5), ["chose_intensity"], ["recover_after_push"], []),
239
+ (f"Return to {unchosen} as the main path and keep this work secondary", _delta(14, -12, 7, -5, 9), ["returned_to_alternative"], [], ["chosen_path_as_primary"]),
240
+ ),
241
+ (
242
+ ("Commit to the chosen path with explicit money, health, and relationship boundaries", _delta(8, 11, 6, -10, 7), ["committed_with_boundaries"], [], []),
243
+ (f"Choose {unchosen} and preserve the meaningful parts as a serious side practice", _delta(14, -7, 8, -7, 10), ["chose_stability_with_meaning"], [], ["chosen_path_as_primary"]),
244
+ ("Run a measured three-month experiment before using a permanent label", _delta(5, 5, 1, -2, 2), ["chose_measured_experiment"], ["define_success_metrics"], []),
245
+ ),
246
+ )
247
+ result = []
248
+ for text, delta, facts, obligations, closed in templates[index]:
249
+ result.append(
250
+ {
251
+ "text": text,
252
+ "delta": delta,
253
+ "facts_created": facts,
254
+ "obligations": obligations,
255
+ "closed_options": closed,
256
+ }
257
+ )
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
264
+ scenario = " ".join(str(data.get("scenario", "")).split())[:420]
265
+ if len(scenario) < 50 or not narrative_respects_state(scenario, context["world_state"]):
266
+ return fallback
267
+ choices = []
268
+ for index, raw in enumerate(data["choices"]):
269
+ if not isinstance(raw, dict) or len(str(raw.get("text", ""))) < 12:
270
+ return fallback
271
+ base = fallback["choices"][index]
272
+ choices.append(
273
+ {
274
+ "text": " ".join(str(raw["text"]).split())[:150],
275
+ "delta": _normalize_delta(raw.get("delta")),
276
+ "facts_created": _clean_list(raw.get("facts_created")) or base["facts_created"],
277
+ "obligations": _clean_list(raw.get("obligations")),
278
+ "closed_options": _clean_list(raw.get("closed_options")),
279
+ }
280
+ )
281
+ return {**fallback, "scenario": scenario, "choices": choices, "generation_source": "hf_llm"}
282
+
283
+
284
+ def narrative_respects_state(text: str, state: dict[str, int]) -> bool:
285
+ lower = text.lower()
286
+ if state["stress"] >= 80 and not any(word in lower for word in ("stress", "tired", "exhaust", "pressure", "overwhelm", "sleep")):
287
+ return False
288
+ if state["financial_security"] <= 25 and not any(word in lower for word in ("money", "rent", "bill", "saving", "income", "cost", "financial")):
289
+ return False
290
+ if state["family_satisfaction"] <= 25 and not any(word in lower for word in ("family", "home", "parent", "trust", "conversation")):
291
+ return False
292
+ return True
293
+
294
+
295
+ def state_facts(state: dict[str, int]) -> list[str]:
296
+ facts = []
297
+ if state["financial_security"] <= 25:
298
+ facts.append("Money is now urgent enough to constrain the available choices.")
299
+ elif state["financial_security"] >= 75:
300
+ facts.append("Savings provide room to choose without immediate panic.")
301
+ if state["stress"] >= 80:
302
+ facts.append("Exhaustion is visible and cannot be treated as background noise.")
303
+ elif state["stress"] <= 30:
304
+ facts.append("You have enough emotional room to think beyond survival.")
305
+ if state["family_satisfaction"] <= 30:
306
+ facts.append("Trust at home is strained and affects the next decision.")
307
+ if state["creative_fulfillment"] >= 75:
308
+ facts.append("The work feels meaningful enough that walking away has a real emotional cost.")
309
+ if state["social_validation"] <= 30:
310
+ facts.append("Progress is difficult to explain publicly, increasing isolation.")
311
+ while len(facts) < 2:
312
+ facts.append("The trade-off remains real: every available move protects one value by spending another.")
313
+ return facts[:2]
314
+
315
+
316
+ def persona_reaction(session: SimulationSession, record: dict[str, Any]) -> str:
317
+ index = record["node_index"]
318
+ if index not in {0, 1, 3, 5, 7} and not _threshold_crossed(record):
319
+ return ""
320
+ choice = record["choice_text"]
321
+ state = session.world_state
322
+ if session.persona == "Inner Voice":
323
+ return f"I chose to {choice.lower()}, and I can feel which cost I am pretending not to notice."
324
+ if session.persona == "Friend":
325
+ return f"I understand why you chose to {choice.lower()}. Just do not confuse momentum with proof that the pressure is sustainable."
326
+ if session.persona == "Mentor":
327
+ return f"What does choosing to {choice.lower()} protect that the safer option cannot, and what obligation did it create?"
328
+ if session.persona == "Partner":
329
+ return f"I support the reason behind this choice, but stress is at {state['stress']}; our shared plan has to include that cost."
330
+ return f"I see why you chose to {choice.lower()}, but stability and family trust are part of the decision too, not enemies of it."
331
+
332
+
333
+ def cascade_for(session: SimulationSession, record: dict[str, Any]) -> dict[str, str] | None:
334
+ index = record["node_index"]
335
+ if index not in {2, 5, 7} or not session.history:
336
+ return None
337
+ source_index = 0 if index in {2, 5} else min(3, len(session.history) - 1)
338
+ source = session.history[source_index]
339
+ labels = {2: "A small echo", 5: "The delayed consequence", 7: "The final payoff"}
340
+ return {
341
+ "label": labels[index],
342
+ "memory": f"In {source['month_label']}, you chose: {source['choice_text']}",
343
+ "effect": "That decision changed the constraints and obligations shaping this moment.",
344
+ }
345
+
346
+
347
+ def build_report(session: SimulationSession) -> dict[str, Any]:
348
+ state = session.world_state
349
+ risk_count = sum(1 for item in session.history if item["delta"].get("stress", 0) >= 7)
350
+ structure_count = sum(1 for item in session.history if item["delta"].get("financial_security", 0) >= 5)
351
+ meaning_count = sum(1 for item in session.history if item["delta"].get("creative_fulfillment", 0) >= 5)
352
+ if meaning_count > structure_count:
353
+ archetype = "Meaning Builder"
354
+ elif structure_count > meaning_count:
355
+ archetype = "Practical Architect"
356
+ else:
357
+ archetype = "Measured Explorer"
358
+ return {
359
+ "archetype": archetype,
360
+ "summary": (
361
+ f"You made {meaning_count} meaning-protecting moves, {structure_count} stabilizing moves, "
362
+ f"and {risk_count} high-pressure moves while testing {session.chosen_path}."
363
+ ),
364
+ "honest_mirror": (
365
+ f"Your choices show that {session.chosen_path} is attractive only when it can become livable. "
366
+ f"You did not simply choose courage or safety; you repeatedly negotiated between identity, money, and relationships. "
367
+ f"The causal record matters: {', '.join(session.facts[-3:]) or 'you kept options open'}. "
368
+ f"Your next real-world experiment should test those obligations without treating this simulation as a prediction."
369
+ ),
370
+ "world_state": copy.deepcopy(state),
371
+ "facts": session.facts,
372
+ "obligations": session.obligations,
373
+ "model": MODEL_ID,
374
+ }
375
+
376
+
377
+ def environment_image(session: SimulationSession) -> str:
378
+ state = environment_state(session.world_state)
379
+ path = Path(__file__).parent / "assets" / "environments"
380
+ return str(path / f"{session.environment_category}_{state}.png")
381
+
382
+
383
+ def environment_state(state: dict[str, int]) -> str:
384
+ wellbeing = (
385
+ state["financial_security"]
386
+ + state["creative_fulfillment"]
387
+ + state["social_validation"]
388
+ + state["family_satisfaction"]
389
+ + (100 - state["stress"])
390
+ ) / 5
391
+ if state["stress"] >= 82 or state["financial_security"] <= 18 or wellbeing < 38:
392
+ return "struggling"
393
+ if wellbeing >= 67 and state["stress"] <= 55:
394
+ return "thriving"
395
+ return "stable"
396
+
397
+
398
+ def infer_environment(path: str) -> str:
399
+ text = path.lower()
400
+ mapping = (
401
+ (("artist", "art", "music", "design", "film", "creative", "writer"), "studio_creative"),
402
+ (("startup", "founder", "business"), "startup_chaotic"),
403
+ (("phd", "mtech", "college", "study", "masters", "research"), "campus_academic"),
404
+ (("doctor", "medical", "neet", "hospital"), "medical_clinical"),
405
+ (("corporate", "manager", "consulting", "mba"), "corporate"),
406
+ )
407
+ for words, category in mapping:
408
+ if any(word in text for word in words):
409
+ return category
410
+ return "tech_office"
411
+
412
+
413
+ def derive_initial_world_state(dilemma: str, calibration: str) -> dict[str, int]:
414
+ state = {
415
+ "financial_security": 58,
416
+ "creative_fulfillment": 58,
417
+ "social_validation": 50,
418
+ "stress": 42,
419
+ "family_satisfaction": 50,
420
+ }
421
+ text = f"{dilemma} {calibration}".lower()
422
+ if any(word in text for word in ("rent", "loan", "salary", "money", "saving", "fees")):
423
+ state["financial_security"] -= 13
424
+ state["stress"] += 9
425
+ if any(word in text for word in ("family", "parent", "approval", "relatives")):
426
+ state["family_satisfaction"] -= 11
427
+ state["stress"] += 6
428
+ if any(word in text for word in ("passion", "meaning", "creative", "research", "freedom")):
429
+ state["creative_fulfillment"] += 10
430
+ if any(word in text for word in ("offer", "admitted", "selected", "client", "revenue")):
431
+ state["social_validation"] += 8
432
+ state["stress"] -= 3
433
+ return {key: _clamp(value) for key, value in state.items()}
434
+
435
+
436
+ def _consume_or_build(session: SimulationSession, index: int) -> dict[str, Any]:
437
+ fallback = build_node(session, index, use_llm=False)
438
+ context = compact_context(session)
439
+ future = session.pending_nodes.pop(index, None)
440
+ if future and future.done():
441
+ try:
442
+ node = validate_node(future.result(), fallback, context)
443
+ except Exception:
444
+ node = fallback
445
+ else:
446
+ node = fallback
447
+ session.generated_nodes[index] = node
448
+ return node
449
+
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
+
456
+ client = InferenceClient(model=MODEL_ID, token=token, timeout=12)
457
+ prompt = {
458
+ "task": "Generate one LifeChoice Simulator decision node as strict JSON.",
459
+ "rules": [
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],
466
+ "theme": THEMES[index],
467
+ "context": context,
468
+ "schema": {
469
+ "scenario": "string",
470
+ "choices": [
471
+ {
472
+ "text": "string",
473
+ "delta": {key: 0 for key in METRICS},
474
+ "facts_created": ["string"],
475
+ "obligations": ["string"],
476
+ "closed_options": ["string"],
477
+ }
478
+ ],
479
+ },
480
+ }
481
+ response = client.chat_completion(
482
+ messages=[{"role": "user", "content": json.dumps(prompt, ensure_ascii=True)}],
483
+ max_tokens=850,
484
+ temperature=0.55,
485
+ )
486
+ text = response.choices[0].message.content
487
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
488
+ return json.loads(match.group(0)) if match else None
489
+ except Exception:
490
+ return None
491
+
492
+
493
+ def _custom_choice(node: dict[str, Any], text: str) -> dict[str, Any]:
494
+ totals = {key: 0 for key in METRICS}
495
+ for choice in node["choices"]:
496
+ for key in METRICS:
497
+ totals[key] += int(choice["delta"].get(key, 0))
498
+ return {
499
+ "text": text[:150],
500
+ "delta": {key: round(value / len(node["choices"])) for key, value in totals.items()},
501
+ "facts_created": [f"custom_choice_{_slug(text)[:40]}"],
502
+ "obligations": ["review_custom_choice_consequences"],
503
+ "closed_options": [],
504
+ }
505
+
506
+
507
+ def _characters(chosen: str, unchosen: str) -> dict[str, dict[str, str]]:
508
+ return {
509
+ "family": {"name": "Family", "context": f"Wants {chosen} to have a credible safety floor."},
510
+ "friend": {"name": "Friend", "context": f"Chose {unchosen} and provides a living comparison."},
511
+ "mentor": {"name": "Mentor", "context": f"Five years ahead on {chosen}, including its difficult middle."},
512
+ }
513
+
514
+
515
+ def _threshold_crossed(record: dict[str, Any]) -> bool:
516
+ state = record["world_state_after"]
517
+ return state["stress"] >= 80 or state["financial_security"] <= 20 or state["family_satisfaction"] <= 25
518
+
519
+
520
+ def _delta(financial: int, creative: int, social: int, stress: int, family: int) -> dict[str, int]:
521
+ return dict(
522
+ financial_security=financial,
523
+ creative_fulfillment=creative,
524
+ social_validation=social,
525
+ stress=stress,
526
+ family_satisfaction=family,
527
+ )
528
+
529
+
530
+ def _normalize_delta(value: Any) -> dict[str, int]:
531
+ if not isinstance(value, dict):
532
+ value = {}
533
+ return {key: max(-15, min(15, int(value.get(key, 0)))) for key in METRICS}
534
+
535
+
536
+ def _clean_list(value: Any) -> list[str]:
537
+ if not isinstance(value, list):
538
+ return []
539
+ return [_slug(str(item))[:60] for item in value[:3] if str(item).strip()]
540
+
541
+
542
+ def _merge_unique(target: list[str], values: list[str], limit: int) -> None:
543
+ for value in values:
544
+ if value and value not in target:
545
+ target.append(value)
546
+ del target[:-limit]
547
+
548
+
549
+ def _slug(value: str) -> str:
550
+ return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
551
+
552
+
553
+ def _label(value: str) -> str:
554
+ cleaned = re.sub(r"^(?:i am |i'm |should i |choose |between )", "", value.strip(), flags=re.IGNORECASE)
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)))
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=5.33,<6
2
+ huggingface_hub>=0.30
3
+ pytest>=8.0