neonforestmist commited on
Commit
095660c
·
verified ·
1 Parent(s): 290b72b

Run SmolGPT-Fables v1 with the updated character Studio

Browse files

Pins the published v1 model commit, adds the shared prompt contract, keeps separate Name/About character cards, and preloads the ZeroGPU model.

README.md CHANGED
@@ -7,7 +7,7 @@ sdk: gradio
7
  sdk_version: 6.19.0
8
  app_file: app.py
9
  pinned: false
10
- python_version: "3.11"
11
  ---
12
 
13
  # SmolGPT-Fables Studio
@@ -21,11 +21,13 @@ This Studio targets the first public release, **SmolGPT-Fables v1**. Future
21
  model improvements advance as v2, v3, and later releases; the corpus generator's
22
  internal version is tracked separately.
23
 
 
 
24
  The Space never substitutes a remote language model or canned story when its model is missing. It reports the missing artifact and stops.
25
 
26
  The hosted dependency profile pins Torch 2.11.0, one of the versions supported
27
  by Hugging Face ZeroGPU. The real story-generation callback is decorated with
28
- `@spaces.GPU(duration=60)`. The write button calls that function directly as one
29
  queued event, so form preparation, generation, presentation, and download
30
  creation share one success or error result. Local environments use a no-op
31
  fallback for the decorator and continue to select CPU, CUDA, or MPS normally.
@@ -47,7 +49,7 @@ fallback for the decorator and continue to select CPU, CUDA, or MPS normally.
47
  - Keeps its schema-compatible Markdown canvas and model settings hidden from the writer.
48
  - Accepts a free-form genre or style and keeps additional story choices optional.
49
  - Converts ordinary character notes into the model's trained document structure.
50
- - Loads the Transformers-compatible custom decoder and byte-level BPE tokenizer.
51
  - Labels the result area **SmolGPT-Fables Output:** and shows only the
52
  reader-facing title and story while retaining the full internal document in
53
  hidden state.
@@ -58,71 +60,64 @@ fallback for the decorator and continue to select CPU, CUDA, or MPS normally.
58
 
59
  ## What to expect from the model
60
 
61
- The Studio currently loads a 62,930,560-parameter bootstrap checkpoint at
62
- optimizer step 3,000. Its sealed 30-prompt synthetic audit reached
63
- 100% schema validity, 86.7% exact scene-count adherence, and 97.0% macro
64
- required-anchor recall on familiar synthetic constituents. A later novel-name
65
- Studio regression failed, so this checkpoint is not tagged as v1 and is being
66
- replaced by the v5 warm-start line. The Space shows generated text without silently
67
  replacing it with another model, canned prose, or editorial rewriting.
68
 
69
- This remains an experimental small model, not a production writing assistant.
70
- The final release must pass both synthetic held-out gates and the separate
71
- Studio-native novelty gate. Those checks do not prove
72
- literary quality, factuality, safety, or broad reasoning. Review and rewrite
73
- every result before sharing or publishing it.
74
 
75
  ## Model artifact
76
 
77
- Set one of these Space variables:
 
 
 
 
78
 
79
  | Variable | Purpose |
80
  | --- | --- |
81
- | `SMOLGPT_MODEL_REPO` | Hugging Face repository containing the custom model artifact; defaults to `neonforestmist/smolgpt-fables` |
82
- | `SMOLGPT_MODEL_REVISION` | Optional branch, tag, or commit |
83
  | `SMOLGPT_MODEL_DIR` | Local artifact directory when running outside Hugging Face |
84
  | `SMOLGPT_DEVICE` | Optional `auto`, `cpu`, `cuda`, or `mps` |
85
- | `HF_TOKEN` | Optional secret for a private model repository |
86
 
87
  The model repository must contain:
88
 
89
  ```text
90
  model.safetensors safe model weights
91
- config.json architecture values and auto_map
92
- configuration_smolgpt.py Transformers configuration class
93
- modeling_smolgpt.py Transformers causal language model class
94
- tokenizer.json byte-level BPE tokenizer
95
- ```
96
-
97
- The Space resolves `SMOLGPT_MODEL_DIR` first. Otherwise it downloads `SMOLGPT_MODEL_REPO`, then loads `AutoTokenizer` and `AutoModelForCausalLM` from the resolved directory. The model and tokenizer are never fetched from another repository.
98
-
99
- The weights use Safetensors. The custom architecture is loaded with `trust_remote_code=True`, so the Space operator must choose and review the model repository. Treat `SMOLGPT_MODEL_REPO` as trusted executable code, not as untrusted user input.
100
-
101
- The checked-in fallback is `neonforestmist/smolgpt-fables`. An environment variable still overrides it.
102
-
103
- ## Publish all three repositories in two commands
104
-
105
- From the companion project root:
106
-
107
- ```bash
108
- hf auth login
109
- python scripts/publish_hf.py --namespace YOUR_HF_USERNAME
110
- ```
111
-
112
- The publisher replaces `neonforestmist` in a temporary staging copy, then creates
113
- and uploads the model, dataset, and Space repositories. Add a private model's
114
- `HF_TOKEN` through Hugging Face Settings, never in this repository.
115
-
116
- If the Hub returns HTTP 402 while creating this Gradio Space, the destination
117
- account is not currently entitled to dynamic CPU Spaces. The already uploaded
118
- model and dataset remain intact. After enabling Gradio Space hosting, retry only
119
- this artifact:
120
-
121
- ```bash
122
- python scripts/publish_hf.py --namespace YOUR_HF_USERNAME --only space
123
  ```
124
 
125
- A static Space cannot run this Python model.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  ## Local checks
128
 
 
7
  sdk_version: 6.19.0
8
  app_file: app.py
9
  pinned: false
10
+ python_version: "3.12.12"
11
  ---
12
 
13
  # SmolGPT-Fables Studio
 
21
  model improvements advance as v2, v3, and later releases; the corpus generator's
22
  internal version is tracked separately.
23
 
24
+ Training stories: [SmolGPT-Fables Stories](https://huggingface.co/datasets/neonforestmist/smolgpt-markdown-stories).
25
+
26
  The Space never substitutes a remote language model or canned story when its model is missing. It reports the missing artifact and stops.
27
 
28
  The hosted dependency profile pins Torch 2.11.0, one of the versions supported
29
  by Hugging Face ZeroGPU. The real story-generation callback is decorated with
30
+ `@spaces.GPU(duration=120)`. The write button calls that function directly as one
31
  queued event, so form preparation, generation, presentation, and download
32
  creation share one success or error result. Local environments use a no-op
33
  fallback for the decorator and continue to select CPU, CUDA, or MPS normally.
 
49
  - Keeps its schema-compatible Markdown canvas and model settings hidden from the writer.
50
  - Accepts a free-form genre or style and keeps additional story choices optional.
51
  - Converts ordinary character notes into the model's trained document structure.
52
+ - Loads a standard Transformers causal LM or the legacy reviewed custom decoder.
53
  - Labels the result area **SmolGPT-Fables Output:** and shows only the
54
  reader-facing title and story while retaining the full internal document in
55
  hidden state.
 
60
 
61
  ## What to expect from the model
62
 
63
+ SmolGPT-Fables v1 is the model used by this Studio. The selected model card is
64
+ the source of truth for its base model, parameter count, training step, and
65
+ measured evaluation results. The Space shows generated text without silently
 
 
 
66
  replacing it with another model, canned prose, or editorial rewriting.
67
 
68
+ The published checks do not prove literary quality, factuality, safety, or
69
+ broad reasoning. Review and rewrite every result before sharing it.
 
 
 
70
 
71
  ## Model artifact
72
 
73
+ The hosted Studio loads one exact public model revision:
74
+
75
+ `neonforestmist/smolgpt-fables@4245b8a4359e4490be58aaf8ac919dc371b6570e`
76
+
77
+ Local runs can use these variables:
78
 
79
  | Variable | Purpose |
80
  | --- | --- |
 
 
81
  | `SMOLGPT_MODEL_DIR` | Local artifact directory when running outside Hugging Face |
82
  | `SMOLGPT_DEVICE` | Optional `auto`, `cpu`, `cuda`, or `mps` |
 
83
 
84
  The model repository must contain:
85
 
86
  ```text
87
  model.safetensors safe model weights
88
+ config.json standard architecture values or legacy auto_map
89
+ tokenizer.json matching tokenizer
90
+ configuration_smolgpt.py legacy custom exports only
91
+ modeling_smolgpt.py legacy custom exports only
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  ```
93
 
94
+ Outside Hugging Face, the Space resolves `SMOLGPT_MODEL_DIR` first. The hosted
95
+ Space always downloads the exact public revision above, then loads
96
+ `AutoTokenizer` and `AutoModelForCausalLM` from that resolved directory. The
97
+ model and tokenizer are never fetched from another repository.
98
+
99
+ The weights use Safetensors. Standard architectures load without remote code.
100
+ Legacy custom exports conditionally use `trust_remote_code=True` for local
101
+ reproducibility only.
102
+
103
+ The current v1 runtime contract is a standard `LlamaForCausalLM` artifact built
104
+ from `HuggingFaceTB/SmolLM2-1.7B-Instruct` and fine-tuned on the audited
105
+ natural-v6 curriculum. It requires `tokenizer_config.json` with the reviewed
106
+ chat template and `training_manifest.json` with the exact natural-v6
107
+ prompt-contract digest. Both must match the selected model bytes. Unknown,
108
+ incomplete, conflicting, or modified contract bindings are rejected before
109
+ generation.
110
+
111
+ The checked-in hosted model is `neonforestmist/smolgpt-fables` at the exact v1
112
+ commit shown above.
113
+
114
+ ## Release review
115
+
116
+ The dataset, model, and Space use separate guarded release flows. The dataset is
117
+ added without replacing existing configs. The model must be a promoted,
118
+ checksummed natural-v6 artifact, and the Space is updated only after its exact
119
+ model commit is known. See `docs/09-HUGGING-FACE.md` in the companion project for
120
+ the current review commands. A static Space cannot run this Python model.
121
 
122
  ## Local checks
123
 
app.py CHANGED
@@ -1,8 +1,11 @@
1
- """SmolGPT-Fables Studio, a Hugging Face Gradio Space for custom model artifacts.
2
-
3
- The Space loads the project's Transformers-compatible custom architecture from
4
- the chosen local directory or Hugging Face repository. Text generation never
5
- falls back to canned prose or a different remote model.
 
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -10,8 +13,10 @@ from __future__ import annotations
10
  import hashlib
11
  import html
12
  import json
 
13
  import os
14
  import re
 
15
  import tempfile
16
  import threading
17
  from dataclasses import dataclass
@@ -39,17 +44,27 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
39
 
40
 
41
  SPACE_ROOT = Path(__file__).resolve().parent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  FONT_DIR = SPACE_ROOT / "assets" / "fonts"
43
  gr.set_static_paths(paths=[FONT_DIR])
44
 
45
- MODEL_ARTIFACT_FILES = (
46
- "model.safetensors",
47
- "config.json",
48
- "configuration_smolgpt.py",
49
- "modeling_smolgpt.py",
50
- "tokenizer.json",
51
- )
52
  DEFAULT_MODEL_REPO = "neonforestmist/smolgpt-fables"
 
 
53
  RELEASE_VERSION = "v1"
54
  STORY_STRUCTURES = (
55
  "in-medias-res",
@@ -149,11 +164,48 @@ class ModelBundle:
149
  tokenizer: Any
150
  device: torch.device
151
  model_dir: Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
 
154
  _MODEL_BUNDLE: Optional[ModelBundle] = None
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  def _select_device() -> torch.device:
158
  requested = os.getenv("SMOLGPT_DEVICE", "auto").strip().lower()
159
  if requested == "auto":
@@ -175,59 +227,40 @@ def _select_device() -> torch.device:
175
 
176
  def _resolve_model_root(download: bool) -> Path:
177
  local_value = os.getenv("SMOLGPT_MODEL_DIR", "").strip()
178
- repo_id = os.getenv("SMOLGPT_MODEL_REPO", DEFAULT_MODEL_REPO).strip()
179
  if local_value:
 
 
 
 
180
  root = Path(local_value).expanduser().resolve()
181
  if not root.is_dir():
182
  raise ArtifactError(f"SMOLGPT_MODEL_DIR is not a directory: {root}")
183
  return root
184
- if repo_id:
185
- if not download:
186
- raise ArtifactError(
187
- "The Hugging Face model is configured and will download on first generation."
 
 
 
 
 
 
 
188
  )
189
- try:
190
- return Path(
191
- snapshot_download(
192
- repo_id=repo_id,
193
- revision=os.getenv("SMOLGPT_MODEL_REVISION") or None,
194
- token=os.getenv("HF_TOKEN") or None,
195
- )
196
- ).resolve()
197
- except Exception as exc:
198
- raise ArtifactError(
199
- f"Could not download model repository {repo_id}: {exc}"
200
- ) from exc
201
- raise ArtifactError(
202
- "No model is configured. Set SMOLGPT_MODEL_DIR to a local artifact "
203
- "directory or SMOLGPT_MODEL_REPO to a Hugging Face model repository."
204
- )
205
 
206
 
207
  def _validate_model_root(root: Path) -> Mapping[str, Any]:
208
- missing = [name for name in MODEL_ARTIFACT_FILES if not (root / name).is_file()]
209
- if missing:
210
- raise ArtifactError(
211
- "The model repository is missing: " + ", ".join(missing)
212
- )
213
  try:
214
- raw_config = json.loads((root / "config.json").read_text(encoding="utf-8"))
215
- except (OSError, json.JSONDecodeError) as exc:
216
- raise ArtifactError(f"Could not read config.json: {exc}") from exc
217
- if not isinstance(raw_config, Mapping):
218
- raise ArtifactError("config.json must contain a JSON object.")
219
- auto_map = raw_config.get("auto_map")
220
- if not isinstance(auto_map, Mapping):
221
- raise ArtifactError("config.json needs an auto_map for the custom architecture.")
222
- missing_auto = [
223
- name
224
- for name in ("AutoConfig", "AutoModelForCausalLM")
225
- if not isinstance(auto_map.get(name), str) or not auto_map.get(name)
226
- ]
227
- if missing_auto:
228
- raise ArtifactError(
229
- "config.json auto_map is missing: " + ", ".join(missing_auto)
230
- )
231
  return raw_config
232
 
233
 
@@ -237,25 +270,32 @@ def load_model_bundle(force: bool = False) -> ModelBundle:
237
  if _MODEL_BUNDLE is not None and not force:
238
  return _MODEL_BUNDLE
239
  root = _resolve_model_root(download=True)
240
- _validate_model_root(root)
 
 
 
241
  device = _select_device()
242
  try:
243
  tokenizer = AutoTokenizer.from_pretrained(
244
  str(root),
245
  local_files_only=True,
246
- trust_remote_code=True,
247
  )
248
  model = AutoModelForCausalLM.from_pretrained(
249
  str(root),
250
  local_files_only=True,
251
- trust_remote_code=True,
252
  )
253
  except Exception as exc:
254
  raise ArtifactError(
255
- "Transformers could not load the custom SmolGPT artifact: " + str(exc)
256
  ) from exc
257
  if tokenizer.bos_token_id is None or tokenizer.eos_token_id is None:
258
  raise ArtifactError("The tokenizer needs BOS and EOS token IDs.")
 
 
 
 
259
  model.to(device)
260
  model.eval()
261
  _MODEL_BUNDLE = ModelBundle(
@@ -263,6 +303,7 @@ def load_model_bundle(force: bool = False) -> ModelBundle:
263
  tokenizer=tokenizer,
264
  device=device,
265
  model_dir=root,
 
266
  )
267
  return _MODEL_BUNDLE
268
 
@@ -288,8 +329,13 @@ def model_status_html() -> str:
288
  "ready", "SmolGPT is ready.", "Start with an idea whenever you are ready."
289
  )
290
  local_value = os.getenv("SMOLGPT_MODEL_DIR", "").strip()
291
- repo_id = os.getenv("SMOLGPT_MODEL_REPO", DEFAULT_MODEL_REPO).strip()
292
  if local_value:
 
 
 
 
 
 
293
  root = Path(local_value).expanduser()
294
  if not root.is_dir():
295
  return _status_panel(
@@ -304,16 +350,16 @@ def model_status_html() -> str:
304
  "Getting SmolGPT ready...",
305
  "The story model will load when you write your first story.",
306
  )
307
- if repo_id:
308
  return _status_panel(
309
- "waiting",
310
- "Getting SmolGPT ready...",
311
- "The story model will load when you write your first story.",
312
  )
313
  return _status_panel(
314
- "error",
315
- "Model not configured",
316
- "Set SMOLGPT_MODEL_DIR or SMOLGPT_MODEL_REPO before generating a story.",
317
  )
318
 
319
 
@@ -342,9 +388,82 @@ def finish_story_request() -> Tuple[Dict[str, Any], Dict[str, Any]]:
342
  )
343
 
344
 
345
- def toggle_advanced_options(enabled: Any) -> Dict[str, Any]:
346
- """Make advanced settings explicit instead of silently applying hidden values."""
347
- return gr.update(visible=bool(enabled))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
 
350
  def stop_story_request() -> Tuple[Dict[str, Any], Dict[str, Any], str]:
@@ -419,7 +538,7 @@ def _required_anchor_line(
419
  characters: str,
420
  constraints: Optional[str],
421
  ) -> str:
422
- """Match the explicit anchor contract used by the v4 training prompts."""
423
  anchors: List[str] = []
424
 
425
  for match in re.finditer(r"^####\s+(.+?)\s*$", characters, re.MULTILINE):
@@ -455,6 +574,23 @@ def _required_anchor_line(
455
  return ", ".join(unique)
456
 
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  def _scene_count(value: Any) -> int:
459
  """Normalize the numeric Studio control to a bounded whole scene count."""
460
  if isinstance(value, bool):
@@ -533,6 +669,7 @@ def build_canvas(
533
  ending_target: str,
534
  structure: str,
535
  scene_count: Any = DEFAULT_SCENE_COUNT,
 
536
  ) -> Tuple[str, str]:
537
  title = _paragraph(title, "Title")
538
  genre = _paragraph(genre, "Genre")
@@ -553,6 +690,20 @@ def build_canvas(
553
  constraints, "Keep the conflict specific and causally resolved."
554
  )
555
  required_anchors = _required_anchor_line(setting, characters, constraints)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  document = (
557
  f"# Canvas: {title}\n\n"
558
  "## Metadata\n\n"
@@ -573,6 +724,7 @@ def build_canvas(
573
  f"{characters}\n\n"
574
  "### Constraints\n\n"
575
  f"- Target scenes: {scene_count}\n"
 
576
  f"- Must include: {required_anchors}.\n"
577
  f"{constraint_block}\n\n"
578
  "### Beats\n\n"
@@ -610,7 +762,19 @@ def _plain_character_block(value: Optional[str]) -> str:
610
  return "\n\n".join(blocks)
611
 
612
 
613
- def _derived_story_title(premise: str, characters: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
614
  names = [
615
  match.group(1).strip()
616
  for match in re.finditer(r"^####\s+(.+?)\s*$", characters, re.MULTILINE)
@@ -624,8 +788,11 @@ def _derived_story_title(premise: str, characters: str) -> str:
624
  return " ".join(words).strip() or "A SmolGPT Fable"
625
 
626
 
627
- def _premise_anchor_candidates(premise: str, character_block: str) -> List[str]:
628
- """Select a few distinctive premise words so novel objects are copy-signaled."""
 
 
 
629
  character_names = {
630
  match.group(1).strip().casefold()
631
  for match in re.finditer(r"^####\s+(.+?)\s*$", character_block, re.MULTILINE)
@@ -636,28 +803,167 @@ def _premise_anchor_candidates(premise: str, character_block: str) -> List[str]:
636
  "their", "there", "these", "those", "through", "together", "under",
637
  "until", "wants", "where", "which", "while", "would", "young",
638
  }
639
- candidates: List[str] = []
640
  seen = set()
641
  for token in re.findall(r"[A-Za-z][A-Za-z'-]+", premise):
642
  key = token.casefold()
643
  if len(key) < 6 or key in ignored or key in character_names or key in seen:
644
  continue
645
- seen.add(key)
646
- candidates.append(token)
647
- if len(candidates) == 3:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  break
649
  return candidates
650
 
651
 
652
- def _anchored_fallback_beats(anchor_text: str, scene_count: int) -> str:
653
- if not anchor_text:
654
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  if scene_count == 1:
656
- return f"{anchor_text} become essential to resolving the central conflict."
657
- lines = [f"{anchor_text} establish the concrete disruption."]
658
- for _ in range(max(0, scene_count - 2)):
659
- lines.append("The previous choice creates a concrete new consequence.")
660
- lines.append(f"{anchor_text} return in the resolved final choice.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  return "\n".join(lines)
662
 
663
 
@@ -673,14 +979,23 @@ def prepare_story(
673
  important_moments: Optional[str],
674
  details: Optional[str],
675
  ending_target: Optional[str],
 
676
  ) -> Tuple[str, str, int]:
677
- """Build the hidden model canvas from the Studio's plain-language form."""
 
 
678
  premise = _paragraph(story_idea, "Story idea")
679
  count = _scene_count(scene_count)
680
  character_block = _plain_character_block(main_characters)
681
  resolved_setting = _text(setting) or "A vivid setting that fits the story idea."
682
  resolved_details = _text(details)
683
- inferred_anchors = _premise_anchor_candidates(premise, character_block)
 
 
 
 
 
 
684
  if inferred_anchors:
685
  inferred_lines = "\n".join(f"Include {anchor}" for anchor in inferred_anchors)
686
  resolved_details = "\n".join(
@@ -693,7 +1008,12 @@ def prepare_story(
693
  resolved_title = _text(title)
694
  if not resolved_title:
695
  resolved_title = (
696
- _derived_story_title(premise, character_block)
 
 
 
 
 
697
  if specific_brief
698
  else "A SmolGPT Fable"
699
  )
@@ -702,8 +1022,17 @@ def prepare_story(
702
  character_block,
703
  resolved_details,
704
  )
705
- resolved_moments = _text(important_moments) or _anchored_fallback_beats(
706
- anchor_text,
 
 
 
 
 
 
 
 
 
707
  count,
708
  )
709
  canvas, story_input = build_canvas(
@@ -719,10 +1048,42 @@ def prepare_story(
719
  or "Resolve the central conflict and end with a clear emotional change.",
720
  _text(structure) or "linear-three-act",
721
  count,
 
722
  )
723
  return canvas, story_input, SCENE_TOKEN_BUDGETS[count]
724
 
725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  def _story_prompt(canvas: str) -> str:
727
  text = canvas.strip()
728
  required = (
@@ -832,12 +1193,12 @@ def _model_context_length(model: Any) -> int:
832
  if value is not None and int(value) > 0:
833
  return int(value)
834
  raise ArtifactError(
835
- "The custom model config needs context_length or max_position_embeddings."
836
  )
837
 
838
 
839
  @torch.inference_mode()
840
- def generate_continuation(
841
  bundle: ModelBundle,
842
  prompt: str,
843
  temperature: float,
@@ -847,27 +1208,41 @@ def generate_continuation(
847
  max_new_tokens: int,
848
  progress: Optional[gr.Progress] = None,
849
  stop_strings: Tuple[str, ...] = (),
850
- ) -> str:
 
 
851
  tokenizer = bundle.tokenizer
852
  context_length = _model_context_length(bundle.model)
853
- token_ids = [
854
- int(tokenizer.bos_token_id),
855
- *tokenizer.encode(prompt, add_special_tokens=False),
856
- ]
 
 
 
 
 
 
 
 
 
 
857
  if len(token_ids) >= context_length:
858
  raise StudioError(
859
  f"The prompt uses {len(token_ids)} tokens, but this model supports "
860
  f"{context_length}. Shorten the canvas or story."
861
  )
862
- if int(max_new_tokens) < 0:
 
863
  raise StudioError("The story length must be zero or greater.")
864
  # Stop at the learned position boundary instead of sliding the opening
865
  # canvas out of view. That keeps names, required details, and scene count in
866
  # every attention prefix.
867
- budget = min(int(max_new_tokens), context_length - len(token_ids))
868
  generated: List[int] = []
869
  past_key_values: Any = None
870
  cache_active = True
 
871
  for index in range(budget):
872
  step_ids = token_ids if not cache_active or past_key_values is None else token_ids[-1:]
873
  inputs = torch.tensor([step_ids], dtype=torch.long, device=bundle.device)
@@ -894,7 +1269,7 @@ def generate_continuation(
894
  outputs = bundle.model(input_ids=inputs)
895
  logits = getattr(outputs, "logits", None)
896
  if logits is None:
897
- raise StudioError("The custom model did not return causal language logits.")
898
  next_cache = getattr(outputs, "past_key_values", None)
899
  if cache_active and next_cache is None:
900
  # Models exported before KV-cache support accept extra kwargs but
@@ -902,8 +1277,10 @@ def generate_continuation(
902
  cache_active = False
903
  elif cache_active:
904
  past_key_values = next_cache
 
 
905
  prior_ids = torch.tensor(
906
- token_ids[-256:], dtype=torch.long, device=bundle.device
907
  )
908
  value = _sample_next(
909
  logits[0, -1],
@@ -914,19 +1291,56 @@ def generate_continuation(
914
  float(repetition_penalty),
915
  )
916
  if value == int(tokenizer.eos_token_id):
 
917
  break
918
  token_ids.append(value)
919
  generated.append(value)
920
  if stop_strings:
921
  partial = tokenizer.decode(generated, skip_special_tokens=True)
922
  if any(marker in partial for marker in stop_strings):
 
923
  break
924
  if progress is not None and (index + 1) % 24 == 0:
925
  progress(
926
  (index + 1) / max(budget, 1),
927
  desc="Writing your story",
928
  )
929
- return tokenizer.decode(generated, skip_special_tokens=True).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
 
931
 
932
  def _story_filename(story: str) -> str:
@@ -982,7 +1396,7 @@ def _generate_story_document(
982
  )
983
 
984
 
985
- @spaces.GPU(duration=60)
986
  def generate_story(
987
  canvas: str,
988
  temperature: float,
@@ -1008,7 +1422,7 @@ def generate_story(
1008
  raise _ui_error(f"Story generation failed: {exc}") from exc
1009
 
1010
 
1011
- @spaces.GPU(duration=60)
1012
  def write_story_from_form(
1013
  story_idea: Optional[str],
1014
  genre: Optional[str],
@@ -1030,7 +1444,7 @@ def write_story_from_form(
1030
  """Run the complete public Studio flow as one reliable queued event."""
1031
  try:
1032
  use_advanced = bool(advanced_enabled)
1033
- _, story_input, max_new_tokens = prepare_story(
1034
  story_idea,
1035
  genre,
1036
  scene_count,
@@ -1370,8 +1784,7 @@ body { min-height: 100dvh; }
1370
  opacity: .62;
1371
  }
1372
 
1373
- .story-shape .wrap,
1374
- .story-shape [role="radiogroup"] {
1375
  display: grid !important;
1376
  grid-template-columns: repeat(2, minmax(0, 1fr));
1377
  gap: 8px !important;
@@ -1567,8 +1980,7 @@ footer { display: none !important; }
1567
  .character-row { width: 100%; }
1568
  .character-row > .character-row { padding: 10px !important; }
1569
  .story-stage { min-height: 320px; }
1570
- .story-shape .wrap,
1571
- .story-shape [role="radiogroup"] {
1572
  grid-template-columns: 1fr;
1573
  }
1574
  .status-panel { grid-template-columns: 1fr; gap: 5px; }
@@ -1596,6 +2008,7 @@ footer { display: none !important; }
1596
  --color-text: #111827;
1597
  --color-muted: #4b5563;
1598
  --color-success: #16a34a;
 
1599
  --color-warning: #d97706;
1600
  --color-danger: #dc2626;
1601
  --color-line: rgba(38, 61, 91, .32);
@@ -1627,8 +2040,10 @@ footer { display: none !important; }
1627
  --color-canvas: #0d1726;
1628
  --color-text: #f8fafc;
1629
  --color-muted: #cbd5e1;
 
1630
  --color-line: rgba(148, 196, 219, .48);
1631
  --color-focus: #82d9f7;
 
1632
  --studio-panel: #1b2d45;
1633
  --studio-bg: var(--color-canvas);
1634
  --studio-paper: var(--color-surface);
@@ -1672,6 +2087,13 @@ footer { display: none !important; }
1672
  gap: var(--space-8) !important;
1673
  }
1674
  .character-list { gap: var(--space-3) !important; }
 
 
 
 
 
 
 
1675
  .character-card {
1676
  position: relative;
1677
  padding: var(--space-3) !important;
@@ -1704,10 +2126,11 @@ footer { display: none !important; }
1704
  border-color: var(--color-secondary) !important;
1705
  box-shadow: var(--sketch-shadow), 0 0 0 3px rgba(73, 182, 229, .28) !important;
1706
  }
1707
- .story-shape .wrap,
1708
- .story-shape [role="radiogroup"] { grid-template-columns: 1fr !important; }
 
1709
  .story-shape label,
1710
- .gradio-container [role="radiogroup"] label { min-height: 44px !important; }
1711
  .studio-primary {
1712
  color: #111827 !important;
1713
  border: 1.5px solid var(--color-secondary) !important;
@@ -1735,7 +2158,7 @@ footer { display: none !important; }
1735
  .gradio-container input:focus-visible,
1736
  .gradio-container textarea:focus-visible,
1737
  .gradio-container [role="radio"]:focus-visible,
1738
- .gradio-container [role="radiogroup"] label:focus-within,
1739
  .gradio-container summary:focus-visible,
1740
  .gradio-container [role="tab"]:focus-visible,
1741
  .gradio-container input[type="file"]:focus-visible {
@@ -1768,8 +2191,18 @@ footer { display: none !important; }
1768
  .character-list #character-1-card:has(.character-card)::before { content: "1"; }
1769
  .character-list #character-2-card:has(.character-card)::before { content: "2"; }
1770
  .character-card .character-card::before { display: none !important; }
1771
- .character-card textarea { min-height: 68px !important; resize: vertical; }
 
 
 
 
 
 
 
 
 
1772
  .character-card input { min-height: 44px !important; }
 
1773
 
1774
  /* Selected story choices need an unmistakable filled center dot. */
1775
  .gradio-container input[type="radio"] {
@@ -1786,7 +2219,7 @@ footer { display: none !important; }
1786
  }
1787
  .gradio-container input[type="radio"]:checked {
1788
  border-color: var(--color-primary) !important;
1789
- background: radial-gradient(circle, var(--color-primary) 0 38%, transparent 42% 100%) !important;
1790
  }
1791
  .gradio-container label:has(input[type="radio"]:checked) {
1792
  border-color: var(--color-primary) !important;
@@ -1813,18 +2246,283 @@ footer { display: none !important; }
1813
  .advanced-toggle input[type="checkbox"]:checked {
1814
  background: radial-gradient(circle at 32px 50%, #ffffff 0 7px, transparent 8px), var(--color-primary) !important;
1815
  }
1816
- .advanced-panel {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1817
  margin-top: var(--space-2) !important;
1818
  padding: var(--space-4) !important;
1819
  border: 1.5px dashed var(--color-line) !important;
1820
  border-radius: var(--radius-card) !important;
1821
  background: color-mix(in srgb, var(--studio-panel) 62%, transparent) !important;
1822
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1823
 
1824
  @media (max-width: 900px) {
1825
  .creator-grid { grid-template-columns: minmax(0, 1fr); }
1826
  }
1827
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1828
  .main.fillable.app {
1829
  width: min(calc(100vw - 32px), 1280px) !important;
1830
  margin-inline: auto !important;
@@ -1863,7 +2561,7 @@ def build_demo() -> gr.Blocks:
1863
  <header class="studio-head">
1864
  <h1>SmolGPT-Fables Studio</h1>
1865
  <svg class="studio-squiggle" viewBox="0 0 330 14" aria-hidden="true" focusable="false"><path d="M2 9 C 48 1, 84 13, 128 7 S 220 4, 328 8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
1866
- <p>Give it an idea and see what this tiny experimental story model writes.</p>
1867
  </header>
1868
  <div class="studio-rule" aria-hidden="true"></div>
1869
  """
@@ -1896,16 +2594,41 @@ def build_demo() -> gr.Blocks:
1896
  placeholder="Mystery, romance, folklore, space adventure",
1897
  info="Use any genre or mix of styles you like.",
1898
  )
1899
- scene_count = gr.Number(
1900
- value=DEFAULT_SCENE_COUNT,
1901
- label="How many scenes?",
1902
- info="Choose a whole number from 1 to 6.",
1903
- minimum=MIN_SCENE_COUNT,
1904
- maximum=MAX_SCENE_COUNT,
1905
- step=1,
1906
- precision=0,
1907
- elem_classes="scene-count",
1908
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1909
  gr.Markdown(
1910
  "### Main characters\nOne character per card. The second card is optional.",
1911
  elem_classes="character-heading",
@@ -1920,6 +2643,7 @@ def build_demo() -> gr.Blocks:
1920
  placeholder="Mara",
1921
  info="Character 1",
1922
  lines=1,
 
1923
  )
1924
  first_character_about = gr.Textbox(
1925
  label="About them",
@@ -1928,6 +2652,7 @@ def build_demo() -> gr.Blocks:
1928
  ),
1929
  info="Role, personality, and what they want.",
1930
  lines=2,
 
1931
  )
1932
  with gr.Group(
1933
  elem_classes=["character-card", "character-card-optional"],
@@ -1938,12 +2663,14 @@ def build_demo() -> gr.Blocks:
1938
  placeholder="Ilyan",
1939
  info="Character 2",
1940
  lines=1,
 
1941
  )
1942
  second_character_about = gr.Textbox(
1943
  label="About them (optional)",
1944
  placeholder="A wandering artist looking for a home",
1945
  info="Leave both fields blank for a one-character story.",
1946
  lines=2,
 
1947
  )
1948
  setting = gr.Textbox(
1949
  label="Where does it happen?",
@@ -1953,13 +2680,17 @@ def build_demo() -> gr.Blocks:
1953
 
1954
  advanced_enabled = gr.Checkbox(
1955
  value=False,
1956
- label="Use advanced options",
1957
  info=(
1958
  "Turn this on to customize the title, viewpoint, "
1959
  "story shape, key moments, required details, and ending."
1960
  ),
1961
  elem_classes="advanced-toggle",
1962
  )
 
 
 
 
1963
  with gr.Group(
1964
  visible=False,
1965
  elem_classes="advanced-panel",
@@ -2007,7 +2738,36 @@ def build_demo() -> gr.Blocks:
2007
  advanced_enabled.change(
2008
  fn=toggle_advanced_options,
2009
  inputs=advanced_enabled,
2010
- outputs=advanced_panel,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2011
  show_progress="hidden",
2012
  **PRIVATE_EVENT_ARGS,
2013
  )
@@ -2071,7 +2831,7 @@ def build_demo() -> gr.Blocks:
2071
  """
2072
  ## About SmolGPT-Fables Studio
2073
 
2074
- SmolGPT-Fables Studio is a hands-on demo for the experimental SmolGPT-Fables v1 model. The form keeps its internal document structure out of the way, so you can focus on characters, places, and what happens.
2075
 
2076
  Choose between one and six scenes. The Studio uses that choice to shape the hidden story plan and keep the generation budget proportional to the requested story.
2077
 
@@ -2135,6 +2895,12 @@ This is a focused, text-only story Studio.
2135
  return demo
2136
 
2137
 
 
 
 
 
 
 
2138
  demo = build_demo()
2139
 
2140
 
 
1
+ """SmolGPT-Fables Studio for standard and legacy Transformers model artifacts.
2
+
3
+ The hosted Studio loads one canonical, immutable model revision. Local model
4
+ directories remain available outside Hugging Face Spaces so the reviewed
5
+ evaluation workflow can exercise the exact same generation code. Standard
6
+ Transformers models use their native architecture and chat template; legacy
7
+ SmolGPT exports retain the reviewed custom-code path. Text generation never
8
+ falls back to canned prose.
9
  """
10
 
11
  from __future__ import annotations
 
13
  import hashlib
14
  import html
15
  import json
16
+ import math
17
  import os
18
  import re
19
+ import sys
20
  import tempfile
21
  import threading
22
  from dataclasses import dataclass
 
44
 
45
 
46
  SPACE_ROOT = Path(__file__).resolve().parent
47
+ if str(SPACE_ROOT) not in sys.path:
48
+ sys.path.insert(0, str(SPACE_ROOT))
49
+
50
+ from runtime_contract import ( # noqa: E402
51
+ CHAT_PROMPT_FORMAT,
52
+ PRODUCT_CONTEXT_LENGTH,
53
+ RAW_PROMPT_FORMAT,
54
+ SMOLLM3_CHAT_PROMPT_FORMAT,
55
+ SMOLLM3_PRODUCT_CONTEXT_LENGTH,
56
+ generation_prompt_ids,
57
+ product_context_length_for_prompt_format,
58
+ prompt_format_for_artifact,
59
+ validate_transformers_artifact,
60
+ )
61
+
62
  FONT_DIR = SPACE_ROOT / "assets" / "fonts"
63
  gr.set_static_paths(paths=[FONT_DIR])
64
 
 
 
 
 
 
 
 
65
  DEFAULT_MODEL_REPO = "neonforestmist/smolgpt-fables"
66
+ # Exact SmolGPT-Fables v1 commit published on the model repository's main branch.
67
+ DEFAULT_MODEL_REVISION = "4245b8a4359e4490be58aaf8ac919dc371b6570e"
68
  RELEASE_VERSION = "v1"
69
  STORY_STRUCTURES = (
70
  "in-medias-res",
 
164
  tokenizer: Any
165
  device: torch.device
166
  model_dir: Path
167
+ prompt_format: str = RAW_PROMPT_FORMAT
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class GenerationTrace:
172
+ """Exact token-boundary telemetry captured before continuation decoding."""
173
+
174
+ requested_token_budget: int
175
+ effective_token_budget: int
176
+ emitted_continuation_token_count: int
177
+ stop_reason: str
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class GenerationResult:
182
+ """Decoded continuation plus its exact, generation-time token trace."""
183
+
184
+ continuation: str
185
+ trace: GenerationTrace
186
 
187
 
188
  _MODEL_BUNDLE: Optional[ModelBundle] = None
189
 
190
 
191
+ def _running_on_huggingface_space() -> bool:
192
+ """Return whether Hugging Face has identified this process as a Space."""
193
+
194
+ return bool(os.getenv("SPACE_ID", "").strip())
195
+
196
+
197
+ def _default_model_revision_is_sealed() -> bool:
198
+ return re.fullmatch(r"[0-9a-f]{40}", DEFAULT_MODEL_REVISION) is not None
199
+
200
+
201
+ def _require_sealed_default_model_revision() -> None:
202
+ if not _default_model_revision_is_sealed():
203
+ raise ArtifactError(
204
+ "The hosted SmolGPT-Fables model revision is not sealed yet. "
205
+ "Publish and merge the promoted model before launching the Studio."
206
+ )
207
+
208
+
209
  def _select_device() -> torch.device:
210
  requested = os.getenv("SMOLGPT_DEVICE", "auto").strip().lower()
211
  if requested == "auto":
 
227
 
228
  def _resolve_model_root(download: bool) -> Path:
229
  local_value = os.getenv("SMOLGPT_MODEL_DIR", "").strip()
 
230
  if local_value:
231
+ if _running_on_huggingface_space():
232
+ raise ArtifactError(
233
+ "Hosted SmolGPT-Fables does not accept a local model override."
234
+ )
235
  root = Path(local_value).expanduser().resolve()
236
  if not root.is_dir():
237
  raise ArtifactError(f"SMOLGPT_MODEL_DIR is not a directory: {root}")
238
  return root
239
+ _require_sealed_default_model_revision()
240
+ if not download:
241
+ raise ArtifactError(
242
+ "The pinned Hugging Face model will download on first generation."
243
+ )
244
+ try:
245
+ return Path(
246
+ snapshot_download(
247
+ repo_id=DEFAULT_MODEL_REPO,
248
+ revision=DEFAULT_MODEL_REVISION,
249
+ token=os.getenv("HF_TOKEN") or None,
250
  )
251
+ ).resolve()
252
+ except Exception as exc:
253
+ raise ArtifactError(
254
+ "Could not download the pinned SmolGPT-Fables model "
255
+ f"{DEFAULT_MODEL_REPO}@{DEFAULT_MODEL_REVISION}: {exc}"
256
+ ) from exc
 
 
 
 
 
 
 
 
 
 
257
 
258
 
259
  def _validate_model_root(root: Path) -> Mapping[str, Any]:
 
 
 
 
 
260
  try:
261
+ raw_config, _artifact_kind = validate_transformers_artifact(root)
262
+ except ValueError as exc:
263
+ raise ArtifactError(str(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  return raw_config
265
 
266
 
 
270
  if _MODEL_BUNDLE is not None and not force:
271
  return _MODEL_BUNDLE
272
  root = _resolve_model_root(download=True)
273
+ try:
274
+ _raw_config, artifact_kind = validate_transformers_artifact(root)
275
+ except ValueError as exc:
276
+ raise ArtifactError(str(exc)) from exc
277
  device = _select_device()
278
  try:
279
  tokenizer = AutoTokenizer.from_pretrained(
280
  str(root),
281
  local_files_only=True,
282
+ trust_remote_code=artifact_kind == "custom",
283
  )
284
  model = AutoModelForCausalLM.from_pretrained(
285
  str(root),
286
  local_files_only=True,
287
+ trust_remote_code=artifact_kind == "custom",
288
  )
289
  except Exception as exc:
290
  raise ArtifactError(
291
+ "Transformers could not load the selected SmolGPT artifact: " + str(exc)
292
  ) from exc
293
  if tokenizer.bos_token_id is None or tokenizer.eos_token_id is None:
294
  raise ArtifactError("The tokenizer needs BOS and EOS token IDs.")
295
+ try:
296
+ prompt_format = prompt_format_for_artifact(root, artifact_kind, tokenizer)
297
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
298
+ raise ArtifactError(f"Model prompt contract is invalid: {exc}") from exc
299
  model.to(device)
300
  model.eval()
301
  _MODEL_BUNDLE = ModelBundle(
 
303
  tokenizer=tokenizer,
304
  device=device,
305
  model_dir=root,
306
+ prompt_format=prompt_format,
307
  )
308
  return _MODEL_BUNDLE
309
 
 
329
  "ready", "SmolGPT is ready.", "Start with an idea whenever you are ready."
330
  )
331
  local_value = os.getenv("SMOLGPT_MODEL_DIR", "").strip()
 
332
  if local_value:
333
+ if _running_on_huggingface_space():
334
+ return _status_panel(
335
+ "error",
336
+ "Hosted model override rejected",
337
+ "This Studio only loads its reviewed, pinned model revision.",
338
+ )
339
  root = Path(local_value).expanduser()
340
  if not root.is_dir():
341
  return _status_panel(
 
350
  "Getting SmolGPT ready...",
351
  "The story model will load when you write your first story.",
352
  )
353
+ if not _default_model_revision_is_sealed():
354
  return _status_panel(
355
+ "error",
356
+ "Studio release is not sealed",
357
+ "The promoted model must be merged and pinned before this Studio can run.",
358
  )
359
  return _status_panel(
360
+ "waiting",
361
+ "Getting SmolGPT ready...",
362
+ "The pinned story model will load when you write your first story.",
363
  )
364
 
365
 
 
388
  )
389
 
390
 
391
+ def advanced_options_status(enabled: Any) -> str:
392
+ """Return the visible and screen-reader announced advanced-options state."""
393
+
394
+ is_enabled = bool(enabled)
395
+ state = "On" if is_enabled else "Off"
396
+ detail = (
397
+ "Title, viewpoint, story shape, key moments, details, and ending are enabled."
398
+ if is_enabled
399
+ else "Simple story defaults are active."
400
+ )
401
+ state_class = "advanced-on" if is_enabled else "advanced-off"
402
+ return (
403
+ f'<div class="advanced-state {state_class}" role="status" aria-live="polite">'
404
+ f'<strong>{state}</strong><span>{detail}</span></div>'
405
+ )
406
+
407
+
408
+ def toggle_advanced_options(enabled: Any) -> Tuple[Dict[str, Any], str]:
409
+ """Show the advanced panel and keep its explicit on/off state synchronized."""
410
+
411
+ return gr.update(visible=bool(enabled)), advanced_options_status(enabled)
412
+
413
+
414
+ def _step_scene_count(value: Any, delta: int) -> Tuple[int, Dict[str, Any], Dict[str, Any]]:
415
+ """Move the scene stepper by one and expose disabled endpoint states."""
416
+
417
+ if delta not in (-1, 1):
418
+ raise ValueError("scene stepper delta must be -1 or 1")
419
+ count = min(MAX_SCENE_COUNT, max(MIN_SCENE_COUNT, _scene_count(value) + delta))
420
+ return (
421
+ count,
422
+ gr.update(interactive=count > MIN_SCENE_COUNT),
423
+ gr.update(interactive=count < MAX_SCENE_COUNT),
424
+ )
425
+
426
+
427
+ def decrease_scene_count(value: Any) -> Tuple[int, Dict[str, Any], Dict[str, Any]]:
428
+ return _step_scene_count(value, -1)
429
+
430
+
431
+ def increase_scene_count(value: Any) -> Tuple[int, Dict[str, Any], Dict[str, Any]]:
432
+ return _step_scene_count(value, 1)
433
+
434
+
435
+ def sync_scene_stepper(value: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]:
436
+ """Synchronize endpoint buttons while the user edits the numeric value."""
437
+
438
+ count = _normalized_scene_stepper_value(value)
439
+ return (
440
+ gr.update(interactive=count > MIN_SCENE_COUNT),
441
+ gr.update(interactive=count < MAX_SCENE_COUNT),
442
+ )
443
+
444
+
445
+ def _normalized_scene_stepper_value(value: Any) -> int:
446
+ """Round and clamp direct UI input without weakening backend validation."""
447
+
448
+ if isinstance(value, bool):
449
+ return DEFAULT_SCENE_COUNT
450
+ try:
451
+ numeric = float(value)
452
+ except (TypeError, ValueError):
453
+ return DEFAULT_SCENE_COUNT
454
+ if not math.isfinite(numeric):
455
+ return DEFAULT_SCENE_COUNT
456
+ return min(MAX_SCENE_COUNT, max(MIN_SCENE_COUNT, int(round(numeric))))
457
+
458
+
459
+ def normalize_scene_stepper(
460
+ value: Any,
461
+ ) -> Tuple[int, Dict[str, Any], Dict[str, Any]]:
462
+ """Normalize the visible Number when it loses focus and sync endpoints."""
463
+
464
+ count = _normalized_scene_stepper_value(value)
465
+ minus, plus = sync_scene_stepper(count)
466
+ return count, minus, plus
467
 
468
 
469
  def stop_story_request() -> Tuple[Dict[str, Any], Dict[str, Any], str]:
 
538
  characters: str,
539
  constraints: Optional[str],
540
  ) -> str:
541
+ """Match the explicit anchor contract used by the v5 training prompts."""
542
  anchors: List[str] = []
543
 
544
  for match in re.finditer(r"^####\s+(.+?)\s*$", characters, re.MULTILINE):
 
574
  return ", ".join(unique)
575
 
576
 
577
+ def _explicit_include_anchors(value: Optional[str]) -> List[str]:
578
+ """Return only the positive details the user explicitly asked to include."""
579
+ anchors: List[str] = []
580
+ for raw_line in _text(value).splitlines():
581
+ line = re.sub(r"^[-*]\s*", "", raw_line.strip()).rstrip(".")
582
+ lower = line.casefold()
583
+ if lower.startswith("must include:"):
584
+ line = line.split(":", 1)[1].strip()
585
+ elif lower.startswith("include "):
586
+ line = line[8:].strip()
587
+ else:
588
+ continue
589
+ if line:
590
+ anchors.append(line)
591
+ return anchors
592
+
593
+
594
  def _scene_count(value: Any) -> int:
595
  """Normalize the numeric Studio control to a bounded whole scene count."""
596
  if isinstance(value, bool):
 
669
  ending_target: str,
670
  structure: str,
671
  scene_count: Any = DEFAULT_SCENE_COUNT,
672
+ scene_word_target: Optional[Tuple[int, int]] = None,
673
  ) -> Tuple[str, str]:
674
  title = _paragraph(title, "Title")
675
  genre = _paragraph(genre, "Genre")
 
690
  constraints, "Keep the conflict specific and causally resolved."
691
  )
692
  required_anchors = _required_anchor_line(setting, characters, constraints)
693
+ word_target_line = ""
694
+ if scene_word_target is not None:
695
+ minimum, maximum = scene_word_target
696
+ if not (0 < int(minimum) <= int(maximum)):
697
+ raise StudioError("The hidden scene word target is invalid.")
698
+ word_target_line = (
699
+ f"- Aim for {int(minimum)} to {int(maximum)} words in each scene.\n"
700
+ "- Keep later and final scenes as fully developed as the first; do not "
701
+ "compress them below the word target.\n"
702
+ "- In the final scene, explicitly perform the central actions named in "
703
+ "the story idea, with their objects, and show the result on-page; do "
704
+ "not end with them merely planned, attempted, promised, or still "
705
+ "waiting to happen.\n"
706
+ )
707
  document = (
708
  f"# Canvas: {title}\n\n"
709
  "## Metadata\n\n"
 
724
  f"{characters}\n\n"
725
  "### Constraints\n\n"
726
  f"- Target scenes: {scene_count}\n"
727
+ f"{word_target_line}"
728
  f"- Must include: {required_anchors}.\n"
729
  f"{constraint_block}\n\n"
730
  "### Beats\n\n"
 
762
  return "\n\n".join(blocks)
763
 
764
 
765
+ def _derived_story_title(
766
+ premise: str,
767
+ characters: str,
768
+ explicit_anchor: str = "",
769
+ setting: str = "",
770
+ ) -> str:
771
+ if explicit_anchor and setting and setting != "A vivid setting that fits the story idea.":
772
+ subject = re.sub(r"^(?:a|an|the)\s+", "", explicit_anchor, flags=re.IGNORECASE)
773
+ place = re.sub(r"^(?:a|an|the)\s+", "", setting, flags=re.IGNORECASE)
774
+ subject = " ".join(subject.split())[:48].strip()
775
+ place = " ".join(place.split())[:64].strip()
776
+ if subject and place:
777
+ return f"{subject[:1].upper() + subject[1:]} at {place}"
778
  names = [
779
  match.group(1).strip()
780
  for match in re.finditer(r"^####\s+(.+?)\s*$", characters, re.MULTILINE)
 
788
  return " ".join(words).strip() or "A SmolGPT Fable"
789
 
790
 
791
+ def _premise_anchor_candidates_v1(
792
+ premise: str, character_block: str
793
+ ) -> List[str]:
794
+ """Reproduce the frozen natural-v6 training prompt contract exactly."""
795
+
796
  character_names = {
797
  match.group(1).strip().casefold()
798
  for match in re.finditer(r"^####\s+(.+?)\s*$", character_block, re.MULTILINE)
 
803
  "their", "there", "these", "those", "through", "together", "under",
804
  "until", "wants", "where", "which", "while", "would", "young",
805
  }
 
806
  seen = set()
807
  for token in re.findall(r"[A-Za-z][A-Za-z'-]+", premise):
808
  key = token.casefold()
809
  if len(key) < 6 or key in ignored or key in character_names or key in seen:
810
  continue
811
+ return [token]
812
+ return []
813
+
814
+
815
+ def _premise_anchor_candidates_v2(premise: str, character_block: str) -> List[str]:
816
+ """Select a concrete premise phrase so novel objects are copy-signaled."""
817
+ character_names = {
818
+ match.group(1).strip().casefold()
819
+ for match in re.finditer(r"^####\s+(.+?)\s*$", character_block, re.MULTILINE)
820
+ }
821
+ character_name_tokens = {
822
+ token.casefold()
823
+ for name in character_names
824
+ for token in re.findall(r"[A-Za-z][A-Za-z'-]+", name)
825
+ }
826
+ ignored = {
827
+ "about", "after", "again", "before", "began", "begins", "being",
828
+ "caused", "could", "dispute", "find", "finds", "first", "learned",
829
+ "learns", "marked", "marks", "named",
830
+ "needs", "points", "reaches", "returns", "return", "sends", "should",
831
+ "starts", "stolen", "their", "there", "these", "those", "through",
832
+ "together", "toward", "under", "until", "wants", "where", "which",
833
+ "while", "would", "young",
834
+ }
835
+ phrase_stops = ignored | {
836
+ "and", "because", "before", "from", "into", "must", "that", "then",
837
+ "they", "this", "when", "with", "without",
838
+ }
839
+ tokens = re.findall(r"[A-Za-z][A-Za-z'-]+", premise)
840
+ candidates: List[str] = []
841
+ seen = set()
842
+ for index, token in enumerate(tokens):
843
+ key = token.casefold()
844
+ if (
845
+ len(key) < 6
846
+ or key in ignored
847
+ or key in character_names
848
+ or key in character_name_tokens
849
+ or key in seen
850
+ ):
851
+ continue
852
+ phrase = [token]
853
+ if index + 1 < len(tokens):
854
+ following = tokens[index + 1]
855
+ following_key = following.casefold()
856
+ if (
857
+ len(following_key) >= 4
858
+ and following_key not in phrase_stops
859
+ and following_key not in character_names
860
+ and following_key not in character_name_tokens
861
+ ):
862
+ phrase.append(following)
863
+ value = " ".join(phrase)
864
+ seen.add(value.casefold())
865
+ candidates.append(value)
866
+ if len(candidates) == 1:
867
  break
868
  return candidates
869
 
870
 
871
+ def _anchored_fallback_beats_v1(
872
+ premise: str,
873
+ setting: str,
874
+ character_block: str,
875
+ explicit_anchor: str,
876
+ premise_anchor: str,
877
+ scene_count: int,
878
+ ) -> str:
879
+ """Reproduce the frozen natural-v6 fallback plan for historical rows."""
880
+
881
+ names = [
882
+ match.group(1).strip()
883
+ for match in re.finditer(r"^####\s+(.+?)\s*$", character_block, re.MULTILINE)
884
+ if match.group(1).strip() and match.group(1).strip() != "Main character"
885
+ ]
886
+ team = " and ".join(names) or "The protagonist"
887
+ verb = "examine" if len(names) != 1 else "examines"
888
+ artifact = explicit_anchor or premise_anchor or "the central clue"
889
+ if not re.match(r"^(?:a|an|the|this|that|my|our|their)\s", artifact, re.IGNORECASE):
890
+ article = "an" if artifact[:1].casefold() in {"a", "e", "i", "o", "u"} else "a"
891
+ artifact = f"{article} {artifact}"
892
+ location = setting or "the story's decisive place"
893
+ if re.match(r"^(?:A|An|The)\s", location):
894
+ location = location[:1].lower() + location[1:]
895
+ disruption = premise.rstrip(" .")
896
+ if re.match(r"^(?:A|An|The)\s", disruption):
897
+ disruption = disruption[:1].lower() + disruption[1:]
898
+ lines = [f"At {location}, {team} {verb} {artifact} after {disruption}."]
899
+ if scene_count == 1:
900
+ return lines[0]
901
+ clue = premise_anchor or artifact
902
+ middle = (
903
+ f"Following {clue}, the characters revise their plan; the result creates "
904
+ "a concrete new consequence."
905
+ )
906
+ lines.extend([middle] * max(0, scene_count - 2))
907
+ lines.append(
908
+ "The protagonist acts on what the evidence revealed, making a visible "
909
+ "choice that resolves the central conflict."
910
+ )
911
+ return "\n".join(lines)
912
+
913
+
914
+ def _anchored_fallback_beats_v2(
915
+ premise: str,
916
+ setting: str,
917
+ character_block: str,
918
+ explicit_anchor: str,
919
+ premise_anchor: str,
920
+ scene_count: int,
921
+ ) -> str:
922
+ """Create causal default beats in the same role order as the v5 curriculum."""
923
+ names = [
924
+ match.group(1).strip()
925
+ for match in re.finditer(r"^####\s+(.+?)\s*$", character_block, re.MULTILINE)
926
+ if match.group(1).strip() and match.group(1).strip() != "Main character"
927
+ ]
928
+ team = " and ".join(names) or "The protagonist"
929
+ plural_team = len(names) >= 2
930
+ verb = "examine" if plural_team else "examines"
931
+ test_verb = "test" if plural_team else "tests"
932
+ combine_verb = "combine" if plural_team else "combines"
933
+ artifact = explicit_anchor or premise_anchor or "the central clue"
934
+ if not re.match(r"^(?:a|an|the|this|that|my|our|their)\s", artifact, re.IGNORECASE):
935
+ article = "an" if artifact[:1].casefold() in {"a", "e", "i", "o", "u"} else "a"
936
+ artifact = f"{article} {artifact}"
937
+ location = setting or "the story's decisive place"
938
+ if re.match(r"^(?:A|An|The)\s", location):
939
+ location = location[:1].lower() + location[1:]
940
+ disruption = premise.rstrip(" .")
941
+ if re.match(r"^(?:A|An|The)\s", disruption):
942
+ disruption = disruption[:1].lower() + disruption[1:]
943
+ lines = [f"At {location}, {team} {verb} {artifact} after {disruption}."]
944
  if scene_count == 1:
945
+ return (
946
+ f"{lines[0]} In that same scene, the protagonist completes every "
947
+ "central action named in the story idea, with its object, and shows "
948
+ "the result."
949
+ )
950
+ clue = premise_anchor or artifact
951
+ middle_beats = (
952
+ f"{team} {test_verb} what {clue} implies, and the attempt exposes a concrete cost "
953
+ "nobody expected.",
954
+ f"A conflicting clue forces {team} to choose between the safe plan and the "
955
+ "urgent one.",
956
+ f"The consequence reaches {location}, changing who can be trusted and what "
957
+ "must happen next.",
958
+ f"{team} {combine_verb} what each character knows, turning the setback into a "
959
+ "workable final plan.",
960
+ )
961
+ lines.extend(middle_beats[: max(0, scene_count - 2)])
962
+ lines.append(
963
+ "The protagonist completes the actions required by the story idea on-page, "
964
+ "making a visible choice that resolves the central conflict rather than "
965
+ "merely planning or attempting it."
966
+ )
967
  return "\n".join(lines)
968
 
969
 
 
979
  important_moments: Optional[str],
980
  details: Optional[str],
981
  ending_target: Optional[str],
982
+ planner_version: str = "v1",
983
  ) -> Tuple[str, str, int]:
984
+ """Build a model canvas; v1 remains frozen for historical evidence."""
985
+ if planner_version not in {"v1", "v2"}:
986
+ raise StudioError("The hidden Studio planner version is invalid.")
987
  premise = _paragraph(story_idea, "Story idea")
988
  count = _scene_count(scene_count)
989
  character_block = _plain_character_block(main_characters)
990
  resolved_setting = _text(setting) or "A vivid setting that fits the story idea."
991
  resolved_details = _text(details)
992
+ anchor_builder = (
993
+ _premise_anchor_candidates_v2
994
+ if planner_version == "v2"
995
+ else _premise_anchor_candidates_v1
996
+ )
997
+ inferred_anchors = anchor_builder(premise, character_block)
998
+ explicit_anchors = _explicit_include_anchors(resolved_details)
999
  if inferred_anchors:
1000
  inferred_lines = "\n".join(f"Include {anchor}" for anchor in inferred_anchors)
1001
  resolved_details = "\n".join(
 
1008
  resolved_title = _text(title)
1009
  if not resolved_title:
1010
  resolved_title = (
1011
+ _derived_story_title(
1012
+ premise,
1013
+ character_block,
1014
+ explicit_anchors[0] if explicit_anchors else "",
1015
+ resolved_setting,
1016
+ )
1017
  if specific_brief
1018
  else "A SmolGPT Fable"
1019
  )
 
1022
  character_block,
1023
  resolved_details,
1024
  )
1025
+ beat_builder = (
1026
+ _anchored_fallback_beats_v2
1027
+ if planner_version == "v2"
1028
+ else _anchored_fallback_beats_v1
1029
+ )
1030
+ resolved_moments = _text(important_moments) or beat_builder(
1031
+ premise,
1032
+ resolved_setting,
1033
+ character_block,
1034
+ explicit_anchors[0] if explicit_anchors else "",
1035
+ inferred_anchors[0] if inferred_anchors else "",
1036
  count,
1037
  )
1038
  canvas, story_input = build_canvas(
 
1048
  or "Resolve the central conflict and end with a clear emotional change.",
1049
  _text(structure) or "linear-three-act",
1050
  count,
1051
+ (55, 100) if planner_version == "v2" else None,
1052
  )
1053
  return canvas, story_input, SCENE_TOKEN_BUDGETS[count]
1054
 
1055
 
1056
+ def prepare_studio_story(
1057
+ story_idea: Optional[str],
1058
+ genre: Optional[str],
1059
+ scene_count: Any,
1060
+ main_characters: Optional[str],
1061
+ setting: Optional[str],
1062
+ title: Optional[str],
1063
+ point_of_view: Optional[str],
1064
+ structure: Optional[str],
1065
+ important_moments: Optional[str],
1066
+ details: Optional[str],
1067
+ ending_target: Optional[str],
1068
+ ) -> Tuple[str, str, int]:
1069
+ """Build the current public v2 Studio plan without rebinding v1 corpora."""
1070
+
1071
+ return prepare_story(
1072
+ story_idea,
1073
+ genre,
1074
+ scene_count,
1075
+ main_characters,
1076
+ setting,
1077
+ title,
1078
+ point_of_view,
1079
+ structure,
1080
+ important_moments,
1081
+ details,
1082
+ ending_target,
1083
+ planner_version="v2",
1084
+ )
1085
+
1086
+
1087
  def _story_prompt(canvas: str) -> str:
1088
  text = canvas.strip()
1089
  required = (
 
1193
  if value is not None and int(value) > 0:
1194
  return int(value)
1195
  raise ArtifactError(
1196
+ "The model config needs context_length or max_position_embeddings."
1197
  )
1198
 
1199
 
1200
  @torch.inference_mode()
1201
+ def generate_continuation_with_trace(
1202
  bundle: ModelBundle,
1203
  prompt: str,
1204
  temperature: float,
 
1208
  max_new_tokens: int,
1209
  progress: Optional[gr.Progress] = None,
1210
  stop_strings: Tuple[str, ...] = (),
1211
+ ) -> GenerationResult:
1212
+ """Generate text while retaining exact pre-decode completion telemetry."""
1213
+
1214
  tokenizer = bundle.tokenizer
1215
  context_length = _model_context_length(bundle.model)
1216
+ prompt_format = getattr(bundle, "prompt_format", RAW_PROMPT_FORMAT)
1217
+ try:
1218
+ contract_context_length = product_context_length_for_prompt_format(
1219
+ prompt_format
1220
+ )
1221
+ except ValueError as exc:
1222
+ raise ArtifactError(str(exc)) from exc
1223
+ if contract_context_length is not None:
1224
+ context_length = min(context_length, contract_context_length)
1225
+ token_ids = generation_prompt_ids(
1226
+ tokenizer,
1227
+ prompt,
1228
+ prompt_format,
1229
+ )
1230
  if len(token_ids) >= context_length:
1231
  raise StudioError(
1232
  f"The prompt uses {len(token_ids)} tokens, but this model supports "
1233
  f"{context_length}. Shorten the canvas or story."
1234
  )
1235
+ requested_budget = int(max_new_tokens)
1236
+ if requested_budget < 0:
1237
  raise StudioError("The story length must be zero or greater.")
1238
  # Stop at the learned position boundary instead of sliding the opening
1239
  # canvas out of view. That keeps names, required details, and scene count in
1240
  # every attention prefix.
1241
+ budget = min(requested_budget, context_length - len(token_ids))
1242
  generated: List[int] = []
1243
  past_key_values: Any = None
1244
  cache_active = True
1245
+ stop_reason = "token-budget"
1246
  for index in range(budget):
1247
  step_ids = token_ids if not cache_active or past_key_values is None else token_ids[-1:]
1248
  inputs = torch.tensor([step_ids], dtype=torch.long, device=bundle.device)
 
1269
  outputs = bundle.model(input_ids=inputs)
1270
  logits = getattr(outputs, "logits", None)
1271
  if logits is None:
1272
+ raise StudioError("The model did not return causal language logits.")
1273
  next_cache = getattr(outputs, "past_key_values", None)
1274
  if cache_active and next_cache is None:
1275
  # Models exported before KV-cache support accept extra kwargs but
 
1277
  cache_active = False
1278
  elif cache_active:
1279
  past_key_values = next_cache
1280
+ # Requested names/details live in the prompt and must remain copyable.
1281
+ # Repetition control applies only to tokens emitted by the model.
1282
  prior_ids = torch.tensor(
1283
+ generated[-256:], dtype=torch.long, device=bundle.device
1284
  )
1285
  value = _sample_next(
1286
  logits[0, -1],
 
1291
  float(repetition_penalty),
1292
  )
1293
  if value == int(tokenizer.eos_token_id):
1294
+ stop_reason = "eos"
1295
  break
1296
  token_ids.append(value)
1297
  generated.append(value)
1298
  if stop_strings:
1299
  partial = tokenizer.decode(generated, skip_special_tokens=True)
1300
  if any(marker in partial for marker in stop_strings):
1301
+ stop_reason = "stop-string"
1302
  break
1303
  if progress is not None and (index + 1) % 24 == 0:
1304
  progress(
1305
  (index + 1) / max(budget, 1),
1306
  desc="Writing your story",
1307
  )
1308
+ continuation = tokenizer.decode(generated, skip_special_tokens=True).strip()
1309
+ return GenerationResult(
1310
+ continuation=continuation,
1311
+ trace=GenerationTrace(
1312
+ requested_token_budget=requested_budget,
1313
+ effective_token_budget=budget,
1314
+ emitted_continuation_token_count=len(generated),
1315
+ stop_reason=stop_reason,
1316
+ ),
1317
+ )
1318
+
1319
+
1320
+ def generate_continuation(
1321
+ bundle: ModelBundle,
1322
+ prompt: str,
1323
+ temperature: float,
1324
+ top_p: float,
1325
+ top_k: int,
1326
+ repetition_penalty: float,
1327
+ max_new_tokens: int,
1328
+ progress: Optional[gr.Progress] = None,
1329
+ stop_strings: Tuple[str, ...] = (),
1330
+ ) -> str:
1331
+ """Backward-compatible Studio text path; telemetry is available separately."""
1332
+
1333
+ return generate_continuation_with_trace(
1334
+ bundle,
1335
+ prompt,
1336
+ temperature,
1337
+ top_p,
1338
+ top_k,
1339
+ repetition_penalty,
1340
+ max_new_tokens,
1341
+ progress=progress,
1342
+ stop_strings=stop_strings,
1343
+ ).continuation
1344
 
1345
 
1346
  def _story_filename(story: str) -> str:
 
1396
  )
1397
 
1398
 
1399
+ @spaces.GPU(duration=120)
1400
  def generate_story(
1401
  canvas: str,
1402
  temperature: float,
 
1422
  raise _ui_error(f"Story generation failed: {exc}") from exc
1423
 
1424
 
1425
+ @spaces.GPU(duration=120)
1426
  def write_story_from_form(
1427
  story_idea: Optional[str],
1428
  genre: Optional[str],
 
1444
  """Run the complete public Studio flow as one reliable queued event."""
1445
  try:
1446
  use_advanced = bool(advanced_enabled)
1447
+ _, story_input, max_new_tokens = prepare_studio_story(
1448
  story_idea,
1449
  genre,
1450
  scene_count,
 
1784
  opacity: .62;
1785
  }
1786
 
1787
+ .story-shape fieldset:has(input[type="radio"]) > .wrap {
 
1788
  display: grid !important;
1789
  grid-template-columns: repeat(2, minmax(0, 1fr));
1790
  gap: 8px !important;
 
1980
  .character-row { width: 100%; }
1981
  .character-row > .character-row { padding: 10px !important; }
1982
  .story-stage { min-height: 320px; }
1983
+ .story-shape fieldset:has(input[type="radio"]) > .wrap {
 
1984
  grid-template-columns: 1fr;
1985
  }
1986
  .status-panel { grid-template-columns: 1fr; gap: 5px; }
 
2008
  --color-text: #111827;
2009
  --color-muted: #4b5563;
2010
  --color-success: #16a34a;
2011
+ --color-success-ink: #0f6b31;
2012
  --color-warning: #d97706;
2013
  --color-danger: #dc2626;
2014
  --color-line: rgba(38, 61, 91, .32);
 
2040
  --color-canvas: #0d1726;
2041
  --color-text: #f8fafc;
2042
  --color-muted: #cbd5e1;
2043
+ --color-secondary: #b9eaff;
2044
  --color-line: rgba(148, 196, 219, .48);
2045
  --color-focus: #82d9f7;
2046
+ --color-success-ink: #86efac;
2047
  --studio-panel: #1b2d45;
2048
  --studio-bg: var(--color-canvas);
2049
  --studio-paper: var(--color-surface);
 
2087
  gap: var(--space-8) !important;
2088
  }
2089
  .character-list { gap: var(--space-3) !important; }
2090
+ .character-list .character-list > .styler {
2091
+ display: grid !important;
2092
+ grid-template-columns: minmax(0, 1fr) !important;
2093
+ gap: var(--space-3) !important;
2094
+ width: 100% !important;
2095
+ min-width: 0 !important;
2096
+ }
2097
  .character-card {
2098
  position: relative;
2099
  padding: var(--space-3) !important;
 
2126
  border-color: var(--color-secondary) !important;
2127
  box-shadow: var(--sketch-shadow), 0 0 0 3px rgba(73, 182, 229, .28) !important;
2128
  }
2129
+ .story-shape fieldset:has(input[type="radio"]) > .wrap {
2130
+ grid-template-columns: 1fr !important;
2131
+ }
2132
  .story-shape label,
2133
+ .gradio-container label:has(input[type="radio"]) { min-height: 44px !important; }
2134
  .studio-primary {
2135
  color: #111827 !important;
2136
  border: 1.5px solid var(--color-secondary) !important;
 
2158
  .gradio-container input:focus-visible,
2159
  .gradio-container textarea:focus-visible,
2160
  .gradio-container [role="radio"]:focus-visible,
2161
+ .gradio-container label:has(input[type="radio"]):focus-within,
2162
  .gradio-container summary:focus-visible,
2163
  .gradio-container [role="tab"]:focus-visible,
2164
  .gradio-container input[type="file"]:focus-visible {
 
2191
  .character-list #character-1-card:has(.character-card)::before { content: "1"; }
2192
  .character-list #character-2-card:has(.character-card)::before { content: "2"; }
2193
  .character-card .character-card::before { display: none !important; }
2194
+ .character-card textarea { max-width: 100% !important; }
2195
+ .character-name-field textarea {
2196
+ min-height: 44px !important;
2197
+ height: 44px !important;
2198
+ resize: none !important;
2199
+ }
2200
+ .character-about-field textarea {
2201
+ min-height: 72px !important;
2202
+ resize: vertical;
2203
+ }
2204
  .character-card input { min-height: 44px !important; }
2205
+ .character-card .form { gap: var(--space-2) !important; }
2206
 
2207
  /* Selected story choices need an unmistakable filled center dot. */
2208
  .gradio-container input[type="radio"] {
 
2219
  }
2220
  .gradio-container input[type="radio"]:checked {
2221
  border-color: var(--color-primary) !important;
2222
+ background: radial-gradient(circle at center, var(--color-primary) 0 5px, var(--color-surface) 5.5px 100%) !important;
2223
  }
2224
  .gradio-container label:has(input[type="radio"]:checked) {
2225
  border-color: var(--color-primary) !important;
 
2246
  .advanced-toggle input[type="checkbox"]:checked {
2247
  background: radial-gradient(circle at 32px 50%, #ffffff 0 7px, transparent 8px), var(--color-primary) !important;
2248
  }
2249
+ .advanced-toggle label {
2250
+ min-height: 48px !important;
2251
+ align-items: center !important;
2252
+ touch-action: manipulation;
2253
+ }
2254
+ .advanced-panel,
2255
+ .advanced-panel .advanced-panel {
2256
+ min-width: 0 !important;
2257
+ margin: 0 !important;
2258
+ padding: 0 !important;
2259
+ border: 0 !important;
2260
+ border-radius: 0 !important;
2261
+ background: transparent !important;
2262
+ box-shadow: none !important;
2263
+ }
2264
+ .advanced-panel:has(.advanced-panel) {
2265
  margin-top: var(--space-2) !important;
2266
  padding: var(--space-4) !important;
2267
  border: 1.5px dashed var(--color-line) !important;
2268
  border-radius: var(--radius-card) !important;
2269
  background: color-mix(in srgb, var(--studio-panel) 62%, transparent) !important;
2270
  }
2271
+ .advanced-panel .advanced-panel > .styler {
2272
+ width: 100% !important;
2273
+ min-width: 0 !important;
2274
+ background: transparent !important;
2275
+ }
2276
+
2277
+ /* Compact, keyboard-first scene stepper. The full button text remains the
2278
+ accessible name while the drawn symbols stay visually compact. */
2279
+ .scene-stepper-field,
2280
+ .scene-stepper-field .scene-stepper-field {
2281
+ min-width: 0 !important;
2282
+ margin: 0 !important;
2283
+ padding: 0 !important;
2284
+ border: 0 !important;
2285
+ border-radius: 0 !important;
2286
+ background: transparent !important;
2287
+ box-shadow: none !important;
2288
+ }
2289
+ .scene-stepper-field:has(.scene-stepper-field) {
2290
+ padding: var(--space-3) !important;
2291
+ border: 1.5px solid var(--color-line) !important;
2292
+ border-radius: var(--radius-card) !important;
2293
+ background: color-mix(in srgb, var(--studio-panel) 52%, var(--color-surface)) !important;
2294
+ box-shadow: var(--sketch-shadow) !important;
2295
+ }
2296
+ .scene-stepper-field .scene-stepper-field > .styler {
2297
+ width: 100% !important;
2298
+ min-width: 0 !important;
2299
+ background: transparent !important;
2300
+ }
2301
+ .scene-stepper-label {
2302
+ min-width: 0 !important;
2303
+ margin: 0 0 var(--space-2) !important;
2304
+ padding: 0 !important;
2305
+ border: 0 !important;
2306
+ background: transparent !important;
2307
+ }
2308
+ .scene-stepper-label h3 {
2309
+ margin: 0 !important;
2310
+ color: var(--color-text) !important;
2311
+ font-family: "Delius Swash Caps", "Comic Sans MS", cursive !important;
2312
+ font-size: 16px !important;
2313
+ font-weight: 400 !important;
2314
+ }
2315
+ .scene-stepper-label p {
2316
+ margin: 2px 0 0 !important;
2317
+ color: var(--color-muted) !important;
2318
+ font-size: 13px !important;
2319
+ }
2320
+ .scene-stepper {
2321
+ display: grid !important;
2322
+ grid-template-columns: 48px minmax(72px, 96px) 48px !important;
2323
+ gap: var(--space-2) !important;
2324
+ justify-content: start !important;
2325
+ align-items: stretch !important;
2326
+ min-width: 0 !important;
2327
+ flex-wrap: nowrap !important;
2328
+ }
2329
+ .scene-stepper > * { min-width: 0 !important; }
2330
+ button.scene-stepper-button,
2331
+ .scene-stepper-button > button {
2332
+ width: 48px !important;
2333
+ min-width: 48px !important;
2334
+ min-height: 48px !important;
2335
+ }
2336
+ .scene-stepper-button,
2337
+ .scene-stepper-button button {
2338
+ padding: 0 !important;
2339
+ border: 1.5px solid var(--color-secondary) !important;
2340
+ border-radius: 46% 54% 49% 51% !important;
2341
+ background: var(--color-surface) !important;
2342
+ box-shadow: var(--sketch-shadow) !important;
2343
+ color: var(--color-secondary) !important;
2344
+ font-size: 0 !important;
2345
+ touch-action: manipulation;
2346
+ }
2347
+ button.scene-stepper-button::before,
2348
+ .scene-stepper-button > button::before {
2349
+ display: block;
2350
+ font-family: "JetBrains Mono", ui-monospace, monospace;
2351
+ font-size: 25px;
2352
+ line-height: 1;
2353
+ }
2354
+ button.scene-stepper-minus::before,
2355
+ .scene-stepper-minus > button::before { content: "\2212"; }
2356
+ button.scene-stepper-plus::before,
2357
+ .scene-stepper-plus > button::before { content: "+"; }
2358
+ button.scene-stepper-button:hover:not(:disabled),
2359
+ .scene-stepper-button > button:hover:not(:disabled) {
2360
+ background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)) !important;
2361
+ }
2362
+ button.scene-stepper-button:disabled,
2363
+ .scene-stepper-button > button:disabled {
2364
+ cursor: not-allowed !important;
2365
+ opacity: .42 !important;
2366
+ box-shadow: none !important;
2367
+ }
2368
+ .scene-stepper-value {
2369
+ width: 100% !important;
2370
+ max-width: 96px !important;
2371
+ min-width: 72px !important;
2372
+ }
2373
+ .scene-stepper-value input {
2374
+ width: 100% !important;
2375
+ min-height: 48px !important;
2376
+ padding-inline: var(--space-2) !important;
2377
+ border: 1.5px solid var(--color-secondary) !important;
2378
+ border-radius: var(--radius-control) !important;
2379
+ background: var(--color-surface) !important;
2380
+ color: var(--color-text) !important;
2381
+ font-family: "JetBrains Mono", ui-monospace, monospace !important;
2382
+ font-size: 22px !important;
2383
+ font-variant-numeric: tabular-nums;
2384
+ text-align: center !important;
2385
+ appearance: textfield;
2386
+ }
2387
+ .scene-stepper-value input::-webkit-inner-spin-button,
2388
+ .scene-stepper-value input::-webkit-outer-spin-button {
2389
+ margin: 0;
2390
+ appearance: none;
2391
+ }
2392
+
2393
+ .advanced-toggle label:has(input[type="checkbox"]:checked) {
2394
+ border-radius: var(--radius-control) !important;
2395
+ background: color-mix(in srgb, var(--color-primary) 24%, var(--color-surface)) !important;
2396
+ }
2397
+ .advanced-status {
2398
+ min-width: 0 !important;
2399
+ margin: calc(-1 * var(--space-2)) 0 var(--space-2) !important;
2400
+ padding: 0 var(--space-3) var(--space-2) !important;
2401
+ border: 0 !important;
2402
+ background: transparent !important;
2403
+ }
2404
+ .advanced-state {
2405
+ display: flex;
2406
+ gap: var(--space-2);
2407
+ align-items: baseline;
2408
+ min-width: 0;
2409
+ color: var(--color-muted);
2410
+ font-size: 13px;
2411
+ line-height: 1.45;
2412
+ }
2413
+ .advanced-state strong {
2414
+ flex: 0 0 auto;
2415
+ min-width: 42px;
2416
+ padding: 2px var(--space-2);
2417
+ border: 1px solid var(--color-line);
2418
+ border-radius: 999px;
2419
+ background: var(--color-surface);
2420
+ color: var(--color-text);
2421
+ font-family: "JetBrains Mono", ui-monospace, monospace;
2422
+ font-size: 12px;
2423
+ line-height: 1.5;
2424
+ text-align: center;
2425
+ }
2426
+ .advanced-state.advanced-on strong {
2427
+ border-color: var(--color-success-ink);
2428
+ color: var(--color-success-ink);
2429
+ }
2430
+ .advanced-state span { min-width: 0; overflow-wrap: anywhere; }
2431
+
2432
+ /* In Gradio 6 the advanced radio inputs inherit a fluid input width. Pin the
2433
+ indicator and let the human-readable label take the remaining 410px column. */
2434
+ .advanced-panel fieldset:has(input[type="radio"]) > .wrap {
2435
+ display: grid !important;
2436
+ grid-template-columns: minmax(0, 1fr) !important;
2437
+ gap: var(--space-2) !important;
2438
+ width: 100% !important;
2439
+ min-width: 0 !important;
2440
+ }
2441
+ .advanced-panel label:has(input[type="radio"]) {
2442
+ display: flex !important;
2443
+ flex-flow: row nowrap !important;
2444
+ gap: var(--space-2) !important;
2445
+ align-items: center !important;
2446
+ justify-content: flex-start !important;
2447
+ width: 100% !important;
2448
+ min-width: 0 !important;
2449
+ min-height: 48px !important;
2450
+ padding: var(--space-2) var(--space-3) !important;
2451
+ box-sizing: border-box !important;
2452
+ word-break: normal !important;
2453
+ }
2454
+ .advanced-panel input[type="radio"] {
2455
+ flex: 0 0 20px !important;
2456
+ width: 20px !important;
2457
+ max-width: 20px !important;
2458
+ min-width: 20px !important;
2459
+ }
2460
+ .advanced-panel label:has(input[type="radio"]) > span {
2461
+ flex: 1 1 auto !important;
2462
+ width: auto !important;
2463
+ min-width: 0 !important;
2464
+ max-width: 100% !important;
2465
+ white-space: normal !important;
2466
+ word-break: normal !important;
2467
+ overflow-wrap: break-word !important;
2468
+ }
2469
+
2470
+ /* Gradio controls must shrink inside cards instead of forcing horizontal
2471
+ overflow. Menus stay inside the viewport and touch targets remain 44px. */
2472
+ .creator-grid > *,
2473
+ .creator-form > *,
2474
+ .character-list > *,
2475
+ .character-card,
2476
+ .advanced-panel,
2477
+ .story-actions,
2478
+ .story-shape,
2479
+ .studio-select,
2480
+ .gradio-container .form,
2481
+ .gradio-container .wrap {
2482
+ min-width: 0 !important;
2483
+ max-width: 100% !important;
2484
+ box-sizing: border-box !important;
2485
+ }
2486
+ .gradio-container label:has(input[type="radio"]),
2487
+ .gradio-container [role="option"],
2488
+ .gradio-container button {
2489
+ overflow-wrap: anywhere;
2490
+ white-space: normal !important;
2491
+ }
2492
+ .gradio-container [role="listbox"] {
2493
+ width: min(100%, calc(100vw - 24px)) !important;
2494
+ max-width: calc(100vw - 24px) !important;
2495
+ max-height: min(360px, 60vh) !important;
2496
+ overflow: auto !important;
2497
+ }
2498
+ .character-card input,
2499
+ .character-card textarea,
2500
+ .advanced-panel input,
2501
+ .advanced-panel textarea {
2502
+ width: 100% !important;
2503
+ max-width: 100% !important;
2504
+ box-sizing: border-box !important;
2505
+ }
2506
 
2507
  @media (max-width: 900px) {
2508
  .creator-grid { grid-template-columns: minmax(0, 1fr); }
2509
  }
2510
 
2511
+ @media (max-width: 520px) {
2512
+ .gradio-container { padding-inline: 10px !important; }
2513
+ .scene-stepper { justify-content: center !important; }
2514
+ .character-card:has(.character-card),
2515
+ .advanced-panel:has(.advanced-panel),
2516
+ .scene-stepper-field:has(.scene-stepper-field) { padding: var(--space-3) !important; }
2517
+ .story-actions {
2518
+ display: grid !important;
2519
+ grid-template-columns: minmax(0, 1fr) !important;
2520
+ }
2521
+ .story-actions > *,
2522
+ .story-actions button { width: 100% !important; min-width: 0 !important; }
2523
+ .advanced-state { align-items: flex-start; }
2524
+ }
2525
+
2526
  .main.fillable.app {
2527
  width: min(calc(100vw - 32px), 1280px) !important;
2528
  margin-inline: auto !important;
 
2561
  <header class="studio-head">
2562
  <h1>SmolGPT-Fables Studio</h1>
2563
  <svg class="studio-squiggle" viewBox="0 0 330 14" aria-hidden="true" focusable="false"><path d="M2 9 C 48 1, 84 13, 128 7 S 220 4, 328 8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
2564
+ <p>Give it an idea and see what this small story model writes.</p>
2565
  </header>
2566
  <div class="studio-rule" aria-hidden="true"></div>
2567
  """
 
2594
  placeholder="Mystery, romance, folklore, space adventure",
2595
  info="Use any genre or mix of styles you like.",
2596
  )
2597
+ with gr.Group(elem_classes="scene-stepper-field"):
2598
+ gr.Markdown(
2599
+ "### How many scenes?\nChoose from 1 to 6.",
2600
+ elem_classes="scene-stepper-label",
2601
+ )
2602
+ with gr.Row(elem_classes="scene-stepper"):
2603
+ scene_minus = gr.Button(
2604
+ "Decrease scenes",
2605
+ size="sm",
2606
+ min_width=48,
2607
+ elem_classes=[
2608
+ "scene-stepper-button",
2609
+ "scene-stepper-minus",
2610
+ ],
2611
+ )
2612
+ scene_count = gr.Number(
2613
+ value=DEFAULT_SCENE_COUNT,
2614
+ label="How many scenes?",
2615
+ show_label=False,
2616
+ container=False,
2617
+ step=1,
2618
+ precision=0,
2619
+ min_width=72,
2620
+ elem_id="scene-count-value",
2621
+ elem_classes="scene-stepper-value",
2622
+ )
2623
+ scene_plus = gr.Button(
2624
+ "Increase scenes",
2625
+ size="sm",
2626
+ min_width=48,
2627
+ elem_classes=[
2628
+ "scene-stepper-button",
2629
+ "scene-stepper-plus",
2630
+ ],
2631
+ )
2632
  gr.Markdown(
2633
  "### Main characters\nOne character per card. The second card is optional.",
2634
  elem_classes="character-heading",
 
2643
  placeholder="Mara",
2644
  info="Character 1",
2645
  lines=1,
2646
+ elem_classes="character-name-field",
2647
  )
2648
  first_character_about = gr.Textbox(
2649
  label="About them",
 
2652
  ),
2653
  info="Role, personality, and what they want.",
2654
  lines=2,
2655
+ elem_classes="character-about-field",
2656
  )
2657
  with gr.Group(
2658
  elem_classes=["character-card", "character-card-optional"],
 
2663
  placeholder="Ilyan",
2664
  info="Character 2",
2665
  lines=1,
2666
+ elem_classes="character-name-field",
2667
  )
2668
  second_character_about = gr.Textbox(
2669
  label="About them (optional)",
2670
  placeholder="A wandering artist looking for a home",
2671
  info="Leave both fields blank for a one-character story.",
2672
  lines=2,
2673
+ elem_classes="character-about-field",
2674
  )
2675
  setting = gr.Textbox(
2676
  label="Where does it happen?",
 
2680
 
2681
  advanced_enabled = gr.Checkbox(
2682
  value=False,
2683
+ label="Advanced options",
2684
  info=(
2685
  "Turn this on to customize the title, viewpoint, "
2686
  "story shape, key moments, required details, and ending."
2687
  ),
2688
  elem_classes="advanced-toggle",
2689
  )
2690
+ advanced_status = gr.HTML(
2691
+ advanced_options_status(False),
2692
+ elem_classes="advanced-status",
2693
+ )
2694
  with gr.Group(
2695
  visible=False,
2696
  elem_classes="advanced-panel",
 
2738
  advanced_enabled.change(
2739
  fn=toggle_advanced_options,
2740
  inputs=advanced_enabled,
2741
+ outputs=[advanced_panel, advanced_status],
2742
+ show_progress="hidden",
2743
+ **PRIVATE_EVENT_ARGS,
2744
+ )
2745
+
2746
+ scene_minus.click(
2747
+ fn=decrease_scene_count,
2748
+ inputs=scene_count,
2749
+ outputs=[scene_count, scene_minus, scene_plus],
2750
+ show_progress="hidden",
2751
+ **PRIVATE_EVENT_ARGS,
2752
+ )
2753
+ scene_plus.click(
2754
+ fn=increase_scene_count,
2755
+ inputs=scene_count,
2756
+ outputs=[scene_count, scene_minus, scene_plus],
2757
+ show_progress="hidden",
2758
+ **PRIVATE_EVENT_ARGS,
2759
+ )
2760
+ scene_count.input(
2761
+ fn=sync_scene_stepper,
2762
+ inputs=scene_count,
2763
+ outputs=[scene_minus, scene_plus],
2764
+ show_progress="hidden",
2765
+ **PRIVATE_EVENT_ARGS,
2766
+ )
2767
+ scene_count.blur(
2768
+ fn=normalize_scene_stepper,
2769
+ inputs=scene_count,
2770
+ outputs=[scene_count, scene_minus, scene_plus],
2771
  show_progress="hidden",
2772
  **PRIVATE_EVENT_ARGS,
2773
  )
 
2831
  """
2832
  ## About SmolGPT-Fables Studio
2833
 
2834
+ SmolGPT-Fables Studio is a hands-on demo for SmolGPT-Fables v1. The form keeps its internal document structure out of the way, so you can focus on characters, places, and what happens.
2835
 
2836
  Choose between one and six scenes. The Studio uses that choice to shape the hidden story plan and keep the generation budget proportional to the requested story.
2837
 
 
2895
  return demo
2896
 
2897
 
2898
+ if _running_on_huggingface_space():
2899
+ # ZeroGPU supports CUDA placement during startup and recommends loading the
2900
+ # model outside the decorated request so each story does not reload weights.
2901
+ load_model_bundle()
2902
+
2903
+
2904
  demo = build_demo()
2905
 
2906
 
runtime_contract.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stable prompt and artifact contract shared by the Studio and product SFT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any, Mapping, Sequence
10
+
11
+
12
+ CHAT_PROMPT_FORMAT = "smolgpt-fables-chat-v3"
13
+ SMOLLM3_CHAT_PROMPT_FORMAT = "smolgpt-fables-smollm3-chat-v5"
14
+ RAW_PROMPT_FORMAT = "smolgpt-fables-raw-v1"
15
+ PRODUCT_CONTEXT_LENGTH = 2048
16
+ SMOLLM3_PRODUCT_CONTEXT_LENGTH = 4096
17
+ CHAT_SYSTEM_PROMPT = (
18
+ "You are SmolGPT-Fables. Output only the requested finished Markdown story "
19
+ "continuation. Begin exactly with `### Scene 01:`. Emit exactly the requested "
20
+ "number of consecutive, zero-padded `### Scene NN:` sections. Those scene "
21
+ "headings are the only headings allowed in the output: never emit an H1, H2, "
22
+ "any other H3, an H4 or deeper heading, or any other section before, between, "
23
+ "or after them. Follow the canvas exactly and copy every required name and "
24
+ "detail verbatim. Stop immediately after the requested final scene. Never "
25
+ "repeat or quote the story title, metadata, canvas, `## Story`, or any instruction."
26
+ )
27
+ CHAT_PROMPT_TRANSFORM_VERSION = "scene-output-contract-v1"
28
+ CHAT_STORY_BOUNDARY = "## Story\n\n"
29
+ CHAT_OUTPUT_CONTRACT_TEMPLATE = (
30
+ "Output contract (follow exactly): use only these H3 heading prefixes, in this "
31
+ "order: {headings}. Write exactly {scene_count} scenes; emit no other heading "
32
+ "or section; stop immediately after completing `### Scene {final_scene:02d}:`.\n\n"
33
+ )
34
+ SMOLLM3_PROMPT_TRANSFORM_VERSION = "natural-fable-scene-contract-v3"
35
+ SMOLLM3_STORY_BOUNDARY = "## Story\n\n"
36
+ SMOLLM3_SCENE_WORD_RANGE = (45, 115)
37
+ SMOLLM3_SYSTEM_PROMPT = (
38
+ "You are SmolGPT-Fables. Write a vivid, complete fable from the user's canvas. "
39
+ "Output only the finished story continuation. Begin with `### Scene 01:` and "
40
+ "emit exactly the requested consecutive, zero-padded scene sections. A scene "
41
+ "heading may include a short title after the colon. Use no other Markdown "
42
+ "heading. Copy every required name, setting, and unusual detail verbatim. Make "
43
+ "each character's described role, personality, and desire affect what they do. "
44
+ "Write concrete action and dialogue instead of summarizing instructions. Keep "
45
+ "each scene concise, make every scene change the situation, and resolve the "
46
+ "ending target inside the final scene. Stop immediately after the final sentence; "
47
+ "never add notes, analysis, a moral label, an ending section, or quoted canvas "
48
+ "text. /no_think"
49
+ )
50
+ _SCENE_COUNT_PATTERN = re.compile(r"(?m)^- Scene Count: ([0-9]+)$")
51
+ _TARGET_SCENES_PATTERN = re.compile(r"(?m)^- Target scenes: ([0-9]+)$")
52
+ _MIN_SCENE_COUNT = 1
53
+ _MAX_SCENE_COUNT = 6
54
+ SUPPORTED_PROMPT_FORMATS = frozenset(
55
+ {CHAT_PROMPT_FORMAT, SMOLLM3_CHAT_PROMPT_FORMAT, RAW_PROMPT_FORMAT}
56
+ )
57
+ CHAT_PROMPT_FORMATS = frozenset(
58
+ {CHAT_PROMPT_FORMAT, SMOLLM3_CHAT_PROMPT_FORMAT}
59
+ )
60
+ COMMON_ARTIFACT_FILES = ("config.json", "tokenizer.json")
61
+ CUSTOM_CODE_FILES = ("configuration_smolgpt.py", "modeling_smolgpt.py")
62
+
63
+
64
+ def _render_output_contract(scene_count: int) -> str:
65
+ headings = ", ".join(
66
+ f"`### Scene {index:02d}:`" for index in range(1, scene_count + 1)
67
+ )
68
+ return CHAT_OUTPUT_CONTRACT_TEMPLATE.format(
69
+ headings=headings,
70
+ scene_count=scene_count,
71
+ final_scene=scene_count,
72
+ )
73
+
74
+
75
+ def prompt_contract_sha256() -> str:
76
+ payload = {
77
+ "format": CHAT_PROMPT_FORMAT,
78
+ "system": CHAT_SYSTEM_PROMPT,
79
+ "messages": ["system", "user", "assistant"],
80
+ "assistant_only_loss": True,
81
+ "prompt_transform": {
82
+ "version": CHAT_PROMPT_TRANSFORM_VERSION,
83
+ "scene_count_pattern": _SCENE_COUNT_PATTERN.pattern,
84
+ "target_scenes_pattern": _TARGET_SCENES_PATTERN.pattern,
85
+ "scene_count_range": [_MIN_SCENE_COUNT, _MAX_SCENE_COUNT],
86
+ "story_boundary": CHAT_STORY_BOUNDARY,
87
+ "rendered_output_contracts": {
88
+ str(scene_count): _render_output_contract(scene_count)
89
+ for scene_count in range(_MIN_SCENE_COUNT, _MAX_SCENE_COUNT + 1)
90
+ },
91
+ },
92
+ }
93
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
94
+ return hashlib.sha256(encoded).hexdigest()
95
+
96
+
97
+ def smollm3_render_output_contract(scene_count: int) -> str:
98
+ """Render the exact SmolLM3 v5 output contract used for SFT."""
99
+
100
+ if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
101
+ raise ValueError("v4 scene count must be between 1 and 6")
102
+ headings = ", ".join(
103
+ f"`### Scene {index:02d}:`"
104
+ for index in range(1, scene_count + 1)
105
+ )
106
+ minimum, maximum = SMOLLM3_SCENE_WORD_RANGE
107
+ return (
108
+ "Output contract (follow exactly):\n"
109
+ f"- Use these scene prefixes in order: {headings}.\n"
110
+ f"- Write exactly {scene_count} scenes and {minimum}-{maximum} words per scene.\n"
111
+ "- Use no heading except those scene headings.\n"
112
+ "- Copy every item on `Must include` verbatim into the story.\n"
113
+ "- Show the character-role details through decisions, action, or dialogue.\n"
114
+ f"- Resolve the ending target in Scene {scene_count:02d} and stop.\n\n"
115
+ )
116
+
117
+
118
+ def _canonical_smollm3_scene_count(prompt: str) -> int:
119
+ scene_counts = _SCENE_COUNT_PATTERN.findall(prompt)
120
+ target_counts = _TARGET_SCENES_PATTERN.findall(prompt)
121
+ if len(scene_counts) != 1 or len(target_counts) != 1:
122
+ raise ValueError("v4 prompt needs one Scene Count and one Target scenes line")
123
+ scene_count = int(scene_counts[0])
124
+ target_count = int(target_counts[0])
125
+ if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
126
+ raise ValueError("v4 scene count must be between 1 and 6")
127
+ if scene_count != target_count:
128
+ raise ValueError("v4 Scene Count and Target scenes must match")
129
+ return scene_count
130
+
131
+
132
+ def smollm3_transform_prompt(prompt: str) -> str:
133
+ """Apply the exact SmolLM3 v5 prompt transform used for SFT."""
134
+
135
+ scene_count = _canonical_smollm3_scene_count(prompt)
136
+ if not prompt.endswith(SMOLLM3_STORY_BOUNDARY):
137
+ raise ValueError("v4 prompt must end at the canonical Story boundary")
138
+ return (
139
+ prompt[: -len(SMOLLM3_STORY_BOUNDARY)]
140
+ + smollm3_render_output_contract(scene_count)
141
+ + SMOLLM3_STORY_BOUNDARY
142
+ )
143
+
144
+
145
+ def smollm3_chat_messages(
146
+ prompt: str,
147
+ completion: str | None = None,
148
+ ) -> list[dict[str, str]]:
149
+ messages = [
150
+ {"role": "system", "content": SMOLLM3_SYSTEM_PROMPT},
151
+ {"role": "user", "content": smollm3_transform_prompt(prompt)},
152
+ ]
153
+ if completion is not None:
154
+ messages.append({"role": "assistant", "content": completion})
155
+ return messages
156
+
157
+
158
+ def smollm3_prompt_contract_sha256() -> str:
159
+ """Hash the exact SmolLM3 v5 prompt contract used for SFT."""
160
+
161
+ payload = {
162
+ "format": SMOLLM3_CHAT_PROMPT_FORMAT,
163
+ "system": SMOLLM3_SYSTEM_PROMPT,
164
+ "messages": ["system", "user", "assistant"],
165
+ "assistant_only_loss": True,
166
+ "thinking": False,
167
+ "context_length": SMOLLM3_PRODUCT_CONTEXT_LENGTH,
168
+ "transform_version": SMOLLM3_PROMPT_TRANSFORM_VERSION,
169
+ "story_boundary": SMOLLM3_STORY_BOUNDARY,
170
+ "scene_count_pattern": _SCENE_COUNT_PATTERN.pattern,
171
+ "target_scenes_pattern": _TARGET_SCENES_PATTERN.pattern,
172
+ "scene_word_range": list(SMOLLM3_SCENE_WORD_RANGE),
173
+ "rendered_contracts": {
174
+ str(count): smollm3_render_output_contract(count)
175
+ for count in range(_MIN_SCENE_COUNT, _MAX_SCENE_COUNT + 1)
176
+ },
177
+ }
178
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
179
+ return hashlib.sha256(encoded).hexdigest()
180
+
181
+
182
+ def raw_prompt_contract_sha256() -> str:
183
+ payload = {
184
+ "format": RAW_PROMPT_FORMAT,
185
+ "messages": ["raw-markdown-prompt"],
186
+ "bos_prefix": True,
187
+ }
188
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
189
+ return hashlib.sha256(encoded).hexdigest()
190
+
191
+
192
+ def product_context_length_for_prompt_format(prompt_format: str) -> int | None:
193
+ if prompt_format == CHAT_PROMPT_FORMAT:
194
+ return PRODUCT_CONTEXT_LENGTH
195
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
196
+ return SMOLLM3_PRODUCT_CONTEXT_LENGTH
197
+ if prompt_format == RAW_PROMPT_FORMAT:
198
+ return None
199
+ raise ValueError(f"unsupported prompt format: {prompt_format}")
200
+
201
+
202
+ def _has_model_weights(root: Path) -> bool:
203
+ return (root / "model.safetensors").is_file() or (
204
+ root / "model.safetensors.index.json"
205
+ ).is_file()
206
+
207
+
208
+ def validate_transformers_artifact(root: Path) -> tuple[Mapping[str, Any], str]:
209
+ """Validate either the legacy custom export or a standard Transformers LM."""
210
+
211
+ missing = [name for name in COMMON_ARTIFACT_FILES if not (root / name).is_file()]
212
+ if missing:
213
+ raise ValueError("model repository is missing: " + ", ".join(missing))
214
+ if not _has_model_weights(root):
215
+ raise ValueError("model repository is missing Safetensors weights")
216
+ try:
217
+ config = json.loads((root / "config.json").read_text(encoding="utf-8"))
218
+ except (OSError, json.JSONDecodeError) as exc:
219
+ raise ValueError(f"could not read config.json: {exc}") from exc
220
+ if not isinstance(config, Mapping):
221
+ raise ValueError("config.json must contain a JSON object")
222
+ auto_map = config.get("auto_map")
223
+ if isinstance(auto_map, Mapping) and auto_map.get("AutoModelForCausalLM"):
224
+ missing_code = [name for name in CUSTOM_CODE_FILES if not (root / name).is_file()]
225
+ if missing_code:
226
+ raise ValueError("custom model repository is missing: " + ", ".join(missing_code))
227
+ return config, "custom"
228
+ architectures = config.get("architectures")
229
+ if not isinstance(architectures, list) or not all(
230
+ isinstance(value, str) and value for value in architectures
231
+ ):
232
+ raise ValueError("standard model config needs a non-empty architectures list")
233
+ if not isinstance(config.get("model_type"), str) or not config["model_type"]:
234
+ raise ValueError("standard model config needs model_type")
235
+ return config, "standard"
236
+
237
+
238
+ def _expected_prompt_contract_sha256(prompt_format: str) -> str:
239
+ if prompt_format == CHAT_PROMPT_FORMAT:
240
+ return prompt_contract_sha256()
241
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
242
+ return smollm3_prompt_contract_sha256()
243
+ if prompt_format == RAW_PROMPT_FORMAT:
244
+ return raw_prompt_contract_sha256()
245
+ raise ValueError(f"unsupported prompt format: {prompt_format}")
246
+
247
+
248
+ def _manifest_prompt_binding(
249
+ manifest: Mapping[str, Any],
250
+ *,
251
+ artifact_kind: str,
252
+ ) -> tuple[str, str] | None:
253
+ """Read legacy top-level or SmolLM3 nested bindings without ambiguity."""
254
+
255
+ top_format_present = "prompt_format" in manifest
256
+ top_hash_present = "prompt_contract_sha256" in manifest
257
+ nested_present = "prompt_contract" in manifest
258
+ if not top_format_present and not top_hash_present and not nested_present:
259
+ if artifact_kind == "custom":
260
+ return None
261
+ raise ValueError("training manifest has no supported prompt_format")
262
+ if top_format_present != top_hash_present:
263
+ raise ValueError("training manifest prompt binding is incomplete")
264
+
265
+ bindings: list[tuple[str, str]] = []
266
+ if top_format_present:
267
+ bindings.append(
268
+ (manifest.get("prompt_format"), manifest.get("prompt_contract_sha256"))
269
+ )
270
+ if nested_present:
271
+ nested = manifest.get("prompt_contract")
272
+ if (
273
+ not isinstance(nested, Mapping)
274
+ or set(nested) != {"format", "sha256", "thinking"}
275
+ or nested.get("thinking") is not False
276
+ ):
277
+ raise ValueError("training manifest nested prompt contract is invalid")
278
+ bindings.append((nested.get("format"), nested.get("sha256")))
279
+ if any(
280
+ not isinstance(value, str)
281
+ or not value
282
+ or not isinstance(digest, str)
283
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
284
+ for value, digest in bindings
285
+ ):
286
+ raise ValueError("training manifest prompt binding is invalid")
287
+ if any(binding != bindings[0] for binding in bindings[1:]):
288
+ raise ValueError("training manifest prompt bindings conflict")
289
+ return bindings[0]
290
+
291
+
292
+ def prompt_format_for_artifact(root: Path, artifact_kind: str, tokenizer: Any) -> str:
293
+ manifest_path = root / "training_manifest.json"
294
+ if manifest_path.is_file():
295
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
296
+ if not isinstance(manifest, Mapping):
297
+ raise ValueError("training manifest must contain a JSON object")
298
+ binding = _manifest_prompt_binding(manifest, artifact_kind=artifact_kind)
299
+ if binding is None:
300
+ # Checked-in pre-contract SmolGPT exports are unambiguously the raw
301
+ # Markdown/BOS path. Any partially declared contract still fails.
302
+ return RAW_PROMPT_FORMAT
303
+ value, contract_hash = binding
304
+ if value not in SUPPORTED_PROMPT_FORMATS:
305
+ raise ValueError("training manifest has no supported prompt_format")
306
+ if artifact_kind == "standard" and value not in CHAT_PROMPT_FORMATS:
307
+ raise ValueError("standard product manifest must use the chat prompt format")
308
+ if artifact_kind == "custom" and value == SMOLLM3_CHAT_PROMPT_FORMAT:
309
+ raise ValueError("SmolLM3 chat-v5 requires a standard model artifact")
310
+ expected_sha = _expected_prompt_contract_sha256(value)
311
+ if contract_hash != expected_sha:
312
+ raise ValueError("training manifest prompt contract hash does not match runtime")
313
+ if value in CHAT_PROMPT_FORMATS and not getattr(
314
+ tokenizer, "chat_template", None
315
+ ):
316
+ raise ValueError("chat prompt format requires a tokenizer chat_template")
317
+ return str(value)
318
+ if artifact_kind == "standard" and getattr(tokenizer, "chat_template", None):
319
+ return CHAT_PROMPT_FORMAT
320
+ return RAW_PROMPT_FORMAT
321
+
322
+
323
+ def _canonical_scene_count(prompt: str) -> int:
324
+ scene_counts = _SCENE_COUNT_PATTERN.findall(prompt)
325
+ target_counts = _TARGET_SCENES_PATTERN.findall(prompt)
326
+ if len(scene_counts) != 1:
327
+ raise ValueError(
328
+ "chat-v3 prompt must contain exactly one canonical '- Scene Count: N' line"
329
+ )
330
+ if len(target_counts) != 1:
331
+ raise ValueError(
332
+ "chat-v3 prompt must contain exactly one canonical '- Target scenes: N' line"
333
+ )
334
+ scene_count = int(scene_counts[0])
335
+ target_count = int(target_counts[0])
336
+ if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
337
+ raise ValueError("chat-v3 Scene Count must be between 1 and 6")
338
+ if not _MIN_SCENE_COUNT <= target_count <= _MAX_SCENE_COUNT:
339
+ raise ValueError("chat-v3 Target scenes must be between 1 and 6")
340
+ if scene_count != target_count:
341
+ raise ValueError("chat-v3 Scene Count and Target scenes must match")
342
+ return scene_count
343
+
344
+
345
+ def _transform_chat_prompt(prompt: str) -> str:
346
+ scene_count = _canonical_scene_count(prompt)
347
+ if not prompt.endswith(CHAT_STORY_BOUNDARY):
348
+ raise ValueError("chat-v3 prompt must end at the canonical '## Story' boundary")
349
+ return (
350
+ prompt[: -len(CHAT_STORY_BOUNDARY)]
351
+ + _render_output_contract(scene_count)
352
+ + CHAT_STORY_BOUNDARY
353
+ )
354
+
355
+
356
+ def chat_messages(prompt: str, completion: str | None = None) -> list[dict[str, str]]:
357
+ messages = [
358
+ {"role": "system", "content": CHAT_SYSTEM_PROMPT},
359
+ {"role": "user", "content": _transform_chat_prompt(prompt)},
360
+ ]
361
+ if completion is not None:
362
+ messages.append({"role": "assistant", "content": completion})
363
+ return messages
364
+
365
+
366
+ def _chat_messages_for_format(
367
+ prompt: str,
368
+ completion: str | None,
369
+ prompt_format: str,
370
+ ) -> list[dict[str, str]]:
371
+ if prompt_format == CHAT_PROMPT_FORMAT:
372
+ return chat_messages(prompt, completion)
373
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
374
+ return smollm3_chat_messages(prompt, completion)
375
+ raise ValueError(f"unsupported chat prompt format: {prompt_format}")
376
+
377
+
378
+ def _apply_runtime_chat_template(
379
+ tokenizer: Any,
380
+ messages: list[dict[str, str]],
381
+ *,
382
+ prompt_format: str,
383
+ add_generation_prompt: bool,
384
+ ) -> Any:
385
+ kwargs: dict[str, Any] = {
386
+ "add_generation_prompt": add_generation_prompt,
387
+ "tokenize": True,
388
+ }
389
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
390
+ kwargs["enable_thinking"] = False
391
+ return tokenizer.apply_chat_template(messages, **kwargs)
392
+
393
+
394
+ def _flatten_token_ids(values: Any, *, context: str) -> list[int]:
395
+ """Normalize chat-template outputs across supported Transformers versions."""
396
+
397
+ if isinstance(values, Mapping):
398
+ if "input_ids" not in values:
399
+ raise ValueError(f"{context} returned no input_ids")
400
+ values = values["input_ids"]
401
+ if hasattr(values, "tolist"):
402
+ values = values.tolist()
403
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes, bytearray)):
404
+ raise ValueError(f"{context} returned unsupported token IDs")
405
+ normalized = list(values)
406
+ if normalized and isinstance(normalized[0], Sequence) and not isinstance(
407
+ normalized[0], (str, bytes, bytearray)
408
+ ):
409
+ if len(normalized) != 1:
410
+ raise ValueError(f"{context} returned more than one token sequence")
411
+ normalized = list(normalized[0])
412
+ try:
413
+ return [int(value) for value in normalized]
414
+ except (TypeError, ValueError) as exc:
415
+ raise ValueError(f"{context} returned non-integer token IDs") from exc
416
+
417
+
418
+ def generation_prompt_ids(tokenizer: Any, prompt: str, prompt_format: str) -> list[int]:
419
+ if prompt_format in CHAT_PROMPT_FORMATS:
420
+ values = _apply_runtime_chat_template(
421
+ tokenizer,
422
+ _chat_messages_for_format(prompt, None, prompt_format),
423
+ prompt_format=prompt_format,
424
+ add_generation_prompt=True,
425
+ )
426
+ context = (
427
+ "SmolLM3 v5 generation template"
428
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT
429
+ else "chat generation template"
430
+ )
431
+ return _flatten_token_ids(values, context=context)
432
+ if prompt_format != RAW_PROMPT_FORMAT:
433
+ raise ValueError(f"unsupported prompt format: {prompt_format}")
434
+ return [
435
+ int(tokenizer.bos_token_id),
436
+ *(
437
+ int(value)
438
+ for value in tokenizer.encode(prompt, add_special_tokens=False)
439
+ ),
440
+ ]
441
+
442
+
443
+ def assistant_training_ids(
444
+ tokenizer: Any,
445
+ prompt: str,
446
+ completion: str,
447
+ *,
448
+ max_length: int,
449
+ prompt_format: str = CHAT_PROMPT_FORMAT,
450
+ ) -> tuple[list[int], list[int]]:
451
+ """Create one chat sequence with loss masked through the assistant header."""
452
+
453
+ if prompt_format not in CHAT_PROMPT_FORMATS:
454
+ raise ValueError("assistant training requires a supported chat prompt format")
455
+ prefix = generation_prompt_ids(tokenizer, prompt, prompt_format)
456
+ full = _flatten_token_ids(
457
+ _apply_runtime_chat_template(
458
+ tokenizer,
459
+ _chat_messages_for_format(prompt, completion, prompt_format),
460
+ prompt_format=prompt_format,
461
+ add_generation_prompt=False,
462
+ ),
463
+ context=(
464
+ "SmolLM3 v5 training template"
465
+ if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT
466
+ else "chat training template"
467
+ ),
468
+ )
469
+ if full[: len(prefix)] != prefix:
470
+ raise ValueError("chat template assistant prefix is not stable")
471
+ if len(full) > max_length:
472
+ raise ValueError(
473
+ f"chat-formatted example has {len(full)} tokens; maximum is {max_length}"
474
+ )
475
+ if len(full) <= len(prefix):
476
+ raise ValueError("chat-formatted example has no assistant completion tokens")
477
+ labels = [-100] * len(prefix) + full[len(prefix) :]
478
+ return full, labels
479
+
480
+
481
+ def aggregate_sha256(paths: Sequence[Path], root: Path) -> str:
482
+ records = []
483
+ for path in sorted(paths):
484
+ records.append(
485
+ {
486
+ "path": path.relative_to(root).as_posix(),
487
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
488
+ }
489
+ )
490
+ payload = json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
491
+ return hashlib.sha256(payload).hexdigest()
tests/test_runtime_contract_v3.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+
9
+ SPACE_DIR = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SPACE_DIR))
11
+
12
+ import runtime_contract as contract # noqa: E402
13
+
14
+
15
+ def canonical_prompt(scene_count: int = 3) -> str:
16
+ return (
17
+ "# Story: Contract Test\n\n"
18
+ "## Metadata\n\n"
19
+ f"- Scene Count: {scene_count}\n\n"
20
+ "## Canvas\n\n"
21
+ "### Constraints\n\n"
22
+ f"- Target scenes: {scene_count}\n"
23
+ "- Must include: Mara, moonbell.\n\n"
24
+ "### Ending Target\n\n"
25
+ "Mara rings the moonbell.\n\n"
26
+ "## Story\n\n"
27
+ )
28
+
29
+
30
+ class RecordingChatTokenizer:
31
+ bos_token_id = 1
32
+
33
+ def __init__(self) -> None:
34
+ self.message_calls: list[list[dict[str, str]]] = []
35
+ self.raw_encode_calls: list[str] = []
36
+
37
+ @staticmethod
38
+ def _encode(value: str) -> list[int]:
39
+ return [ord(character) for character in value]
40
+
41
+ def apply_chat_template(
42
+ self,
43
+ messages,
44
+ *,
45
+ add_generation_prompt,
46
+ tokenize,
47
+ ):
48
+ assert tokenize
49
+ copied = [dict(message) for message in messages]
50
+ self.message_calls.append(copied)
51
+ rendered = "".join(
52
+ f"<{message['role']}>{message['content']}" for message in copied
53
+ )
54
+ if add_generation_prompt:
55
+ rendered += "<assistant>"
56
+ else:
57
+ rendered += "<end>"
58
+ return self._encode(rendered)
59
+
60
+ def encode(self, value, add_special_tokens=False):
61
+ assert not add_special_tokens
62
+ self.raw_encode_calls.append(value)
63
+ return self._encode(value)
64
+
65
+
66
+ class ChatV3ContractTests(unittest.TestCase):
67
+ def test_format_and_system_allow_only_consecutive_scene_h3_headings(self) -> None:
68
+ self.assertEqual(contract.CHAT_PROMPT_FORMAT, "smolgpt-fables-chat-v3")
69
+ self.assertIn("zero-padded `### Scene NN:`", contract.CHAT_SYSTEM_PROMPT)
70
+ self.assertIn("only headings allowed", contract.CHAT_SYSTEM_PROMPT)
71
+ self.assertIn("never emit an H1, H2", contract.CHAT_SYSTEM_PROMPT)
72
+ self.assertIn("any other H3", contract.CHAT_SYSTEM_PROMPT)
73
+ self.assertIn("H4 or deeper", contract.CHAT_SYSTEM_PROMPT)
74
+ self.assertIn("Stop immediately after", contract.CHAT_SYSTEM_PROMPT)
75
+
76
+ def test_chat_prompt_injects_the_exact_count_specific_contract_at_boundary(self) -> None:
77
+ prompt = canonical_prompt(3)
78
+ transformed = contract.chat_messages(prompt)[1]["content"]
79
+ expected_contract = (
80
+ "Output contract (follow exactly): use only these H3 heading prefixes, "
81
+ "in this order: `### Scene 01:`, `### Scene 02:`, `### Scene 03:`. "
82
+ "Write exactly 3 scenes; emit no other heading or section; stop "
83
+ "immediately after completing `### Scene 03:`.\n\n"
84
+ )
85
+ self.assertEqual(
86
+ transformed,
87
+ prompt[: -len(contract.CHAT_STORY_BOUNDARY)]
88
+ + expected_contract
89
+ + contract.CHAT_STORY_BOUNDARY,
90
+ )
91
+ self.assertEqual(transformed.count(expected_contract), 1)
92
+ self.assertTrue(transformed.endswith(expected_contract + "## Story\n\n"))
93
+
94
+ def test_generation_and_training_use_the_identical_central_transform(self) -> None:
95
+ tokenizer = RecordingChatTokenizer()
96
+ prompt = canonical_prompt(4)
97
+ contract.generation_prompt_ids(tokenizer, prompt, contract.CHAT_PROMPT_FORMAT)
98
+ contract.assistant_training_ids(
99
+ tokenizer,
100
+ prompt,
101
+ "### Scene 01: Start\n\nMara listened.\n",
102
+ max_length=10_000,
103
+ )
104
+ transformed_prompts = [call[1]["content"] for call in tokenizer.message_calls]
105
+ self.assertEqual(len(transformed_prompts), 3)
106
+ self.assertEqual(len(set(transformed_prompts)), 1)
107
+ self.assertIn("`### Scene 04:`", transformed_prompts[0])
108
+ self.assertNotIn("`### Scene 05:`", transformed_prompts[0])
109
+
110
+ def test_raw_legacy_generation_does_not_apply_chat_transform(self) -> None:
111
+ tokenizer = RecordingChatTokenizer()
112
+ raw_prompt = "legacy prompt without scene metadata"
113
+ values = contract.generation_prompt_ids(
114
+ tokenizer,
115
+ raw_prompt,
116
+ contract.RAW_PROMPT_FORMAT,
117
+ )
118
+ self.assertEqual(values, [tokenizer.bos_token_id, *tokenizer._encode(raw_prompt)])
119
+ self.assertEqual(tokenizer.raw_encode_calls, [raw_prompt])
120
+ self.assertEqual(tokenizer.message_calls, [])
121
+
122
+ def test_scene_count_metadata_and_story_boundary_fail_closed(self) -> None:
123
+ prompt = canonical_prompt(3)
124
+ invalid_prompts = {
125
+ "missing Scene Count": prompt.replace("- Scene Count: 3\n\n", "", 1),
126
+ "missing Target scenes": prompt.replace("- Target scenes: 3\n", "", 1),
127
+ "mismatched counts": prompt.replace("- Target scenes: 3", "- Target scenes: 2"),
128
+ "Scene Count below range": prompt.replace("- Scene Count: 3", "- Scene Count: 0"),
129
+ "Target scenes above range": prompt.replace(
130
+ "- Target scenes: 3", "- Target scenes: 7"
131
+ ),
132
+ "duplicate Scene Count": prompt.replace(
133
+ "- Scene Count: 3", "- Scene Count: 3\n- Scene Count: 3"
134
+ ),
135
+ "noncanonical boundary": prompt.rstrip("\n"),
136
+ }
137
+ for label, invalid in invalid_prompts.items():
138
+ with self.subTest(label=label), self.assertRaises(ValueError):
139
+ contract.chat_messages(invalid)
140
+
141
+ def test_prompt_hash_binds_transform_version_and_rendered_content(self) -> None:
142
+ baseline = contract.prompt_contract_sha256()
143
+ with mock.patch.object(
144
+ contract,
145
+ "CHAT_PROMPT_TRANSFORM_VERSION",
146
+ "scene-output-contract-test-version",
147
+ ):
148
+ self.assertNotEqual(contract.prompt_contract_sha256(), baseline)
149
+ with mock.patch.object(
150
+ contract,
151
+ "CHAT_OUTPUT_CONTRACT_TEMPLATE",
152
+ contract.CHAT_OUTPUT_CONTRACT_TEMPLATE + "test-content",
153
+ ):
154
+ self.assertNotEqual(contract.prompt_contract_sha256(), baseline)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ unittest.main()
tests/test_runtime_contract_v5.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+
10
+
11
+ SPACE_DIR = Path(__file__).resolve().parents[1]
12
+ ROOT = Path(__file__).resolve().parents[3]
13
+ for path in (SPACE_DIR, ROOT / "src"):
14
+ if str(path) not in sys.path:
15
+ sys.path.insert(0, str(path))
16
+
17
+ import runtime_contract as runtime # noqa: E402
18
+ from smolgpt import smollm3_contract as source # noqa: E402
19
+
20
+
21
+ def canonical_prompt(scene_count: int = 3) -> str:
22
+ return (
23
+ "# Story: Contract Test\n\n"
24
+ "## Metadata\n\n"
25
+ f"- Scene Count: {scene_count}\n\n"
26
+ "## Canvas\n\n"
27
+ "### Constraints\n\n"
28
+ f"- Target scenes: {scene_count}\n"
29
+ "- Must include: Mara, moonbell.\n\n"
30
+ "### Ending Target\n\n"
31
+ "Mara rings the moonbell.\n\n"
32
+ "## Story\n\n"
33
+ )
34
+
35
+
36
+ class SmolLM3Tokenizer:
37
+ bos_token_id = 1
38
+ chat_template = "pinned-smollm3-template"
39
+
40
+ def __init__(self) -> None:
41
+ self.calls: list[tuple[list[dict[str, str]], dict[str, object]]] = []
42
+
43
+ def apply_chat_template(self, messages, **kwargs):
44
+ copied = [dict(message) for message in messages]
45
+ self.calls.append((copied, dict(kwargs)))
46
+ if kwargs["add_generation_prompt"]:
47
+ return [10, 11, 12]
48
+ return [10, 11, 12, 20, 21]
49
+
50
+
51
+ class SmolLM3RuntimeContractTests(unittest.TestCase):
52
+ def test_v5_runtime_contract_is_byte_equivalent_to_source_contract(self) -> None:
53
+ self.assertEqual(runtime.SMOLLM3_CHAT_PROMPT_FORMAT, source.CHAT_PROMPT_FORMAT)
54
+ self.assertEqual(runtime.SMOLLM3_SYSTEM_PROMPT, source.SYSTEM_PROMPT)
55
+ self.assertEqual(
56
+ runtime.SMOLLM3_PROMPT_TRANSFORM_VERSION,
57
+ source.PROMPT_TRANSFORM_VERSION,
58
+ )
59
+ self.assertEqual(runtime.SMOLLM3_STORY_BOUNDARY, source.STORY_BOUNDARY)
60
+ self.assertEqual(runtime.SMOLLM3_SCENE_WORD_RANGE, source.SCENE_WORD_RANGE)
61
+ self.assertEqual(
62
+ runtime.SMOLLM3_PRODUCT_CONTEXT_LENGTH,
63
+ source.PRODUCT_CONTEXT_LENGTH,
64
+ )
65
+ self.assertEqual(
66
+ runtime.smollm3_prompt_contract_sha256(),
67
+ source.prompt_contract_sha256(),
68
+ )
69
+ completion = "### Scene 01:\n\nMara rang the moonbell."
70
+ for scene_count in range(1, 7):
71
+ prompt = canonical_prompt(scene_count)
72
+ self.assertEqual(
73
+ runtime.smollm3_render_output_contract(scene_count),
74
+ source.render_output_contract(scene_count),
75
+ )
76
+ self.assertEqual(
77
+ runtime.smollm3_transform_prompt(prompt),
78
+ source.transform_prompt(prompt),
79
+ )
80
+ self.assertEqual(
81
+ runtime.smollm3_chat_messages(prompt),
82
+ source.chat_messages(prompt),
83
+ )
84
+ self.assertEqual(
85
+ runtime.smollm3_chat_messages(prompt, completion),
86
+ source.chat_messages(prompt, completion),
87
+ )
88
+
89
+ def test_v5_generation_and_training_tokenization_match_source_exactly(self) -> None:
90
+ prompt = canonical_prompt(2)
91
+ completion = "### Scene 01:\n\nMara listened."
92
+ source_tokenizer = SmolLM3Tokenizer()
93
+ runtime_tokenizer = SmolLM3Tokenizer()
94
+
95
+ source_generation = source.generation_prompt_ids(source_tokenizer, prompt)
96
+ runtime_generation = runtime.generation_prompt_ids(
97
+ runtime_tokenizer,
98
+ prompt,
99
+ runtime.SMOLLM3_CHAT_PROMPT_FORMAT,
100
+ )
101
+ self.assertEqual(runtime_generation, source_generation)
102
+ self.assertEqual(runtime_tokenizer.calls, source_tokenizer.calls)
103
+ self.assertFalse(runtime_tokenizer.calls[0][1]["enable_thinking"])
104
+
105
+ source_training = source.assistant_training_ids(
106
+ source_tokenizer,
107
+ prompt,
108
+ completion,
109
+ )
110
+ runtime_training = runtime.assistant_training_ids(
111
+ runtime_tokenizer,
112
+ prompt,
113
+ completion,
114
+ max_length=runtime.SMOLLM3_PRODUCT_CONTEXT_LENGTH,
115
+ prompt_format=runtime.SMOLLM3_CHAT_PROMPT_FORMAT,
116
+ )
117
+ self.assertEqual(runtime_training, source_training)
118
+ self.assertEqual(runtime_tokenizer.calls, source_tokenizer.calls)
119
+ self.assertTrue(
120
+ all(call[1]["enable_thinking"] is False for call in runtime_tokenizer.calls)
121
+ )
122
+
123
+ def test_v5_transform_fails_closed_on_every_source_rejected_shape(self) -> None:
124
+ prompt = canonical_prompt(3)
125
+ invalid = (
126
+ prompt.replace("- Scene Count: 3\n\n", "", 1),
127
+ prompt.replace("- Target scenes: 3\n", "", 1),
128
+ prompt.replace("- Target scenes: 3", "- Target scenes: 2"),
129
+ prompt.replace("- Scene Count: 3", "- Scene Count: 7"),
130
+ prompt.replace("- Scene Count: 3", "- Scene Count: 3\n- Scene Count: 3"),
131
+ prompt.rstrip("\n"),
132
+ )
133
+ for value in invalid:
134
+ with self.subTest(prompt=value[-80:]):
135
+ with self.assertRaises(ValueError):
136
+ source.transform_prompt(value)
137
+ with self.assertRaises(ValueError):
138
+ runtime.smollm3_transform_prompt(value)
139
+
140
+ def test_v5_manifest_accepts_exact_top_level_or_nested_binding(self) -> None:
141
+ tokenizer = SmolLM3Tokenizer()
142
+ digest = source.prompt_contract_sha256()
143
+ manifests = (
144
+ {
145
+ "prompt_format": source.CHAT_PROMPT_FORMAT,
146
+ "prompt_contract_sha256": digest,
147
+ },
148
+ {
149
+ "prompt_contract": {
150
+ "format": source.CHAT_PROMPT_FORMAT,
151
+ "sha256": digest,
152
+ "thinking": False,
153
+ }
154
+ },
155
+ {
156
+ "prompt_format": source.CHAT_PROMPT_FORMAT,
157
+ "prompt_contract_sha256": digest,
158
+ "prompt_contract": {
159
+ "format": source.CHAT_PROMPT_FORMAT,
160
+ "sha256": digest,
161
+ "thinking": False,
162
+ },
163
+ },
164
+ )
165
+ for manifest in manifests:
166
+ with self.subTest(manifest=manifest), tempfile.TemporaryDirectory() as directory:
167
+ root = Path(directory)
168
+ (root / "training_manifest.json").write_text(
169
+ json.dumps(manifest), encoding="utf-8"
170
+ )
171
+ self.assertEqual(
172
+ runtime.prompt_format_for_artifact(root, "standard", tokenizer),
173
+ runtime.SMOLLM3_CHAT_PROMPT_FORMAT,
174
+ )
175
+
176
+ def test_v5_manifest_rejects_ambiguous_or_incompatible_bindings(self) -> None:
177
+ digest = source.prompt_contract_sha256()
178
+ invalid = {
179
+ "wrong hash": {
180
+ "prompt_format": source.CHAT_PROMPT_FORMAT,
181
+ "prompt_contract_sha256": "0" * 64,
182
+ },
183
+ "partial top-level": {"prompt_format": source.CHAT_PROMPT_FORMAT},
184
+ "thinking enabled": {
185
+ "prompt_contract": {
186
+ "format": source.CHAT_PROMPT_FORMAT,
187
+ "sha256": digest,
188
+ "thinking": True,
189
+ }
190
+ },
191
+ "nested extra field": {
192
+ "prompt_contract": {
193
+ "format": source.CHAT_PROMPT_FORMAT,
194
+ "sha256": digest,
195
+ "thinking": False,
196
+ "extra": "unreviewed",
197
+ }
198
+ },
199
+ "conflicting bindings": {
200
+ "prompt_format": source.CHAT_PROMPT_FORMAT,
201
+ "prompt_contract_sha256": digest,
202
+ "prompt_contract": {
203
+ "format": runtime.CHAT_PROMPT_FORMAT,
204
+ "sha256": runtime.prompt_contract_sha256(),
205
+ "thinking": False,
206
+ },
207
+ },
208
+ }
209
+ for label, manifest in invalid.items():
210
+ with self.subTest(label=label), tempfile.TemporaryDirectory() as directory:
211
+ root = Path(directory)
212
+ (root / "training_manifest.json").write_text(
213
+ json.dumps(manifest), encoding="utf-8"
214
+ )
215
+ with self.assertRaises(ValueError):
216
+ runtime.prompt_format_for_artifact(
217
+ root,
218
+ "standard",
219
+ SmolLM3Tokenizer(),
220
+ )
221
+
222
+ with tempfile.TemporaryDirectory() as directory:
223
+ root = Path(directory)
224
+ (root / "training_manifest.json").write_text(
225
+ json.dumps(
226
+ {
227
+ "prompt_format": source.CHAT_PROMPT_FORMAT,
228
+ "prompt_contract_sha256": digest,
229
+ }
230
+ ),
231
+ encoding="utf-8",
232
+ )
233
+ with self.assertRaisesRegex(ValueError, "standard model artifact"):
234
+ runtime.prompt_format_for_artifact(
235
+ root,
236
+ "custom",
237
+ SmolLM3Tokenizer(),
238
+ )
239
+ with self.assertRaisesRegex(ValueError, "chat_template"):
240
+ runtime.prompt_format_for_artifact(
241
+ root,
242
+ "standard",
243
+ SimpleNamespace(chat_template=None),
244
+ )
245
+
246
+ def test_context_length_is_versioned_without_changing_legacy_paths(self) -> None:
247
+ self.assertEqual(
248
+ runtime.product_context_length_for_prompt_format(
249
+ runtime.SMOLLM3_CHAT_PROMPT_FORMAT
250
+ ),
251
+ 4096,
252
+ )
253
+ self.assertEqual(
254
+ runtime.product_context_length_for_prompt_format(
255
+ runtime.CHAT_PROMPT_FORMAT
256
+ ),
257
+ 2048,
258
+ )
259
+ self.assertIsNone(
260
+ runtime.product_context_length_for_prompt_format(
261
+ runtime.RAW_PROMPT_FORMAT
262
+ )
263
+ )
264
+
265
+
266
+ if __name__ == "__main__":
267
+ unittest.main()
tests/test_space.py CHANGED
@@ -15,6 +15,7 @@ SPACE_DIR = Path(__file__).resolve().parents[1]
15
  sys.path.insert(0, str(SPACE_DIR))
16
 
17
  import app # noqa: E402
 
18
 
19
 
20
  class RecordingProgress:
@@ -74,6 +75,56 @@ class DeterministicCausalModel:
74
  return SimpleNamespace(logits=logits, past_key_values=cache)
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  class ContinuationCacheTests(unittest.TestCase):
78
  @staticmethod
79
  def bundle(model):
@@ -134,6 +185,76 @@ class ContinuationCacheTests(unittest.TestCase):
134
  self.assertEqual(result, "zz")
135
  self.assertEqual(model.call_lengths, [prompt_length, 1])
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  class CanvasTests(unittest.TestCase):
139
  def test_canvas_matches_story_schema_boundary(self) -> None:
@@ -222,7 +343,9 @@ class StudioPresentationTests(unittest.TestCase):
222
  )
223
  self.assertEqual(canvas, story_input)
224
  self.assertEqual(tokens, app.SCENE_TOKEN_BUDGETS[3])
225
- self.assertIn("# Canvas: Mara and Ilyan", canvas)
 
 
226
  self.assertIn("- Tags: high-fantasy, close-third-person", canvas)
227
  self.assertIn("- Scene Count: 3", canvas)
228
  self.assertIn("#### Mara\n- Role: a careful mapmaker and protagonist", canvas)
@@ -248,7 +371,7 @@ class StudioPresentationTests(unittest.TestCase):
248
  )
249
 
250
  def test_novel_studio_brief_uses_the_trained_anchor_contract(self) -> None:
251
- canvas, _, _ = app.prepare_story(
252
  "A young fox named Nilo must return a stolen moonbell to the "
253
  "lighthouse before the first winter tide.",
254
  "Cozy fantasy",
@@ -262,14 +385,148 @@ class StudioPresentationTests(unittest.TestCase):
262
  "Include a red wool scarf\nAvoid a tragic ending",
263
  None,
264
  )
265
- self.assertTrue(canvas.startswith("# Canvas: Nilo and Mara\n"))
 
 
 
 
266
  must_include = next(
267
  line for line in canvas.splitlines() if line.startswith("- Must include:")
268
  )
269
- for anchor in ("Nilo", "Mara", "moonbell", "lighthouse", "red wool scarf"):
 
 
 
 
 
 
 
270
  self.assertIn(anchor, must_include)
271
  self.assertGreaterEqual(canvas.casefold().count(anchor.casefold()), 2)
272
  self.assertNotIn("tragic ending", must_include)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  def test_blank_optional_gradio_values_accept_none(self) -> None:
275
  canvas, story_input, tokens = app.prepare_story(
@@ -351,6 +608,50 @@ class StudioPresentationTests(unittest.TestCase):
351
  with self.assertRaises(app.StudioError):
352
  app._scene_count(value)
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  def test_character_rows_stay_separate_in_the_hidden_canvas(self) -> None:
355
  combined = app._character_rows(
356
  "Mara, a careful mapmaker",
@@ -496,7 +797,7 @@ class HostedBoundaryTests(unittest.TestCase):
496
  self.assertIn("About them", labels)
497
  self.assertIn("Name (optional)", labels)
498
  self.assertIn("About them (optional)", labels)
499
- self.assertIn("Use advanced options", labels)
500
  self.assertIn("How many scenes?", labels)
501
  self.assertNotIn("Main characters", labels)
502
  character_rows = [
@@ -505,12 +806,24 @@ class HostedBoundaryTests(unittest.TestCase):
505
  if "character-card" in item.get("props", {}).get("elem_classes", [])
506
  ]
507
  self.assertEqual(len(character_rows), 2)
 
 
 
 
 
 
 
 
 
 
508
  self.assertNotIn("Story length", labels)
509
  self.assertIn("Download story as Markdown", labels)
510
  self.assertNotIn("Image prompt", labels)
511
  self.assertNotIn("Generated illustration", labels)
512
  self.assertNotIn("Find illustration plans", buttons)
513
  self.assertNotIn("Generate illustration", buttons)
 
 
514
  self.assertIn("Write my story", buttons)
515
  self.assertIn("This is a focused, text-only story Studio", copy)
516
  for technical_label in (
@@ -551,10 +864,36 @@ class HostedBoundaryTests(unittest.TestCase):
551
  self.assertEqual(genre.get("value"), "Cozy fantasy")
552
  self.assertEqual(scene_component.get("type"), "number")
553
  self.assertEqual(scene.get("value"), app.DEFAULT_SCENE_COUNT)
554
- self.assertEqual(scene.get("minimum"), app.MIN_SCENE_COUNT)
555
- self.assertEqual(scene.get("maximum"), app.MAX_SCENE_COUNT)
 
 
 
 
556
  self.assertEqual(scene.get("step"), 1)
557
  self.assertEqual(scene.get("precision"), 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  self.assertEqual(structure_component.get("type"), "radio")
559
  self.assertEqual(
560
  structure.get("choices"), list(app.STRUCTURE_OPTIONS)
@@ -581,6 +920,13 @@ class HostedBoundaryTests(unittest.TestCase):
581
  for item in config.get("components", [])
582
  }
583
  self.assertIn(scene_component.get("id"), write_dependency.get("inputs", []))
 
 
 
 
 
 
 
584
  self.assertTrue(
585
  any(
586
  component_types.get(component_id) != "state"
@@ -593,6 +939,12 @@ class HostedBoundaryTests(unittest.TestCase):
593
  for component_id in write_dependency.get("outputs", [])
594
  )
595
  )
 
 
 
 
 
 
596
 
597
  def test_local_ui_matches_the_text_only_product_surface(self) -> None:
598
  with patch.dict(os.environ, {}, clear=True):
@@ -613,6 +965,8 @@ class HostedBoundaryTests(unittest.TestCase):
613
  self.assertNotIn("provider_status", api_names)
614
  self.assertNotIn("generate_illustration", api_names)
615
  self.assertIn("This is a focused, text-only story Studio", copy)
 
 
616
  self.assertFalse(
617
  any(
618
  item.get("type") == "dropdown"
@@ -623,6 +977,173 @@ class HostedBoundaryTests(unittest.TestCase):
623
 
624
 
625
  class RuntimeContractTests(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  def test_doodle_system_is_bundled_accessible_and_versioned(self) -> None:
627
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
628
  self.assertIn('RELEASE_VERSION = "v1"', source)
@@ -635,6 +1156,46 @@ class RuntimeContractTests(unittest.TestCase):
635
  self.assertIn('aria-live="{live}"', source)
636
  self.assertIn('outline: 3px solid var(--color-focus)', source)
637
  self.assertIn('interactive=False', source)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  for filename in (
639
  "DeliusSwashCaps-Regular.ttf",
640
  "JetBrainsMono-Regular.ttf",
@@ -645,11 +1206,19 @@ class RuntimeContractTests(unittest.TestCase):
645
 
646
  def test_checked_in_model_repo_fallback_is_visible_and_not_fake(self) -> None:
647
  self.assertEqual(app.DEFAULT_MODEL_REPO, "neonforestmist/smolgpt-fables")
648
- with patch.dict(os.environ, {}, clear=True):
649
- with self.assertRaisesRegex(app.ArtifactError, "configured"):
 
 
 
 
 
 
 
650
  app._resolve_model_root(download=False)
651
  status = app.model_status_html()
652
  self.assertIn("Getting SmolGPT ready", status)
 
653
  self.assertNotIn("demo story", status.lower())
654
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
655
  readme = (SPACE_DIR / "README.md").read_text(encoding="utf-8")
@@ -659,7 +1228,13 @@ class RuntimeContractTests(unittest.TestCase):
659
  def test_transformers_artifact_contract(self) -> None:
660
  with tempfile.TemporaryDirectory() as temporary:
661
  root = Path(temporary)
662
- for name in app.MODEL_ARTIFACT_FILES:
 
 
 
 
 
 
663
  (root / name).write_text("", encoding="utf-8")
664
  (root / "config.json").write_text(
665
  json.dumps(
@@ -676,11 +1251,29 @@ class RuntimeContractTests(unittest.TestCase):
676
  config = app._validate_model_root(root)
677
  self.assertEqual(config["model_type"], "smolgpt")
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  def test_space_has_no_legacy_pt_loader(self) -> None:
680
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
681
  self.assertIn("AutoTokenizer.from_pretrained", source)
682
  self.assertIn("AutoModelForCausalLM.from_pretrained", source)
683
- self.assertIn("trust_remote_code=True", source)
684
  self.assertNotIn("torch.load", source)
685
  self.assertNotIn("model.pt", source)
686
 
@@ -698,7 +1291,35 @@ class RuntimeContractTests(unittest.TestCase):
698
  self.assertIn("sdk: gradio", readme)
699
  self.assertIn("sdk_version: 6.19.0", readme)
700
  self.assertIn("app_file: app.py", readme)
701
- self.assertIn("python_version: \"3.11\"", readme)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
  def test_ui_source_avoids_forbidden_dash_characters(self) -> None:
704
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
 
15
  sys.path.insert(0, str(SPACE_DIR))
16
 
17
  import app # noqa: E402
18
+ import runtime_contract as contract # noqa: E402
19
 
20
 
21
  class RecordingProgress:
 
75
  return SimpleNamespace(logits=logits, past_key_values=cache)
76
 
77
 
78
+ class PromptCopyModel:
79
+ """Prefer a prompt token first, then expose generated-only repetition control."""
80
+
81
+ def __init__(self) -> None:
82
+ self.config = SimpleNamespace(context_length=32)
83
+
84
+ def __call__(self, input_ids, past_key_values=None, use_cache=False, **kwargs):
85
+ del past_key_values, use_cache, kwargs
86
+ logits = app.torch.full(
87
+ (input_ids.shape[0], input_ids.shape[1], 256),
88
+ -10.0,
89
+ device=input_ids.device,
90
+ )
91
+ logits[:, :, ord("z")] = 10.0
92
+ logits[:, :, ord("y")] = 9.0
93
+ return SimpleNamespace(logits=logits, past_key_values=None)
94
+
95
+
96
+ class TokenSequenceModel:
97
+ def __init__(self, context_length: int, values) -> None:
98
+ self.config = SimpleNamespace(context_length=context_length)
99
+ self.values = list(values)
100
+ self.calls = 0
101
+
102
+ def __call__(self, input_ids, past_key_values=None, use_cache=False, **kwargs):
103
+ del past_key_values, use_cache, kwargs
104
+ value = self.values[min(self.calls, len(self.values) - 1)]
105
+ self.calls += 1
106
+ logits = app.torch.full(
107
+ (input_ids.shape[0], input_ids.shape[1], 256),
108
+ -10.0,
109
+ device=input_ids.device,
110
+ )
111
+ logits[:, :, int(value)] = 10.0
112
+ return SimpleNamespace(logits=logits, past_key_values=None)
113
+
114
+
115
+ class LossyDecodeTokenizer(CharacterTokenizer):
116
+ def __init__(self) -> None:
117
+ self.encode_calls = []
118
+
119
+ def encode(self, text, add_special_tokens=False, **kwargs):
120
+ self.encode_calls.append(text)
121
+ return super().encode(text, add_special_tokens=add_special_tokens, **kwargs)
122
+
123
+ def decode(self, token_ids, skip_special_tokens=True, **kwargs):
124
+ del token_ids, skip_special_tokens, kwargs
125
+ return "collapsed-decoding"
126
+
127
+
128
  class ContinuationCacheTests(unittest.TestCase):
129
  @staticmethod
130
  def bundle(model):
 
185
  self.assertEqual(result, "zz")
186
  self.assertEqual(model.call_lengths, [prompt_length, 1])
187
 
188
+ def test_repetition_penalty_ignores_prompt_but_penalizes_generated_tokens(self) -> None:
189
+ result = app.generate_continuation(
190
+ self.bundle(PromptCopyModel()),
191
+ "z",
192
+ temperature=0.0,
193
+ top_p=1.0,
194
+ top_k=0,
195
+ repetition_penalty=2.0,
196
+ max_new_tokens=2,
197
+ )
198
+ self.assertEqual(result, "zy")
199
+
200
+ def test_trace_records_context_clamp_and_exact_budget_stop(self) -> None:
201
+ prompt = "small harbor"
202
+ prompt_length = 1 + len(prompt)
203
+ model = DeterministicCausalModel(prompt_length + 2, returns_cache=True)
204
+ result = app.generate_continuation_with_trace(
205
+ self.bundle(model),
206
+ prompt,
207
+ temperature=0.0,
208
+ top_p=1.0,
209
+ top_k=0,
210
+ repetition_penalty=1.0,
211
+ max_new_tokens=12,
212
+ )
213
+ self.assertEqual(result.continuation, "zz")
214
+ self.assertEqual(result.trace.requested_token_budget, 12)
215
+ self.assertEqual(result.trace.effective_token_budget, 2)
216
+ self.assertEqual(result.trace.emitted_continuation_token_count, 2)
217
+ self.assertEqual(result.trace.stop_reason, "token-budget")
218
+
219
+ def test_trace_classifies_eos_without_counting_eos_as_continuation(self) -> None:
220
+ prompt = "small harbor"
221
+ model = TokenSequenceModel(100, [ord("z"), CharacterTokenizer.eos_token_id])
222
+ result = app.generate_continuation_with_trace(
223
+ self.bundle(model),
224
+ prompt,
225
+ temperature=0.0,
226
+ top_p=1.0,
227
+ top_k=0,
228
+ repetition_penalty=1.0,
229
+ max_new_tokens=5,
230
+ )
231
+ self.assertEqual(result.continuation, "z")
232
+ self.assertEqual(result.trace.requested_token_budget, 5)
233
+ self.assertEqual(result.trace.effective_token_budget, 5)
234
+ self.assertEqual(result.trace.emitted_continuation_token_count, 1)
235
+ self.assertEqual(result.trace.stop_reason, "eos")
236
+
237
+ def test_trace_counts_generated_ids_before_lossy_decode_without_reencoding(self) -> None:
238
+ tokenizer = LossyDecodeTokenizer()
239
+ bundle = SimpleNamespace(
240
+ tokenizer=tokenizer,
241
+ model=DeterministicCausalModel(20, returns_cache=True),
242
+ device=app.torch.device("cpu"),
243
+ )
244
+ result = app.generate_continuation_with_trace(
245
+ bundle,
246
+ "p",
247
+ temperature=0.0,
248
+ top_p=1.0,
249
+ top_k=0,
250
+ repetition_penalty=1.0,
251
+ max_new_tokens=3,
252
+ )
253
+ self.assertEqual(result.continuation, "collapsed-decoding")
254
+ self.assertEqual(result.trace.emitted_continuation_token_count, 3)
255
+ self.assertEqual(result.trace.stop_reason, "token-budget")
256
+ self.assertEqual(tokenizer.encode_calls, ["p"])
257
+
258
 
259
  class CanvasTests(unittest.TestCase):
260
  def test_canvas_matches_story_schema_boundary(self) -> None:
 
343
  )
344
  self.assertEqual(canvas, story_input)
345
  self.assertEqual(tokens, app.SCENE_TOKEN_BUDGETS[3])
346
+ self.assertIn(
347
+ "# Canvas: Star visible only at dawn at winter observatory", canvas
348
+ )
349
  self.assertIn("- Tags: high-fantasy, close-third-person", canvas)
350
  self.assertIn("- Scene Count: 3", canvas)
351
  self.assertIn("#### Mara\n- Role: a careful mapmaker and protagonist", canvas)
 
371
  )
372
 
373
  def test_novel_studio_brief_uses_the_trained_anchor_contract(self) -> None:
374
+ canvas, _, _ = app.prepare_studio_story(
375
  "A young fox named Nilo must return a stolen moonbell to the "
376
  "lighthouse before the first winter tide.",
377
  "Cozy fantasy",
 
385
  "Include a red wool scarf\nAvoid a tragic ending",
386
  None,
387
  )
388
+ self.assertTrue(
389
+ canvas.startswith(
390
+ "# Canvas: Red wool scarf at wind-carved island village during the first snow\n"
391
+ )
392
+ )
393
  must_include = next(
394
  line for line in canvas.splitlines() if line.startswith("- Must include:")
395
  )
396
+ self.assertEqual(len(must_include.removeprefix("- Must include: ").split(",")), 5)
397
+ for anchor in (
398
+ "Nilo",
399
+ "Mara",
400
+ "moonbell",
401
+ "wind-carved island village",
402
+ "red wool scarf",
403
+ ):
404
  self.assertIn(anchor, must_include)
405
  self.assertGreaterEqual(canvas.casefold().count(anchor.casefold()), 2)
406
  self.assertNotIn("tragic ending", must_include)
407
+ self.assertIn(
408
+ "1. At a wind-carved island village during the first snow, Nilo and Mara "
409
+ "examine a red wool scarf after a young fox named Nilo must return a stolen "
410
+ "moonbell",
411
+ canvas,
412
+ )
413
+
414
+ def test_hidden_plan_chooses_a_concrete_object_not_a_name_or_generic_verb(self) -> None:
415
+ canvas, _, _ = app.prepare_studio_story(
416
+ "A small favor sends Ada Choi-Wells after a copper compass that points "
417
+ "toward promises people have forgotten.",
418
+ "Folklore",
419
+ 4,
420
+ "Ada Choi-Wells, a watchmaker who keeps other people's promises",
421
+ "A rain-bright canal market",
422
+ None,
423
+ None,
424
+ None,
425
+ None,
426
+ None,
427
+ None,
428
+ )
429
+ must_include = next(
430
+ line for line in canvas.splitlines() if line.startswith("- Must include:")
431
+ )
432
+ self.assertIn("copper compass", must_include)
433
+ self.assertNotIn("begins", must_include.casefold())
434
+ self.assertNotIn("choi-wells and", must_include.casefold())
435
+
436
+ def test_default_six_scene_plan_has_distinct_causal_beats(self) -> None:
437
+ canvas, _, _ = app.prepare_studio_story(
438
+ "A fox must return a moonbell before the winter tide.",
439
+ "Cozy fantasy",
440
+ 6,
441
+ "Nilo, a cautious young fox\nMara, an elderly lighthouse keeper",
442
+ "A wind-carved island village",
443
+ None,
444
+ None,
445
+ None,
446
+ None,
447
+ None,
448
+ None,
449
+ )
450
+ beats = app._markdown_section(canvas, "Beats")
451
+ lines = re.findall(r"^\d+\. (.+)$", beats, re.MULTILINE)
452
+ self.assertEqual(len(lines), 6)
453
+ self.assertEqual(len(set(lines)), 6)
454
+ self.assertNotIn("Following begins", beats)
455
+ self.assertIn("- Aim for 55 to 100 words in each scene.", canvas)
456
+ self.assertIn(
457
+ "- Keep later and final scenes as fully developed as the first; do not "
458
+ "compress them below the word target.",
459
+ canvas,
460
+ )
461
+ self.assertIn(
462
+ "- In the final scene, explicitly perform the central actions named "
463
+ "in the story idea, with their objects, and show the result on-page; "
464
+ "do not end with them merely planned, attempted, promised, or still "
465
+ "waiting to happen.",
466
+ canvas,
467
+ )
468
+ self.assertIn(
469
+ "completes the actions required by the story idea on-page",
470
+ lines[-1],
471
+ )
472
+
473
+ def test_historical_v1_prompt_omits_v2_completion_guidance(self) -> None:
474
+ canvas, _, _ = app.prepare_story(
475
+ "A fox must return a moonbell before the winter tide.",
476
+ "Cozy fantasy",
477
+ 2,
478
+ "Nilo, a cautious young fox",
479
+ "A wind-carved island village",
480
+ None,
481
+ None,
482
+ None,
483
+ None,
484
+ None,
485
+ None,
486
+ )
487
+ self.assertNotIn("completed on-page", canvas)
488
+ self.assertNotIn("merely planning or attempting it", canvas)
489
+ self.assertNotIn("later and final scenes", canvas)
490
+ self.assertNotIn("with their objects", canvas)
491
+
492
+ def test_single_character_default_beats_use_singular_verbs(self) -> None:
493
+ canvas, _, _ = app.prepare_studio_story(
494
+ "A ferryperson finds a moonbell beneath the last crossing.",
495
+ "Folklore",
496
+ 6,
497
+ "Orra, a practical ferryperson",
498
+ "A braided river at first frost",
499
+ None,
500
+ None,
501
+ None,
502
+ None,
503
+ None,
504
+ None,
505
+ )
506
+ beats = app._markdown_section(canvas, "Beats")
507
+ self.assertIn("Orra examines", beats)
508
+ self.assertIn("Orra tests", beats)
509
+ self.assertIn("Orra combines", beats)
510
+
511
+ def test_one_scene_v2_plan_requires_completed_actions_and_results(self) -> None:
512
+ canvas, _, _ = app.prepare_studio_story(
513
+ "A ferryperson must repair the moonbell and reopen the crossing.",
514
+ "Folklore",
515
+ 1,
516
+ "Orra, a practical ferryperson",
517
+ "A braided river at first frost",
518
+ None,
519
+ None,
520
+ None,
521
+ None,
522
+ None,
523
+ None,
524
+ )
525
+ beats = app._markdown_section(canvas, "Beats")
526
+ self.assertIn("Orra examines", beats)
527
+ self.assertIn("completes every central action", beats)
528
+ self.assertIn("with its object", beats)
529
+ self.assertIn("shows the result", beats)
530
 
531
  def test_blank_optional_gradio_values_accept_none(self) -> None:
532
  canvas, story_input, tokens = app.prepare_story(
 
608
  with self.assertRaises(app.StudioError):
609
  app._scene_count(value)
610
 
611
+ def test_scene_stepper_clamps_and_disables_its_endpoints(self) -> None:
612
+ count, minus, plus = app.decrease_scene_count(2)
613
+ self.assertEqual(count, 1)
614
+ self.assertFalse(minus["interactive"])
615
+ self.assertTrue(plus["interactive"])
616
+
617
+ count, minus, plus = app.increase_scene_count(5)
618
+ self.assertEqual(count, 6)
619
+ self.assertTrue(minus["interactive"])
620
+ self.assertFalse(plus["interactive"])
621
+
622
+ self.assertEqual(app.increase_scene_count(6)[0], 6)
623
+ self.assertEqual(app.decrease_scene_count(1)[0], 1)
624
+ self.assertEqual(
625
+ app.sync_scene_stepper(3),
626
+ (
627
+ {"interactive": True, "__type__": "update"},
628
+ {"interactive": True, "__type__": "update"},
629
+ ),
630
+ )
631
+ high, minus, plus = app.normalize_scene_stepper(9)
632
+ self.assertEqual(high, 6)
633
+ self.assertTrue(minus["interactive"])
634
+ self.assertFalse(plus["interactive"])
635
+ low, minus, plus = app.normalize_scene_stepper(-4)
636
+ self.assertEqual(low, 1)
637
+ self.assertFalse(minus["interactive"])
638
+ self.assertTrue(plus["interactive"])
639
+ self.assertEqual(app.normalize_scene_stepper(2.6)[0], 3)
640
+ self.assertEqual(
641
+ app.normalize_scene_stepper(None)[0], app.DEFAULT_SCENE_COUNT
642
+ )
643
+
644
+ def test_advanced_toggle_reports_explicit_on_and_off_states(self) -> None:
645
+ hidden, off = app.toggle_advanced_options(False)
646
+ shown, on = app.toggle_advanced_options(True)
647
+ self.assertFalse(hidden["visible"])
648
+ self.assertTrue(shown["visible"])
649
+ self.assertIn(">Off<", off)
650
+ self.assertIn("advanced-off", off)
651
+ self.assertIn(">On<", on)
652
+ self.assertIn("advanced-on", on)
653
+ self.assertIn('aria-live="polite"', on)
654
+
655
  def test_character_rows_stay_separate_in_the_hidden_canvas(self) -> None:
656
  combined = app._character_rows(
657
  "Mara, a careful mapmaker",
 
797
  self.assertIn("About them", labels)
798
  self.assertIn("Name (optional)", labels)
799
  self.assertIn("About them (optional)", labels)
800
+ self.assertIn("Advanced options", labels)
801
  self.assertIn("How many scenes?", labels)
802
  self.assertNotIn("Main characters", labels)
803
  character_rows = [
 
806
  if "character-card" in item.get("props", {}).get("elem_classes", [])
807
  ]
808
  self.assertEqual(len(character_rows), 2)
809
+ for label in ("Name", "Name (optional)"):
810
+ self.assertIn(
811
+ "character-name-field",
812
+ self._component(config, label).get("props", {}).get("elem_classes", []),
813
+ )
814
+ for label in ("About them", "About them (optional)"):
815
+ self.assertIn(
816
+ "character-about-field",
817
+ self._component(config, label).get("props", {}).get("elem_classes", []),
818
+ )
819
  self.assertNotIn("Story length", labels)
820
  self.assertIn("Download story as Markdown", labels)
821
  self.assertNotIn("Image prompt", labels)
822
  self.assertNotIn("Generated illustration", labels)
823
  self.assertNotIn("Find illustration plans", buttons)
824
  self.assertNotIn("Generate illustration", buttons)
825
+ self.assertIn("Decrease scenes", buttons)
826
+ self.assertIn("Increase scenes", buttons)
827
  self.assertIn("Write my story", buttons)
828
  self.assertIn("This is a focused, text-only story Studio", copy)
829
  for technical_label in (
 
864
  self.assertEqual(genre.get("value"), "Cozy fantasy")
865
  self.assertEqual(scene_component.get("type"), "number")
866
  self.assertEqual(scene.get("value"), app.DEFAULT_SCENE_COUNT)
867
+ self.assertIsNone(scene.get("info"))
868
+ # Bounds stay out of gr.Number so Gradio does not reject 9 before the
869
+ # blur normalizer can visibly clamp it to 6. Backend _scene_count stays
870
+ # strict and the stepper callbacks enforce the same 1-6 contract.
871
+ self.assertIsNone(scene.get("minimum"))
872
+ self.assertIsNone(scene.get("maximum"))
873
  self.assertEqual(scene.get("step"), 1)
874
  self.assertEqual(scene.get("precision"), 0)
875
+ self.assertEqual(scene.get("elem_id"), "scene-count-value")
876
+ self.assertIn("scene-stepper-value", scene.get("elem_classes", []))
877
+ decrease = next(
878
+ item
879
+ for item in config.get("components", [])
880
+ if item.get("props", {}).get("value") == "Decrease scenes"
881
+ )
882
+ increase = next(
883
+ item
884
+ for item in config.get("components", [])
885
+ if item.get("props", {}).get("value") == "Increase scenes"
886
+ )
887
+ self.assertEqual(decrease.get("props", {}).get("min_width"), 48)
888
+ self.assertEqual(increase.get("props", {}).get("min_width"), 48)
889
+ self.assertIn(
890
+ "scene-stepper-minus",
891
+ decrease.get("props", {}).get("elem_classes", []),
892
+ )
893
+ self.assertIn(
894
+ "scene-stepper-plus",
895
+ increase.get("props", {}).get("elem_classes", []),
896
+ )
897
  self.assertEqual(structure_component.get("type"), "radio")
898
  self.assertEqual(
899
  structure.get("choices"), list(app.STRUCTURE_OPTIONS)
 
920
  for item in config.get("components", [])
921
  }
922
  self.assertIn(scene_component.get("id"), write_dependency.get("inputs", []))
923
+ normalize_dependency = next(
924
+ item
925
+ for item in config.get("dependencies", [])
926
+ if (scene_component.get("id"), "blur") in item.get("targets", [])
927
+ )
928
+ self.assertIn(scene_component.get("id"), normalize_dependency.get("inputs", []))
929
+ self.assertIn(scene_component.get("id"), normalize_dependency.get("outputs", []))
930
  self.assertTrue(
931
  any(
932
  component_types.get(component_id) != "state"
 
939
  for component_id in write_dependency.get("outputs", [])
940
  )
941
  )
942
+ output_labels = [
943
+ item.get("props", {}).get("value")
944
+ for item in config.get("components", [])
945
+ if "output-brand" in item.get("props", {}).get("elem_classes", [])
946
+ ]
947
+ self.assertEqual(output_labels, ["## SmolGPT-Fables Output:"])
948
 
949
  def test_local_ui_matches_the_text_only_product_surface(self) -> None:
950
  with patch.dict(os.environ, {}, clear=True):
 
965
  self.assertNotIn("provider_status", api_names)
966
  self.assertNotIn("generate_illustration", api_names)
967
  self.assertIn("This is a focused, text-only story Studio", copy)
968
+ self.assertIn("SmolGPT-Fables Output:", copy)
969
+ self.assertIn(">Off<", copy)
970
  self.assertFalse(
971
  any(
972
  item.get("type") == "dropdown"
 
977
 
978
 
979
  class RuntimeContractTests(unittest.TestCase):
980
+ def test_model_loader_selects_nested_smollm3_v5_contract(self) -> None:
981
+ class LoadedTokenizer(CharacterTokenizer):
982
+ chat_template = "pinned-smollm3-template"
983
+
984
+ class LoadedModel:
985
+ config = SimpleNamespace(max_position_embeddings=8192)
986
+
987
+ def __init__(self) -> None:
988
+ self.device = None
989
+ self.evaluating = False
990
+
991
+ def to(self, device):
992
+ self.device = device
993
+ return self
994
+
995
+ def eval(self):
996
+ self.evaluating = True
997
+ return self
998
+
999
+ with tempfile.TemporaryDirectory() as temporary:
1000
+ root = Path(temporary)
1001
+ (root / "model.safetensors").write_bytes(b"weights")
1002
+ (root / "tokenizer.json").write_text("{}", encoding="utf-8")
1003
+ (root / "config.json").write_text(
1004
+ json.dumps(
1005
+ {
1006
+ "architectures": ["SmolLM3ForCausalLM"],
1007
+ "model_type": "smollm3",
1008
+ "max_position_embeddings": 8192,
1009
+ }
1010
+ ),
1011
+ encoding="utf-8",
1012
+ )
1013
+ (root / "training_manifest.json").write_text(
1014
+ json.dumps(
1015
+ {
1016
+ "prompt_contract": {
1017
+ "format": app.SMOLLM3_CHAT_PROMPT_FORMAT,
1018
+ "sha256": contract.smollm3_prompt_contract_sha256(),
1019
+ "thinking": False,
1020
+ }
1021
+ }
1022
+ ),
1023
+ encoding="utf-8",
1024
+ )
1025
+ tokenizer = LoadedTokenizer()
1026
+ model = LoadedModel()
1027
+ with (
1028
+ patch.object(app, "_MODEL_BUNDLE", None),
1029
+ patch.object(app, "_resolve_model_root", return_value=root),
1030
+ patch.object(app, "_select_device", return_value=app.torch.device("cpu")),
1031
+ patch.object(
1032
+ app.AutoTokenizer,
1033
+ "from_pretrained",
1034
+ return_value=tokenizer,
1035
+ ) as tokenizer_loader,
1036
+ patch.object(
1037
+ app.AutoModelForCausalLM,
1038
+ "from_pretrained",
1039
+ return_value=model,
1040
+ ) as model_loader,
1041
+ ):
1042
+ bundle = app.load_model_bundle(force=True)
1043
+
1044
+ self.assertEqual(bundle.prompt_format, app.SMOLLM3_CHAT_PROMPT_FORMAT)
1045
+ self.assertIs(bundle.tokenizer, tokenizer)
1046
+ self.assertIs(bundle.model, model)
1047
+ self.assertEqual(model.device, app.torch.device("cpu"))
1048
+ self.assertTrue(model.evaluating)
1049
+ self.assertFalse(tokenizer_loader.call_args.kwargs["trust_remote_code"])
1050
+ self.assertFalse(model_loader.call_args.kwargs["trust_remote_code"])
1051
+
1052
+ def test_standard_chat_runtime_enforces_the_evaluated_2048_context(self) -> None:
1053
+ class OversizedChatTokenizer(CharacterTokenizer):
1054
+ chat_template = "fake"
1055
+
1056
+ def apply_chat_template(self, *args, **kwargs):
1057
+ del args, kwargs
1058
+ return [self.bos_token_id, *([7] * 2048)]
1059
+
1060
+ bundle = SimpleNamespace(
1061
+ tokenizer=OversizedChatTokenizer(),
1062
+ model=SimpleNamespace(
1063
+ config=SimpleNamespace(max_position_embeddings=8192)
1064
+ ),
1065
+ device=app.torch.device("cpu"),
1066
+ prompt_format=app.CHAT_PROMPT_FORMAT,
1067
+ )
1068
+ with self.assertRaisesRegex(app.StudioError, "supports 2048"):
1069
+ app.generate_continuation(
1070
+ bundle,
1071
+ (
1072
+ "# Story: Test\n\n- Scene Count: 1\n\n"
1073
+ "- Target scenes: 1\n\n## Story\n\n"
1074
+ ),
1075
+ temperature=0.0,
1076
+ top_p=1.0,
1077
+ top_k=0,
1078
+ repetition_penalty=1.0,
1079
+ max_new_tokens=1,
1080
+ )
1081
+
1082
+ def test_smollm3_v5_runtime_uses_exact_4096_context_and_disables_thinking(self) -> None:
1083
+ class SizedSmolLM3Tokenizer(CharacterTokenizer):
1084
+ chat_template = "fake-smollm3-template"
1085
+
1086
+ def __init__(self, token_count: int) -> None:
1087
+ self.token_count = token_count
1088
+ self.enable_thinking_values = []
1089
+
1090
+ def apply_chat_template(
1091
+ self,
1092
+ messages,
1093
+ *,
1094
+ add_generation_prompt,
1095
+ tokenize,
1096
+ enable_thinking,
1097
+ ):
1098
+ del messages, add_generation_prompt
1099
+ self.enable_thinking_values.append(enable_thinking)
1100
+ self.assert_tokenize = tokenize
1101
+ return [self.bos_token_id, *([7] * (self.token_count - 1))]
1102
+
1103
+ model = SimpleNamespace(config=SimpleNamespace(max_position_embeddings=8192))
1104
+ accepted_tokenizer = SizedSmolLM3Tokenizer(2049)
1105
+ accepted = SimpleNamespace(
1106
+ tokenizer=accepted_tokenizer,
1107
+ model=model,
1108
+ device=app.torch.device("cpu"),
1109
+ prompt_format=app.SMOLLM3_CHAT_PROMPT_FORMAT,
1110
+ )
1111
+ result = app.generate_continuation_with_trace(
1112
+ accepted,
1113
+ (
1114
+ "# Story: Test\n\n- Scene Count: 1\n\n"
1115
+ "- Target scenes: 1\n\n## Story\n\n"
1116
+ ),
1117
+ temperature=0.0,
1118
+ top_p=1.0,
1119
+ top_k=0,
1120
+ repetition_penalty=1.0,
1121
+ max_new_tokens=0,
1122
+ )
1123
+ self.assertEqual(result.trace.requested_token_budget, 0)
1124
+ self.assertEqual(accepted_tokenizer.enable_thinking_values, [False])
1125
+ self.assertTrue(accepted_tokenizer.assert_tokenize)
1126
+
1127
+ oversized = SimpleNamespace(
1128
+ tokenizer=SizedSmolLM3Tokenizer(4096),
1129
+ model=model,
1130
+ device=app.torch.device("cpu"),
1131
+ prompt_format=app.SMOLLM3_CHAT_PROMPT_FORMAT,
1132
+ )
1133
+ with self.assertRaisesRegex(app.StudioError, "supports 4096"):
1134
+ app.generate_continuation_with_trace(
1135
+ oversized,
1136
+ (
1137
+ "# Story: Test\n\n- Scene Count: 1\n\n"
1138
+ "- Target scenes: 1\n\n## Story\n\n"
1139
+ ),
1140
+ temperature=0.0,
1141
+ top_p=1.0,
1142
+ top_k=0,
1143
+ repetition_penalty=1.0,
1144
+ max_new_tokens=1,
1145
+ )
1146
+
1147
  def test_doodle_system_is_bundled_accessible_and_versioned(self) -> None:
1148
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
1149
  self.assertIn('RELEASE_VERSION = "v1"', source)
 
1156
  self.assertIn('aria-live="{live}"', source)
1157
  self.assertIn('outline: 3px solid var(--color-focus)', source)
1158
  self.assertIn('interactive=False', source)
1159
+ self.assertIn('button.scene-stepper-minus::before', source)
1160
+ self.assertIn('button.scene-stepper-plus::before', source)
1161
+ self.assertIn('min-height: 48px !important', source)
1162
+ self.assertIn('touch-action: manipulation', source)
1163
+ self.assertIn('button:disabled', source)
1164
+ self.assertIn('input[type="radio"]:checked', source)
1165
+ self.assertIn(
1166
+ 'radial-gradient(circle at center, var(--color-primary)', source
1167
+ )
1168
+ self.assertIn('input[type="checkbox"]:checked', source)
1169
+ self.assertIn('--color-success-ink: #0f6b31', source)
1170
+ self.assertIn('--color-secondary: #b9eaff', source)
1171
+ self.assertNotIn('[role="radiogroup"]', source)
1172
+ self.assertIn(
1173
+ '.advanced-panel fieldset:has(input[type="radio"]) > .wrap', source
1174
+ )
1175
+ self.assertIn(
1176
+ '.advanced-panel label:has(input[type="radio"])', source
1177
+ )
1178
+ self.assertIn('.advanced-panel input[type="radio"]', source)
1179
+ self.assertIn(
1180
+ '.advanced-panel label:has(input[type="radio"]) > span', source
1181
+ )
1182
+ self.assertIn('flex: 0 0 20px !important', source)
1183
+ self.assertIn('.advanced-panel:has(.advanced-panel)', source)
1184
+ self.assertIn(
1185
+ '.advanced-panel .advanced-panel > .styler', source
1186
+ )
1187
+ self.assertIn(
1188
+ '.scene-stepper-field:has(.scene-stepper-field)', source
1189
+ )
1190
+ self.assertIn(
1191
+ '.scene-stepper-field .scene-stepper-field > .styler', source
1192
+ )
1193
+ self.assertIn('max-width: calc(100vw - 24px)', source)
1194
+ self.assertIn('.character-list .character-list > .styler', source)
1195
+ self.assertIn('.character-name-field textarea', source)
1196
+ self.assertIn('.character-about-field textarea', source)
1197
+ self.assertIn('@media (max-width: 520px)', source)
1198
+ self.assertIn('role="status" aria-live="polite"', source)
1199
  for filename in (
1200
  "DeliusSwashCaps-Regular.ttf",
1201
  "JetBrainsMono-Regular.ttf",
 
1206
 
1207
  def test_checked_in_model_repo_fallback_is_visible_and_not_fake(self) -> None:
1208
  self.assertEqual(app.DEFAULT_MODEL_REPO, "neonforestmist/smolgpt-fables")
1209
+ self.assertEqual(
1210
+ app.DEFAULT_MODEL_REVISION,
1211
+ "4245b8a4359e4490be58aaf8ac919dc371b6570e",
1212
+ )
1213
+ with (
1214
+ patch.dict(os.environ, {}, clear=True),
1215
+ patch.object(app, "_MODEL_BUNDLE", None),
1216
+ ):
1217
+ with self.assertRaisesRegex(app.ArtifactError, "download on first generation"):
1218
  app._resolve_model_root(download=False)
1219
  status = app.model_status_html()
1220
  self.assertIn("Getting SmolGPT ready", status)
1221
+ self.assertIn("pinned story model", status)
1222
  self.assertNotIn("demo story", status.lower())
1223
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
1224
  readme = (SPACE_DIR / "README.md").read_text(encoding="utf-8")
 
1228
  def test_transformers_artifact_contract(self) -> None:
1229
  with tempfile.TemporaryDirectory() as temporary:
1230
  root = Path(temporary)
1231
+ for name in (
1232
+ "model.safetensors",
1233
+ "config.json",
1234
+ "configuration_smolgpt.py",
1235
+ "modeling_smolgpt.py",
1236
+ "tokenizer.json",
1237
+ ):
1238
  (root / name).write_text("", encoding="utf-8")
1239
  (root / "config.json").write_text(
1240
  json.dumps(
 
1251
  config = app._validate_model_root(root)
1252
  self.assertEqual(config["model_type"], "smolgpt")
1253
 
1254
+ def test_standard_transformers_artifact_contract(self) -> None:
1255
+ with tempfile.TemporaryDirectory() as temporary:
1256
+ root = Path(temporary)
1257
+ (root / "model.safetensors").write_bytes(b"weights")
1258
+ (root / "tokenizer.json").write_text("{}", encoding="utf-8")
1259
+ (root / "config.json").write_text(
1260
+ json.dumps(
1261
+ {
1262
+ "architectures": ["LlamaForCausalLM"],
1263
+ "model_type": "llama",
1264
+ "max_position_embeddings": 8192,
1265
+ }
1266
+ ),
1267
+ encoding="utf-8",
1268
+ )
1269
+ config = app._validate_model_root(root)
1270
+ self.assertEqual(config["model_type"], "llama")
1271
+
1272
  def test_space_has_no_legacy_pt_loader(self) -> None:
1273
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")
1274
  self.assertIn("AutoTokenizer.from_pretrained", source)
1275
  self.assertIn("AutoModelForCausalLM.from_pretrained", source)
1276
+ self.assertIn('trust_remote_code=artifact_kind == "custom"', source)
1277
  self.assertNotIn("torch.load", source)
1278
  self.assertNotIn("model.pt", source)
1279
 
 
1291
  self.assertIn("sdk: gradio", readme)
1292
  self.assertIn("sdk_version: 6.19.0", readme)
1293
  self.assertIn("app_file: app.py", readme)
1294
+ self.assertIn("python_version: \"3.12.12\"", readme)
1295
+ self.assertIn("standard `LlamaForCausalLM` artifact", readme)
1296
+ self.assertIn("`HuggingFaceTB/SmolLM2-1.7B-Instruct`", readme)
1297
+ self.assertIn("audited\nnatural-v6 curriculum", readme)
1298
+ self.assertIn("exact natural-v6\nprompt-contract digest", readme)
1299
+ self.assertIn("**SmolGPT-Fables Output:**", readme)
1300
+ self.assertIn("`neonforestmist/smolgpt-fables`", readme)
1301
+ self.assertIn("4245b8a4359e4490be58aaf8ac919dc371b6570e", readme)
1302
+ self.assertIn("@spaces.GPU(duration=120)", readme)
1303
+ self.assertIn(
1304
+ "https://huggingface.co/datasets/neonforestmist/smolgpt-markdown-stories",
1305
+ readme,
1306
+ )
1307
+ self.assertNotIn("SmolLM3 v5", readme)
1308
+ self.assertNotIn("smolgpt-fables-smollm3-chat-v5", readme)
1309
+ lowered = readme.casefold()
1310
+ for phrase in (
1311
+ "draw things",
1312
+ "image generation",
1313
+ "illustration orchestration",
1314
+ "openai",
1315
+ "iphone",
1316
+ "mobile",
1317
+ "phone",
1318
+ "research preview",
1319
+ "candidate",
1320
+ "experimental",
1321
+ ):
1322
+ self.assertNotIn(phrase, lowered)
1323
 
1324
  def test_ui_source_avoids_forbidden_dash_characters(self) -> None:
1325
  source = (SPACE_DIR / "app.py").read_text(encoding="utf-8")