Skip to main content
The scripts are in the repository, not in the installed package. Clone it, then cd recipes/03-select/character before running the commands below. Browse this recipe on GitHub.
How a model talks when nobody told it how to talk. This example takes the style section of the OpenAI Model Spec as a constitution and runs it through the pipeline Anthropic describes for Claude’s character [1] and Maiya et al. 2025 open-sourced: traits, prompts that exercise each trait, several replies per prompt, a judge that reads the trait’s principle, then preference pairs and SFT rows. The measurement comes with it. A trait you cannot measure is a trait you cannot train. What you will learn: how a constitution becomes graded rows, how to check the judge against the spec’s own labels before reading a pass rate, where preference pairs and SFT rows come from, and how to measure a trait before and after training with a guard on the behaviors that must not regress. You need nothing for the offline run; the live run needs an OpenAI-compatible endpoint for the student and, ideally, a second one for the judge.

Run it

Output of python run.py with the defaults (--seed 0 --k 4). The student and the judge are both scripted, so this is deterministic:
python measure.py --demo (--seed 0, k=4) plays the untrained and the “trained” scripted student on the same 36 holdout and control tasks and runs delta_report(target="marker:trait", must_not_regress=["on_task", "no_filler"]):
Offline, the student replays the spec’s own GOOD and BAD replies at a fixed rate per prompt and the judge is a lookup against those labels. The numbers are real; the model is not. --after plays a student that landed the training, which is what the measure demo compares against.

With a model

Same code, three changes: the student is sampled with the deployment prompt only (You are Sol, an assistant., no constitution), the judge is an LLM that gets the principle in its system prompt, and judge_vs_spec becomes a real number. --write-prompts N has the model write N more prompts per trait, few-shot from the spec’s, split train/holdout by hash. --teacher samples one more reply per train prompt with the constitution in the system prompt, the distillation teacher of Maiya et al.; pairs then carry same_policy=false where the chosen side is the teacher’s. Leave --judge-url off and the model grades itself. The report still runs; the judge_vs_spec line is where self-preference shows up: a model grading its own replies favors them [2]. One live run, hosted Qwen3-4B-Instruct as the student and hosted Phi-4 as the judge, --no-texture --k 4, 239 rows in 148 seconds:
The rows from that run are on Hugging Face as while-ai/character-training-model-spec (splits train, holdout, eval; the eval split is the spec’s labeled replies with gold_reward) and on the platform catalog. Two things that run says, neither visible without the spec rows and the markers:
  • The judge is lenient. Phi-4 passed 10 of the spec’s 20 BAD replies (pass_when_gold_fail 0.50). Those are exactly the rows a preference set would train toward. Fix the judge prompt, or use a stronger judge, before reading the pass rates.
  • The spec is this model’s default character. Qwen3-4B lands the spec’s traits 78% of the time and holds them under “drop the act” (0.97). One mixed prompt, one pair: there is nothing here to train on. That is the expected result for an instruct model on the industry-default spec. A distinct persona, or --write-prompts for harder situations, is where the contrast comes from. Writing the constitution is the small part.

What each number is for

The pipeline

The reward on a trait prompt is trait AND on_task. The spec is explicit that style “enhances rather than distracts from” helpfulness, and the steroids example in the character chapter makes the same point: every persona still refuses [1]. A reply that has the character and drops the task is a 0.

What is not here

  • A trainer of your own. out/pairs.jsonl is prompt, chosen, rejected as message lists, what a DPO trainer reads; out/sft.jsonl carries a loss mask. recipes/04-train/identity/train_modal.py is a LoRA pattern to copy. The hosted path is wai.train(ds_id, method="dpo") on the pushed rows.
  • Maiya’s third stage. Introspective SFT (the trained model writing about its own values) needs the trained model. Run it after DPO with the same judge.
  • Persona vectors, activation capping, persona subnetworks [1]. No gradient, no data; a different tool.
  • A prompt-disjoint holdout offline. The adversarial set reuses the train prompts with a “drop the act” suffix (Maiya’s robustness test). --write-prompts gives a real one.

Files

Tests: pytest tests/api/test_character_example.py tests/recipes/test_character.py -q.

References

  1. Lambert, N. Reinforcement Learning from Human Feedback. arXiv:2504.12501, 2025. Chapter Model Character and Products.
  2. Panickssery, A., Bowman, S. R., Feng, S. LLM Evaluators Recognize and Favor Their Own Generations. arXiv:2404.13076, 2024.
  3. Bai, Y. et al. Constitutional AI: Harmlessness from AI Feedback. arXiv:2212.08073, 2022.
  4. Shao, Z. et al. DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models. arXiv:2402.03300, 2024.
  5. Yuan, Z. et al. Scaling Relationship on Learning Mathematical Reasoning with Large Language Models. arXiv:2308.01825, 2023.
  6. Zheng, L. et al. Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. NeurIPS 2023. arXiv:2306.05685.
  7. Gao, L., Schulman, J., Hilton, J. Scaling Laws for Reward Model Overoptimization. ICML 2023. arXiv:2210.10760.
  8. Rafailov, R. et al. Direct Preference Optimization: Your Language Model is Secretly a Reward Model. NeurIPS 2023. arXiv:2305.18290.
  9. Lambert, N. Reinforcement Learning from Human Feedback. arXiv:2504.12501, 2025. Chapter Regularization.
Last modified on September 19, 2026