Something went wrong. Try again.
a tiny pet with a GPT-2 brain, living in your browser
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443//! The trained prompt contract: closed word lists, headers, lookups, event//! lines, the tag grammar and the generation stop rules. `train/world.py`//! renders the same strings for training; `node test/parity.mjs` and//! `node server/tests/parity.mjs` check both builds of this module against it.use crate::types::{Stats, WorldState};use regex_lite::Regex;use std::{collections::HashSet, sync::LazyLock};
pub const STAGES: &[&str] = &["baby", "kid", "teen", "adult"];pub const PERSONALITIES: &[&str] = &[ "bouncy", "shy", "curious", "sleepy", "dramatic", "foodie", "silly", "sweet", "grumpy", "brave", "artsy", "dreamy", "chatty", "clingy", "sassy", "polite",];pub const FOODS: &[&str] = &[ "strawberry", "cookie", "rice ball", "carrot", "pudding", "apple",];pub const GIFTS: &[&str] = &[ "flower", "pebble", "star", "leaf", "shell", "heart", "drawing", "feather",];pub const DREAMS: &[&str] = &[ "strawberry", "cookie", "rice ball", "carrot", "pudding", "apple", "flower", "pebble", "star", "leaf", "shell", "heart", "drawing", "feather", "ball", "moon", "sun", "cloud", "rain", "frog", "cat", "ghost", "music", "sparkle",];pub const EMOTES: &[&str] = &[ "happy", "love", "sad", "sleepy", "wave", "dance", "spin", "blush", "grumpy", "wow", "giggle", "cry",];pub const WANTS: &[&str] = &["snack", "play", "bath", "sleep", "pats", "medicine"];pub const WALKS: &[&str] = &["left", "right"];pub const DOODLES: &[&str] = &[ "heart", "star", "strawberry", "cloud", "moon", "sun", "flower", "cookie", "frog", "rain", "music", "cat", "ghost", "sparkle",];pub const WEATHER_DAY: &[&str] = &["sunny", "cloudy", "rainy", "windy", "snowy", "foggy"];pub const WEATHER_NIGHT: &[&str] = &["clear", "cloudy", "rainy", "windy", "snowy", "starry"];pub const TOYS: &[&str] = &["ball", "yarn", "tiny drum", "rubber duck", "blocks"];/// Body colors are the pet's look, not part of the prompt.pub const COLORS: &[&str] = &["cream", "pink", "mint", "butter", "lavender", "sky"];pub const EFFECT_TOOLS: &[&str] = &[ "emote", "want", "remember", "gift", "sleep", "dream", "diary", "walk", "yes", "no",];/// `[yes]`/`[no]` answer an offer event line and may appear nowhere else.pub const ANSWER_TOOLS: &[&str] = &["yes", "no"];pub const LOOKUP_TOOLS: &[&str] = &["clock", "recall", "look", "yesterday"];
/// One tag: `[name]`, `[name:arg]`, optionally followed by a lookup `{result}`.pub static TAG: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"\[([A-Za-z0-9_]+)(?::([^\]\n]{1,80}))?\](\{[^}\n]*\})?").unwrap()});/// GPT-2 learned from the open web; keep the pet's mouth clean regardless./// ASCII word boundaries and case folding, as the browser's regex had.pub static NOT_CUTE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"(?i)\b(sex\w*|porn\w*|nude\w*|naked|panties|penis|vagina|cock|dick|pussy|boob\w*|tits?|fuck\w*|shit\w*|bitch\w*|cunt|whore|slut|rape\w*|nigg\w*|fag\w*|retard\w*|kill (you|yourself)|suicide)\b").unwrap()});// GPT-2 has a single "][" token, so a lookup can close mid-token.static LOOKUP_END: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"\[(clock|look|yesterday|recall)(?::([^\]\n]{1,80}))?\]\[?$").unwrap()});static WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-z0-9']+").unwrap());static SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
pub fn part_of_day(h: u32) -> &'static str { match h { 5..=11 => "morning", 12..=16 => "afternoon", 17..=20 => "evening", _ => "night", }}pub fn level_words(s: &Stats) -> [&'static str; 4] { fn word(v: f64, words: &[&'static str]) -> &'static str { words[((v.round() * words.len() as f64 / 101.0).floor() as usize).min(words.len() - 1)] } [ word( s.tummy, &["starving", "hungry", "peckish", "full", "stuffed"], ), word(s.fun, &["sad", "bored", "okay", "happy", "giddy"]), word(s.energy, &["exhausted", "sleepy", "okay", "awake", "hyper"]), word(s.clean, &["stinky", "messy", "clean", "sparkly"]), ]}fn tail<T>(items: &[T], n: usize) -> &[T] { &items[items.len().saturating_sub(n)..]}pub fn header(kind: &str, st: &WorldState) -> String { let w = level_words(&st.stats); let mut lines = vec![ format!("## tokenling {kind}"), format!( "pet: {} · {} · day {} · {}", st.pet, st.stage, st.day, st.traits.join(", ") ), format!("owner: {}", st.owner), ]; if kind == "chat" { let mut now = format!( "now: {:02}:{:02} {}, {}", st.hh, st.mm, part_of_day(st.hh), st.weather ); if st.asleep { now += &format!(", {} is asleep", st.pet); } if st.sick { now += &format!(", {} is sick", st.pet); } lines.push(now); lines.push(format!( "needs: tummy {}, fun {}, energy {}, {}", w[0], w[1], w[2], w[3] )); } else { lines.push(format!("weather: {}", st.weather)); } if !st.memories.is_empty() { lines.push(format!("remembers: {}", tail(&st.memories, 3).join("; "))); } let said = format!("{} said: ", st.owner); let today: Vec<_> = st .today .iter() .filter(|t| kind != "chat" || !t.starts_with(&said)) .cloned() .collect(); if !today.is_empty() { lines.push(format!("today: {}", tail(&today, 6).join("; "))); } lines.push("---".into()); lines.join("\n") + "\n"}
pub fn words(text: &str) -> HashSet<String> { const STOP: &[&str] = &[ "the", "and", "you", "your", "are", "was", "has", "have", "for", "with", "what", "who", "does", "did", "about", "that", "this", "like", "likes", "she", "her", "his", "him", "they", "them", "named", "name", "is", ]; WORD.find_iter(&text.to_lowercase()) .filter_map(|m| { let mut word = m.as_str().trim_matches('\'').to_owned(); if word.len() < 3 || STOP.contains(&word.as_str()) { return None; } if word.ends_with('s') && word.len() > 3 { word.pop(); } Some(word) }) .collect()}/// Deterministic word-overlap search, exactly as the training data's results.pub fn recall(memories: &[String], query: &str) -> String { let q = words(query); let hits: Vec<_> = memories .iter() .rev() .filter(|m| words(m).iter().any(|w| q.contains(w))) .take(2) .cloned() .collect(); if hits.is_empty() { "nothing".into() } else { hits.join("; ") }}pub fn clock(st: &WorldState) -> String { format!("{:02}:{:02} {}", st.hh, st.mm, part_of_day(st.hh))}pub fn look(st: &WorldState) -> String { let shelf = if st.shelf.is_empty() { "empty".into() } else { tail(&st.shelf, 4).join(", ") }; let mut out = format!("shelf: {shelf} · toy: {} · window: {}", st.toy, st.weather); if st.poop > 0 { out += &format!(" · poop x{}", st.poop); } out}/// The `{result}` the app appends after a lookup tag. The model never writes it.pub fn tool_result(st: &WorldState, name: &str, arg: &str) -> Option<String> { match name { "clock" => Some(clock(st)), "look" => Some(look(st)), "recall" => Some(recall(&st.memories, arg)), "yesterday" => Some(if st.yesterday.is_empty() { "no diary yet".into() } else { st.yesterday.clone() }), _ => None, }}pub fn a(food: &str) -> String { format!( "{} {food}", if food.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" } )}pub fn dream_thing(item: &str) -> String { match item { "music" | "rain" => item.into(), "moon" | "sun" => format!("the {item}"), _ => a(item), }}/// Chance that a disturbance wakes a sleeping pet: talking, touching or the/// lights, the pet's energy and how many disturbances came within 5 minutes.pub fn wake_chance(energy: f64, kind: &str, n: u32) -> f64 { let base = match kind { "talk" => 0.03, "touch" => 0.1, _ => 0.2, }; (base + 0.007 * energy + 0.12 * (n as f64 - 1.0)).clamp(0.0, 0.9)}
/// App event lines, exactly as `train/world.py` `event_patterns()` accepts them./// Offers ask the pet to decide; its next line answers with `[yes]` or `[no]`.pub mod events { use super::a; pub fn offer_food(o: &str, p: &str, food: &str) -> String { format!("* {o} offered {p} {} *", a(food)) } pub fn offer_bath(o: &str, p: &str) -> String { format!("* {o} offered {p} a bath *") } pub fn offer_medicine(o: &str, p: &str) -> String { format!("* {o} offered {p} medicine *") } pub fn offer_catch(o: &str, p: &str) -> String { format!("* {o} asked {p} to play catch *") } pub fn caught(o: &str, p: &str, n: u32) -> String { format!("* {o} played catch with {p} ({n} {}) *", stars(n)) } pub fn pat(o: &str, p: &str) -> String { format!("* {o} patted {p} *") } pub fn lights(o: &str, on: bool) -> String { format!( "* {o} turned {} the lights *", if on { "on" } else { "off" } ) } pub fn woke(p: &str) -> String { format!("* {p} woke up *") } pub fn pooped(p: &str) -> String { format!("* {p} pooped *") } pub fn cleaned(o: &str) -> String { format!("* {o} cleaned up the poop *") } pub fn back(o: &str, span: &str) -> String { format!("* {o} came back after {span} *") } pub fn grew(p: &str, stage: &str) -> String { format!("* {p} grew into a {stage}! *") } pub fn stars(n: u32) -> &'static str { if n == 1 { "star" } else { "stars" } }}
/// A tag the brain wrote; `result` is a lookup result the app appended.#[derive(Clone, Debug, PartialEq, serde::Serialize)]pub struct Tag { pub name: String, pub arg: Option<String>, pub result: Option<String>,}pub fn tags(text: &str) -> Vec<Tag> { TAG.captures_iter(text) .map(|c| Tag { name: c[1].to_owned(), arg: c.get(2).map(|m| m.as_str().to_owned()), result: c.get(3).map(|m| m.as_str()[1..m.len() - 1].to_owned()), }) .collect()}/// Text with tags, lookup results and stray brackets removed.pub fn clean_text(text: &str) -> String { SPACES .replace_all( &TAG.replace_all(text, " ").replace(['[', ']', '{', '}'], ""), " ", ) .trim() .to_owned()}/// Diary text: tags and stray brackets removed, paragraphs kept.pub fn clean_page(text: &str) -> String { let stripped = TAG.replace_all(text, " ").replace(['[', ']', '{', '}'], ""); stripped .lines() .map(compact) .filter(|l| !l.is_empty()) .collect::<Vec<_>>() .join("\n")}/// A line made only of tags: the pet acting on its own.pub fn silent(text: &str) -> bool { !clean_text(text).bytes().any(|b| b.is_ascii_alphabetic())}pub fn not_cute(text: &str) -> bool { NOT_CUTE.is_match(text)}pub fn compact(text: &str) -> String { SPACES.replace_all(text, " ").trim().to_owned()}/// Owner-supplied names: no characters that could forge prompt structure.pub fn clean_name(name: &str, max: usize) -> String { name.trim() .chars() .filter(|c| ![':', '*', '[', ']', '{', '}', '\n'].contains(c)) .take(max) .collect()}/// Owner messages: one line without tag or event punctuation, at most 200 characters.pub fn clean_message(text: &str) -> String { compact(&text.replace(['\n', '[', ']', '{', '}', '*'], " ")) .chars() .take(200) .collect()}
/// A complete lookup tag at the end of generated text, ready for its result.pub fn lookup_tail(text: &str) -> Option<(String, String)> { let c = LOOKUP_END.captures(text)?; let name = c[1].to_owned(); let arg = c.get(2).map_or("", |m| m.as_str()).to_owned(); if (name == "recall") == arg.is_empty() { return None; } Some((name, arg))}/// Where generation stops: a chat line at its newline or right after a/// complete lookup tag (so the app can append `{result}`); a diary at a new/// header or a blank gap. Returns the reason and the text to keep.pub fn stop(mode: &str, text: &str) -> Option<(&'static str, String)> { if mode == "line" { if let Some(at) = text.find('\n') { return Some(("line", text[..at].to_owned())); } if lookup_tail(text).is_some() { let kept = text .strip_suffix("][") .map_or_else(|| text.to_owned(), |s| s.to_owned() + "]"); return Some(("lookup", kept)); } } else if let Some(at) = text.find("\n##").or_else(|| text.find("\n\n\n")) { return Some(("end", text[..at].to_owned())); } None}/// Drops the oldest chat lines until header, lines and prefix fit in `ctx`/// tokens with room for `max_new`. `count` is the brain's tokenizer.pub fn fit( header: &str, lines: &[String], prefix: &str, max_new: usize, ctx: usize, count: &mut dyn FnMut(&str) -> Result<usize, String>,) -> Result<String, String> { let budget = ctx .min(1024) .checked_sub(max_new + 4) .ok_or("generation budget exceeds context")?; let mut used = count(header)? + count(prefix)?; if used > budget { return Err("prompt header does not fit the selected brain context".into()); } let mut start = lines.len(); while start > 0 { let size = count(&(lines[start - 1].clone() + "\n"))?; if used + size > budget { break; } used += size; start -= 1; } Ok(header.to_owned() + &lines[start..] .iter() .map(|l| l.clone() + "\n") .collect::<String>() + prefix)}