Spaces:
Sleeping
Sleeping
Run SmolGPT-Fables v1 with the updated character Studio
Browse filesPins the published v1 model commit, adds the shared prompt contract, keeps separate Name/About character cards, and preloads the ZeroGPU model.
- README.md +46 -51
- app.py +897 -131
- runtime_contract.py +491 -0
- tests/test_runtime_contract_v3.py +158 -0
- tests/test_runtime_contract_v5.py +267 -0
- tests/test_space.py +633 -12
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 |
---
|
| 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=
|
| 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
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 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 |
-
|
| 70 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
|
| 3 |
-
The
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
)
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 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 =
|
| 215 |
-
except
|
| 216 |
-
raise ArtifactError(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 241 |
device = _select_device()
|
| 242 |
try:
|
| 243 |
tokenizer = AutoTokenizer.from_pretrained(
|
| 244 |
str(root),
|
| 245 |
local_files_only=True,
|
| 246 |
-
trust_remote_code=
|
| 247 |
)
|
| 248 |
model = AutoModelForCausalLM.from_pretrained(
|
| 249 |
str(root),
|
| 250 |
local_files_only=True,
|
| 251 |
-
trust_remote_code=
|
| 252 |
)
|
| 253 |
except Exception as exc:
|
| 254 |
raise ArtifactError(
|
| 255 |
-
"Transformers could not load the
|
| 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
|
| 308 |
return _status_panel(
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
"The
|
| 312 |
)
|
| 313 |
return _status_panel(
|
| 314 |
-
"
|
| 315 |
-
"
|
| 316 |
-
"
|
| 317 |
)
|
| 318 |
|
| 319 |
|
|
@@ -342,9 +388,82 @@ def finish_story_request() -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
| 342 |
)
|
| 343 |
|
| 344 |
|
| 345 |
-
def
|
| 346 |
-
"""
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 628 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 646 |
-
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
break
|
| 649 |
return candidates
|
| 650 |
|
| 651 |
|
| 652 |
-
def
|
| 653 |
-
|
| 654 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
if scene_count == 1:
|
| 656 |
-
return
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 706 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 836 |
)
|
| 837 |
|
| 838 |
|
| 839 |
@torch.inference_mode()
|
| 840 |
-
def
|
| 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 |
-
) ->
|
|
|
|
|
|
|
| 851 |
tokenizer = bundle.tokenizer
|
| 852 |
context_length = _model_context_length(bundle.model)
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 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 |
-
|
|
|
|
| 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(
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
|
| 931 |
|
| 932 |
def _story_filename(story: str) -> str:
|
|
@@ -982,7 +1396,7 @@ def _generate_story_document(
|
|
| 982 |
)
|
| 983 |
|
| 984 |
|
| 985 |
-
@spaces.GPU(duration=
|
| 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=
|
| 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 =
|
| 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 |
-
|
|
|
|
| 1709 |
.story-shape label,
|
| 1710 |
-
.gradio-container [
|
| 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 [
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 1900 |
-
|
| 1901 |
-
|
| 1902 |
-
|
| 1903 |
-
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
|
| 1907 |
-
|
| 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="
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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.
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
must_include = next(
|
| 267 |
line for line in canvas.splitlines() if line.startswith("- Must include:")
|
| 268 |
)
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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.
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|