diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 2e3e47e2a5..dabdb3c665 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -400,6 +400,7 @@ skiplist skiplisted startoftext maxsim +multinomial adb benchmarkable benchprobe diff --git a/apps/nlp/app/_layout.tsx b/apps/nlp/app/_layout.tsx index 4db20c304f..7d637faa15 100644 --- a/apps/nlp/app/_layout.tsx +++ b/apps/nlp/app/_layout.tsx @@ -55,6 +55,13 @@ export default function Layout() { title: 'LLM', }} /> + ); } diff --git a/apps/nlp/app/constrained-decoding/index.tsx b/apps/nlp/app/constrained-decoding/index.tsx new file mode 100644 index 0000000000..ac34fc26ce --- /dev/null +++ b/apps/nlp/app/constrained-decoding/index.tsx @@ -0,0 +1,431 @@ +import React, { useEffect, useState } from 'react'; +import { Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; +import { + models, + RnExecuTorchError, + useLLMChatSession, + useTokenizer, + type llm, +} from 'react-native-executorch'; +import { Button } from '../../components/Button'; +import { ModelStatus } from '../../components/ModelStatus'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import { theme } from '../../theme'; + +const MODEL = models.llm.LFM2_5_350M.XNNPACK_8DA4W; +const COLORS = { amber: '#eea532', jade: '#2baf87', violet: '#9465db' }; +const OPTIONS = { + color: Object.keys(COLORS), + effect: ['healing', 'flight', 'invisibility'], + rarity: ['common', 'rare', 'legendary'], +}; +type Field = keyof typeof OPTIONS; +const FIELDS: Field[] = ['color', 'effect', 'rarity']; +// A finite choice grammar: each complete recipe has one canonical token path. +const RECIPES = OPTIONS.color.flatMap((color) => + OPTIONS.effect.flatMap((effect) => OPTIONS.rarity.map((rarity) => ({ color, effect, rarity }))) +); + +function validateOutput(output: string, selected: Record): string[] { + if (!output.trim()) return ['The model returned no text.']; + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch (error) { + return [`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`]; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return ['Expected a JSON object with color, effect, and rarity fields.']; + } + const record = parsed as Record; + const issues: string[] = []; + const keys = Object.keys(record); + for (const field of FIELDS) { + if (!Object.prototype.hasOwnProperty.call(record, field)) { + issues.push(`Missing field: ${field}.`); + } else if (!selected[field].includes(record[field] as string)) { + issues.push( + `${field}: received ${JSON.stringify(record[field])}; allowed: ${selected[field].join(', ')}.` + ); + } + } + const extra = keys.filter((key) => !FIELDS.includes(key as Field)); + if (extra.length) issues.push(`Unexpected fields: ${extra.join(', ')}.`); + if (!issues.length && keys.join(',') !== FIELDS.join(',')) { + issues.push('Fields must appear in color, effect, rarity order.'); + } + return issues; +} + +function PotionLab() { + const [loadStarted, setLoadStarted] = useState(false); + const [selected, setSelected] = useState>({ + color: ['amber', 'jade'], + effect: ['healing', 'flight'], + rarity: ['rare'], + }); + const [showRecipes, setShowRecipes] = useState(false); + const [constrained, setConstrained] = useState(true); + const [paths, setPaths] = useState([]); + const [output, setOutput] = useState(''); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState<'idle' | 'complete' | 'stopped' | 'failed'>('idle'); + const [runError, setRunError] = useState(''); + const session = useLLMChatSession(MODEL, { + preventLoad: !loadStarted, + resetOnTurn: true, + stopRegex: /\}$/, + initialMessages: [ + { + role: 'system', + content: + 'You are a fantasy alchemist. Reply with only a JSON object: ' + + '"color": "amber", "jade", or "violet"; ' + + '"effect": "healing", "flight", or "invisibility"; ' + + '"rarity": "common", "rare", or "legendary". ' + + 'Use exactly these three keys in that order, without markdown or explanation.', + }, + ], + }); + const { + encode, + decode, + error: tokenizerError, + } = useTokenizer(session.resource?.tokenizerPath ?? MODEL.tokenizerPath, { + preventLoad: !session.resource, + }); + + useEffect(() => { + if (!encode || !decode) return; + let cancelled = false; + const prepare = async () => { + const empty = await encode(''); + const compiled = await Promise.all( + RECIPES.map(async (recipe) => { + const json = JSON.stringify(recipe); + const encoded = await encode(json); + // Remove tokenizer-added BOS/EOS tokens before constraining generated output. + let start = 0; + let end = encoded.length; + while (start < end && start < empty.length && encoded[start] === empty[start]) start++; + let suffix = empty.length - 1; + while (end > start && suffix >= start && encoded[end - 1] === empty[suffix]) { + end--; + suffix--; + } + const tokens = encoded.slice(start, end); + if (!tokens.length || (await decode(tokens, true)) !== json) { + throw RnExecuTorchError('INVALID_STATE', 'Cannot tokenize potion recipes losslessly.'); + } + return Array.from(tokens); + }) + ); + if (!cancelled) setPaths(compiled); + }; + prepare().catch((error: unknown) => { + if (!cancelled) setRunError(error instanceof Error ? error.message : String(error)); + }); + return () => { + cancelled = true; + }; + }, [encode, decode]); + + const permittedRecipes = RECIPES.filter((recipe) => + FIELDS.every((field) => selected[field].includes(recipe[field])) + ); + const allowedPaths = paths.filter((_, index) => + FIELDS.every((field) => selected[field].includes(RECIPES[index]![field])) + ); + const toggleOption = (field: Field, value: string) => { + const values = selected[field]; + if (busy || (values.length === 1 && values.includes(value))) return; + setSelected({ + ...selected, + [field]: values.includes(value) + ? values.filter((item) => item !== value) + : [...values, value], + }); + setOutput(''); + setResult('idle'); + setRunError(''); + }; + + const brew = async () => { + if (!session.sendMessage || busy || !allowedPaths.length) return; + const recipePaths = allowedPaths; + setBusy(true); + setOutput(''); + setResult('idle'); + setRunError(''); + const constraints: llm.SamplingConstraintsCallback = ({ generatedTokens }) => { + 'worklet'; + // Hermes TypedArray.from expects array-like input; a Set can produce an + // empty typed array even when Set.size > 0, masking the entire vocabulary. + const allowed: number[] = []; + for (const path of recipePaths) { + if (path.length <= generatedTokens.length) continue; + let matches = true; + for (let i = 0; i < generatedTokens.length; i++) { + if (path[i] !== generatedTokens[i]) { + matches = false; + break; + } + } + const next = path[generatedTokens.length]!; + if (matches && !allowed.includes(next)) allowed.push(next); + } + if (!allowed.length) { + throw RnExecuTorchError('INVALID_STATE', 'No valid potion continuation.'); + } + return { allowedTokens: Int32Array.from(allowed) }; + }; + try { + await session.sendMessage( + `Create a potion. Choose one value per field from these allowed options: ${JSON.stringify(selected)}. ` + + 'Reply with only the JSON object, in color, effect, rarity order.', + (token) => setOutput((text) => text + token), + { + temperature: 0.9, + maxNewTokens: constrained ? Math.max(...recipePaths.map((path) => path.length)) : 160, + constraints: constrained ? constraints : undefined, + } + ); + setResult((current) => (current === 'stopped' ? current : 'complete')); + } catch (error) { + setResult('failed'); + setRunError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }; + const issues = result === 'complete' ? validateOutput(output, selected) : []; + const potion = + result === 'complete' && !issues.length + ? permittedRecipes.find( + (recipe) => JSON.stringify(recipe) === JSON.stringify(JSON.parse(output)) + ) + : undefined; + + return ( + + 🧪 Potion Lab + Model: LFM2.5 350M · XNNPACK · 8DA4W + + Choose which values each JSON field may contain, then let the model pick a potion. + + {!loadStarted ? ( + +