mindXtrain / mindxtrain /data /scripts.py
Gregory-L's picture
fork mindXtrain from GitHub (Professor-Codephreak/mindXtrain@661bd41) as the mindX-specific line
dfb775d verified
Raw
History Blame Contribute Delete
7.1 kB
"""Author a training *script* for an *actor*.
The mindXtrain mental model: a **model is an actor**; an actor has a **persona**
(identity / voice) and a **script** (the training examples — the "impression" left
on the actor when it trains). This module is the clean-room primitive for building
a script from a persona + a handful of exchanges, written as the OpenAI-chat JSONL
that `data.source: local` ingests (`{"messages": [{role, content}, ...]}`).
Clean-room: the Codephreak persona is *loaded* at runtime from
`MINDXTRAIN_PERSONA_PATH` (or a caller-supplied path); we never copy mindX bytes —
we read recognised fields and ignore the rest.
Pure stdlib + pydantic; importable on a base install (no `--extra ml`).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
# Recognised keys for a persona's identity/voice in an mindX-style persona JSON.
# We map these defensively — any other keys are ignored (clean-room read).
_NAME_KEYS = ("name", "persona", "id", "title")
_SYSTEM_KEYS = ("system_prompt", "system", "description", "bio", "summary", "prompt")
_VOICE_KEYS = ("voice_examples", "examples", "utterances", "samples", "voice")
class Persona(BaseModel):
"""The identity to imprint onto an actor."""
model_config = ConfigDict(extra="forbid", frozen=True)
name: str = "actor"
system_prompt: str = ""
voice_examples: list[str] = Field(
default_factory=list,
description="Example in-voice utterances; seed rows + the imprint baseline.",
)
class Exchange(BaseModel):
"""One user→assistant turn in a script."""
model_config = ConfigDict(extra="forbid", frozen=True)
user: str
assistant: str
def load_persona(path: str | Path | None = None) -> Persona:
"""Load a persona, clean-room, from JSON.
Resolution: explicit `path` → `MINDXTRAIN_PERSONA_PATH` → a built-in minimal
default. Reads only recognised fields; unknown keys are ignored so an
arbitrary mindX persona file maps cleanly without copying its schema.
"""
resolved = path or os.environ.get("MINDXTRAIN_PERSONA_PATH")
if not resolved:
return _default_persona()
p = Path(resolved).expanduser()
if not p.is_file():
return _default_persona()
try:
raw = json.loads(p.read_text())
except (json.JSONDecodeError, OSError):
return _default_persona()
if not isinstance(raw, dict):
return _default_persona()
return persona_from_dict(raw)
def persona_from_dict(raw: dict) -> Persona:
"""Build a Persona from a loosely-shaped dict (recognised keys only)."""
name = next((str(raw[k]) for k in _NAME_KEYS if raw.get(k)), "actor")
system = next((str(raw[k]) for k in _SYSTEM_KEYS if raw.get(k)), "")
voice: list[str] = []
for k in _VOICE_KEYS:
v = raw.get(k)
if isinstance(v, list):
voice.extend(str(x) for x in v if isinstance(x, (str, int, float)))
elif isinstance(v, str):
voice.append(v)
return Persona(name=name, system_prompt=system, voice_examples=voice)
def _default_persona() -> Persona:
return Persona(
name="actor",
system_prompt="You are a helpful, concise assistant.",
voice_examples=[],
)
def persona_system_prompt(persona: Persona) -> str:
"""The system message that fronts every row of the script.
Uses the persona's own system prompt when present, otherwise synthesises a
minimal one from the name so the actor still has an identity to imprint.
"""
if persona.system_prompt.strip():
return persona.system_prompt.strip()
return f"You are {persona.name}. Stay in character and answer in your own voice."
def build_script_rows(
persona: Persona,
exchanges: list[Exchange],
*,
seed_voice: bool = True,
) -> list[dict]:
"""Turn a persona + exchanges into OpenAI-chat rows for `source: local`.
Each row carries the persona system prompt + one user→assistant turn. When
`seed_voice` is set, the persona's voice examples are added as extra
assistant-only demonstrations so a tiny model has voice to imprint even from
very few exchanges.
"""
system = persona_system_prompt(persona)
rows: list[dict] = []
for ex in exchanges:
rows.append(
{
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": ex.user},
{"role": "assistant", "content": ex.assistant},
],
},
)
if seed_voice:
for sample in persona.voice_examples:
rows.append(
{
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": f"Say something as {persona.name}."},
{"role": "assistant", "content": sample},
],
},
)
return rows
def write_script_jsonl(rows: list[dict], out_path: str | Path) -> Path:
"""Write script rows as JSONL; returns the path. Parent dirs are created."""
out = Path(out_path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
return out
def derive_training_params(num_rows: int) -> dict[str, int]:
"""Derive CPU-imprint training params from the dataset size.
A small persona/skill script must *overfit* to imprint (many epochs, grad_accum 1
so a few-row script still does many optimizer steps); larger datasets taper toward
ordinary SFT. Returns `{epochs, grad_accum, per_device}` the imprint lane can apply.
"""
n = max(1, num_rows)
if n <= 8:
epochs, grad_accum = 24, 1
elif n <= 32:
epochs, grad_accum = 16, 1
elif n <= 128:
epochs, grad_accum = 8, 1
elif n <= 512:
epochs, grad_accum = 4, 2
else:
epochs, grad_accum = 2, 4
return {"epochs": epochs, "grad_accum": grad_accum, "per_device": 1}
def author_script(
*,
out_path: str | Path,
exchanges: list[Exchange],
persona: Persona | None = None,
persona_path: str | Path | None = None,
seed_voice: bool = True,
) -> tuple[Path, int]:
"""One-call script authoring: persona + exchanges → JSONL on disk.
Returns (path, row_count). The persona is taken as-given, else loaded
clean-room from `persona_path` / `MINDXTRAIN_PERSONA_PATH` / the default.
"""
actor_persona = persona or load_persona(persona_path)
rows = build_script_rows(actor_persona, exchanges, seed_voice=seed_voice)
path = write_script_jsonl(rows, out_path)
return path, len(rows)
__all__ = [
"Exchange",
"Persona",
"author_script",
"build_script_rows",
"derive_training_params",
"load_persona",
"persona_from_dict",
"persona_system_prompt",
"write_script_jsonl",
]