SmolGPT-Fable-Studio / runtime_contract.py
neonforestmist's picture
Run SmolGPT-Fables v1 with the updated character Studio
095660c verified
Raw
History Blame Contribute Delete
20.4 kB
"""Stable prompt and artifact contract shared by the Studio and product SFT."""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Mapping, Sequence
CHAT_PROMPT_FORMAT = "smolgpt-fables-chat-v3"
SMOLLM3_CHAT_PROMPT_FORMAT = "smolgpt-fables-smollm3-chat-v5"
RAW_PROMPT_FORMAT = "smolgpt-fables-raw-v1"
PRODUCT_CONTEXT_LENGTH = 2048
SMOLLM3_PRODUCT_CONTEXT_LENGTH = 4096
CHAT_SYSTEM_PROMPT = (
"You are SmolGPT-Fables. Output only the requested finished Markdown story "
"continuation. Begin exactly with `### Scene 01:`. Emit exactly the requested "
"number of consecutive, zero-padded `### Scene NN:` sections. Those scene "
"headings are the only headings allowed in the output: never emit an H1, H2, "
"any other H3, an H4 or deeper heading, or any other section before, between, "
"or after them. Follow the canvas exactly and copy every required name and "
"detail verbatim. Stop immediately after the requested final scene. Never "
"repeat or quote the story title, metadata, canvas, `## Story`, or any instruction."
)
CHAT_PROMPT_TRANSFORM_VERSION = "scene-output-contract-v1"
CHAT_STORY_BOUNDARY = "## Story\n\n"
CHAT_OUTPUT_CONTRACT_TEMPLATE = (
"Output contract (follow exactly): use only these H3 heading prefixes, in this "
"order: {headings}. Write exactly {scene_count} scenes; emit no other heading "
"or section; stop immediately after completing `### Scene {final_scene:02d}:`.\n\n"
)
SMOLLM3_PROMPT_TRANSFORM_VERSION = "natural-fable-scene-contract-v3"
SMOLLM3_STORY_BOUNDARY = "## Story\n\n"
SMOLLM3_SCENE_WORD_RANGE = (45, 115)
SMOLLM3_SYSTEM_PROMPT = (
"You are SmolGPT-Fables. Write a vivid, complete fable from the user's canvas. "
"Output only the finished story continuation. Begin with `### Scene 01:` and "
"emit exactly the requested consecutive, zero-padded scene sections. A scene "
"heading may include a short title after the colon. Use no other Markdown "
"heading. Copy every required name, setting, and unusual detail verbatim. Make "
"each character's described role, personality, and desire affect what they do. "
"Write concrete action and dialogue instead of summarizing instructions. Keep "
"each scene concise, make every scene change the situation, and resolve the "
"ending target inside the final scene. Stop immediately after the final sentence; "
"never add notes, analysis, a moral label, an ending section, or quoted canvas "
"text. /no_think"
)
_SCENE_COUNT_PATTERN = re.compile(r"(?m)^- Scene Count: ([0-9]+)$")
_TARGET_SCENES_PATTERN = re.compile(r"(?m)^- Target scenes: ([0-9]+)$")
_MIN_SCENE_COUNT = 1
_MAX_SCENE_COUNT = 6
SUPPORTED_PROMPT_FORMATS = frozenset(
{CHAT_PROMPT_FORMAT, SMOLLM3_CHAT_PROMPT_FORMAT, RAW_PROMPT_FORMAT}
)
CHAT_PROMPT_FORMATS = frozenset(
{CHAT_PROMPT_FORMAT, SMOLLM3_CHAT_PROMPT_FORMAT}
)
COMMON_ARTIFACT_FILES = ("config.json", "tokenizer.json")
CUSTOM_CODE_FILES = ("configuration_smolgpt.py", "modeling_smolgpt.py")
def _render_output_contract(scene_count: int) -> str:
headings = ", ".join(
f"`### Scene {index:02d}:`" for index in range(1, scene_count + 1)
)
return CHAT_OUTPUT_CONTRACT_TEMPLATE.format(
headings=headings,
scene_count=scene_count,
final_scene=scene_count,
)
def prompt_contract_sha256() -> str:
payload = {
"format": CHAT_PROMPT_FORMAT,
"system": CHAT_SYSTEM_PROMPT,
"messages": ["system", "user", "assistant"],
"assistant_only_loss": True,
"prompt_transform": {
"version": CHAT_PROMPT_TRANSFORM_VERSION,
"scene_count_pattern": _SCENE_COUNT_PATTERN.pattern,
"target_scenes_pattern": _TARGET_SCENES_PATTERN.pattern,
"scene_count_range": [_MIN_SCENE_COUNT, _MAX_SCENE_COUNT],
"story_boundary": CHAT_STORY_BOUNDARY,
"rendered_output_contracts": {
str(scene_count): _render_output_contract(scene_count)
for scene_count in range(_MIN_SCENE_COUNT, _MAX_SCENE_COUNT + 1)
},
},
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()
def smollm3_render_output_contract(scene_count: int) -> str:
"""Render the exact SmolLM3 v5 output contract used for SFT."""
if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
raise ValueError("v4 scene count must be between 1 and 6")
headings = ", ".join(
f"`### Scene {index:02d}:`"
for index in range(1, scene_count + 1)
)
minimum, maximum = SMOLLM3_SCENE_WORD_RANGE
return (
"Output contract (follow exactly):\n"
f"- Use these scene prefixes in order: {headings}.\n"
f"- Write exactly {scene_count} scenes and {minimum}-{maximum} words per scene.\n"
"- Use no heading except those scene headings.\n"
"- Copy every item on `Must include` verbatim into the story.\n"
"- Show the character-role details through decisions, action, or dialogue.\n"
f"- Resolve the ending target in Scene {scene_count:02d} and stop.\n\n"
)
def _canonical_smollm3_scene_count(prompt: str) -> int:
scene_counts = _SCENE_COUNT_PATTERN.findall(prompt)
target_counts = _TARGET_SCENES_PATTERN.findall(prompt)
if len(scene_counts) != 1 or len(target_counts) != 1:
raise ValueError("v4 prompt needs one Scene Count and one Target scenes line")
scene_count = int(scene_counts[0])
target_count = int(target_counts[0])
if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
raise ValueError("v4 scene count must be between 1 and 6")
if scene_count != target_count:
raise ValueError("v4 Scene Count and Target scenes must match")
return scene_count
def smollm3_transform_prompt(prompt: str) -> str:
"""Apply the exact SmolLM3 v5 prompt transform used for SFT."""
scene_count = _canonical_smollm3_scene_count(prompt)
if not prompt.endswith(SMOLLM3_STORY_BOUNDARY):
raise ValueError("v4 prompt must end at the canonical Story boundary")
return (
prompt[: -len(SMOLLM3_STORY_BOUNDARY)]
+ smollm3_render_output_contract(scene_count)
+ SMOLLM3_STORY_BOUNDARY
)
def smollm3_chat_messages(
prompt: str,
completion: str | None = None,
) -> list[dict[str, str]]:
messages = [
{"role": "system", "content": SMOLLM3_SYSTEM_PROMPT},
{"role": "user", "content": smollm3_transform_prompt(prompt)},
]
if completion is not None:
messages.append({"role": "assistant", "content": completion})
return messages
def smollm3_prompt_contract_sha256() -> str:
"""Hash the exact SmolLM3 v5 prompt contract used for SFT."""
payload = {
"format": SMOLLM3_CHAT_PROMPT_FORMAT,
"system": SMOLLM3_SYSTEM_PROMPT,
"messages": ["system", "user", "assistant"],
"assistant_only_loss": True,
"thinking": False,
"context_length": SMOLLM3_PRODUCT_CONTEXT_LENGTH,
"transform_version": SMOLLM3_PROMPT_TRANSFORM_VERSION,
"story_boundary": SMOLLM3_STORY_BOUNDARY,
"scene_count_pattern": _SCENE_COUNT_PATTERN.pattern,
"target_scenes_pattern": _TARGET_SCENES_PATTERN.pattern,
"scene_word_range": list(SMOLLM3_SCENE_WORD_RANGE),
"rendered_contracts": {
str(count): smollm3_render_output_contract(count)
for count in range(_MIN_SCENE_COUNT, _MAX_SCENE_COUNT + 1)
},
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()
def raw_prompt_contract_sha256() -> str:
payload = {
"format": RAW_PROMPT_FORMAT,
"messages": ["raw-markdown-prompt"],
"bos_prefix": True,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()
def product_context_length_for_prompt_format(prompt_format: str) -> int | None:
if prompt_format == CHAT_PROMPT_FORMAT:
return PRODUCT_CONTEXT_LENGTH
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
return SMOLLM3_PRODUCT_CONTEXT_LENGTH
if prompt_format == RAW_PROMPT_FORMAT:
return None
raise ValueError(f"unsupported prompt format: {prompt_format}")
def _has_model_weights(root: Path) -> bool:
return (root / "model.safetensors").is_file() or (
root / "model.safetensors.index.json"
).is_file()
def validate_transformers_artifact(root: Path) -> tuple[Mapping[str, Any], str]:
"""Validate either the legacy custom export or a standard Transformers LM."""
missing = [name for name in COMMON_ARTIFACT_FILES if not (root / name).is_file()]
if missing:
raise ValueError("model repository is missing: " + ", ".join(missing))
if not _has_model_weights(root):
raise ValueError("model repository is missing Safetensors weights")
try:
config = json.loads((root / "config.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"could not read config.json: {exc}") from exc
if not isinstance(config, Mapping):
raise ValueError("config.json must contain a JSON object")
auto_map = config.get("auto_map")
if isinstance(auto_map, Mapping) and auto_map.get("AutoModelForCausalLM"):
missing_code = [name for name in CUSTOM_CODE_FILES if not (root / name).is_file()]
if missing_code:
raise ValueError("custom model repository is missing: " + ", ".join(missing_code))
return config, "custom"
architectures = config.get("architectures")
if not isinstance(architectures, list) or not all(
isinstance(value, str) and value for value in architectures
):
raise ValueError("standard model config needs a non-empty architectures list")
if not isinstance(config.get("model_type"), str) or not config["model_type"]:
raise ValueError("standard model config needs model_type")
return config, "standard"
def _expected_prompt_contract_sha256(prompt_format: str) -> str:
if prompt_format == CHAT_PROMPT_FORMAT:
return prompt_contract_sha256()
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
return smollm3_prompt_contract_sha256()
if prompt_format == RAW_PROMPT_FORMAT:
return raw_prompt_contract_sha256()
raise ValueError(f"unsupported prompt format: {prompt_format}")
def _manifest_prompt_binding(
manifest: Mapping[str, Any],
*,
artifact_kind: str,
) -> tuple[str, str] | None:
"""Read legacy top-level or SmolLM3 nested bindings without ambiguity."""
top_format_present = "prompt_format" in manifest
top_hash_present = "prompt_contract_sha256" in manifest
nested_present = "prompt_contract" in manifest
if not top_format_present and not top_hash_present and not nested_present:
if artifact_kind == "custom":
return None
raise ValueError("training manifest has no supported prompt_format")
if top_format_present != top_hash_present:
raise ValueError("training manifest prompt binding is incomplete")
bindings: list[tuple[str, str]] = []
if top_format_present:
bindings.append(
(manifest.get("prompt_format"), manifest.get("prompt_contract_sha256"))
)
if nested_present:
nested = manifest.get("prompt_contract")
if (
not isinstance(nested, Mapping)
or set(nested) != {"format", "sha256", "thinking"}
or nested.get("thinking") is not False
):
raise ValueError("training manifest nested prompt contract is invalid")
bindings.append((nested.get("format"), nested.get("sha256")))
if any(
not isinstance(value, str)
or not value
or not isinstance(digest, str)
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
for value, digest in bindings
):
raise ValueError("training manifest prompt binding is invalid")
if any(binding != bindings[0] for binding in bindings[1:]):
raise ValueError("training manifest prompt bindings conflict")
return bindings[0]
def prompt_format_for_artifact(root: Path, artifact_kind: str, tokenizer: Any) -> str:
manifest_path = root / "training_manifest.json"
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, Mapping):
raise ValueError("training manifest must contain a JSON object")
binding = _manifest_prompt_binding(manifest, artifact_kind=artifact_kind)
if binding is None:
# Checked-in pre-contract SmolGPT exports are unambiguously the raw
# Markdown/BOS path. Any partially declared contract still fails.
return RAW_PROMPT_FORMAT
value, contract_hash = binding
if value not in SUPPORTED_PROMPT_FORMATS:
raise ValueError("training manifest has no supported prompt_format")
if artifact_kind == "standard" and value not in CHAT_PROMPT_FORMATS:
raise ValueError("standard product manifest must use the chat prompt format")
if artifact_kind == "custom" and value == SMOLLM3_CHAT_PROMPT_FORMAT:
raise ValueError("SmolLM3 chat-v5 requires a standard model artifact")
expected_sha = _expected_prompt_contract_sha256(value)
if contract_hash != expected_sha:
raise ValueError("training manifest prompt contract hash does not match runtime")
if value in CHAT_PROMPT_FORMATS and not getattr(
tokenizer, "chat_template", None
):
raise ValueError("chat prompt format requires a tokenizer chat_template")
return str(value)
if artifact_kind == "standard" and getattr(tokenizer, "chat_template", None):
return CHAT_PROMPT_FORMAT
return RAW_PROMPT_FORMAT
def _canonical_scene_count(prompt: str) -> int:
scene_counts = _SCENE_COUNT_PATTERN.findall(prompt)
target_counts = _TARGET_SCENES_PATTERN.findall(prompt)
if len(scene_counts) != 1:
raise ValueError(
"chat-v3 prompt must contain exactly one canonical '- Scene Count: N' line"
)
if len(target_counts) != 1:
raise ValueError(
"chat-v3 prompt must contain exactly one canonical '- Target scenes: N' line"
)
scene_count = int(scene_counts[0])
target_count = int(target_counts[0])
if not _MIN_SCENE_COUNT <= scene_count <= _MAX_SCENE_COUNT:
raise ValueError("chat-v3 Scene Count must be between 1 and 6")
if not _MIN_SCENE_COUNT <= target_count <= _MAX_SCENE_COUNT:
raise ValueError("chat-v3 Target scenes must be between 1 and 6")
if scene_count != target_count:
raise ValueError("chat-v3 Scene Count and Target scenes must match")
return scene_count
def _transform_chat_prompt(prompt: str) -> str:
scene_count = _canonical_scene_count(prompt)
if not prompt.endswith(CHAT_STORY_BOUNDARY):
raise ValueError("chat-v3 prompt must end at the canonical '## Story' boundary")
return (
prompt[: -len(CHAT_STORY_BOUNDARY)]
+ _render_output_contract(scene_count)
+ CHAT_STORY_BOUNDARY
)
def chat_messages(prompt: str, completion: str | None = None) -> list[dict[str, str]]:
messages = [
{"role": "system", "content": CHAT_SYSTEM_PROMPT},
{"role": "user", "content": _transform_chat_prompt(prompt)},
]
if completion is not None:
messages.append({"role": "assistant", "content": completion})
return messages
def _chat_messages_for_format(
prompt: str,
completion: str | None,
prompt_format: str,
) -> list[dict[str, str]]:
if prompt_format == CHAT_PROMPT_FORMAT:
return chat_messages(prompt, completion)
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
return smollm3_chat_messages(prompt, completion)
raise ValueError(f"unsupported chat prompt format: {prompt_format}")
def _apply_runtime_chat_template(
tokenizer: Any,
messages: list[dict[str, str]],
*,
prompt_format: str,
add_generation_prompt: bool,
) -> Any:
kwargs: dict[str, Any] = {
"add_generation_prompt": add_generation_prompt,
"tokenize": True,
}
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT:
kwargs["enable_thinking"] = False
return tokenizer.apply_chat_template(messages, **kwargs)
def _flatten_token_ids(values: Any, *, context: str) -> list[int]:
"""Normalize chat-template outputs across supported Transformers versions."""
if isinstance(values, Mapping):
if "input_ids" not in values:
raise ValueError(f"{context} returned no input_ids")
values = values["input_ids"]
if hasattr(values, "tolist"):
values = values.tolist()
if not isinstance(values, Sequence) or isinstance(values, (str, bytes, bytearray)):
raise ValueError(f"{context} returned unsupported token IDs")
normalized = list(values)
if normalized and isinstance(normalized[0], Sequence) and not isinstance(
normalized[0], (str, bytes, bytearray)
):
if len(normalized) != 1:
raise ValueError(f"{context} returned more than one token sequence")
normalized = list(normalized[0])
try:
return [int(value) for value in normalized]
except (TypeError, ValueError) as exc:
raise ValueError(f"{context} returned non-integer token IDs") from exc
def generation_prompt_ids(tokenizer: Any, prompt: str, prompt_format: str) -> list[int]:
if prompt_format in CHAT_PROMPT_FORMATS:
values = _apply_runtime_chat_template(
tokenizer,
_chat_messages_for_format(prompt, None, prompt_format),
prompt_format=prompt_format,
add_generation_prompt=True,
)
context = (
"SmolLM3 v5 generation template"
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT
else "chat generation template"
)
return _flatten_token_ids(values, context=context)
if prompt_format != RAW_PROMPT_FORMAT:
raise ValueError(f"unsupported prompt format: {prompt_format}")
return [
int(tokenizer.bos_token_id),
*(
int(value)
for value in tokenizer.encode(prompt, add_special_tokens=False)
),
]
def assistant_training_ids(
tokenizer: Any,
prompt: str,
completion: str,
*,
max_length: int,
prompt_format: str = CHAT_PROMPT_FORMAT,
) -> tuple[list[int], list[int]]:
"""Create one chat sequence with loss masked through the assistant header."""
if prompt_format not in CHAT_PROMPT_FORMATS:
raise ValueError("assistant training requires a supported chat prompt format")
prefix = generation_prompt_ids(tokenizer, prompt, prompt_format)
full = _flatten_token_ids(
_apply_runtime_chat_template(
tokenizer,
_chat_messages_for_format(prompt, completion, prompt_format),
prompt_format=prompt_format,
add_generation_prompt=False,
),
context=(
"SmolLM3 v5 training template"
if prompt_format == SMOLLM3_CHAT_PROMPT_FORMAT
else "chat training template"
),
)
if full[: len(prefix)] != prefix:
raise ValueError("chat template assistant prefix is not stable")
if len(full) > max_length:
raise ValueError(
f"chat-formatted example has {len(full)} tokens; maximum is {max_length}"
)
if len(full) <= len(prefix):
raise ValueError("chat-formatted example has no assistant completion tokens")
labels = [-100] * len(prefix) + full[len(prefix) :]
return full, labels
def aggregate_sha256(paths: Sequence[Path], root: Path) -> str:
records = []
for path in sorted(paths):
records.append(
{
"path": path.relative_to(root).as_posix(),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
)
payload = json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(payload).hexdigest()