/** * Type of expected answer for deterministic comparison */ export type AnswerType = | 'integer' | 'number' | 'boolean' | 'date' | 'string' | 'csv-list-ordered' | 'csv-list-unordered' /** * Options for answer normalization and comparison */ export interface NormalizationOptions { /** * Tolerance for floating-point number comparison (e.g., 1e-6). * @default 1e-6 */ tolerance?: number /** * Whether string comparison should be case-sensitive. * @default false */ caseSensitive?: boolean /** * Allow currency symbols ($, €, etc.) in number extraction. * @default true */ allowCurrency?: boolean /** * Allow percent signs (%) in number extraction (will divide by 100). * @default true */ allowPercent?: boolean /** * Number of decimal places to round to for number comparison. * If specified, overrides tolerance-based comparison. */ decimalPlaces?: number } interface NormalizedResult { success: boolean value?: unknown error?: string } /** * Default normalization options */ const DEFAULT_OPTIONS: Required = { tolerance: 1e-6, caseSensitive: false, allowCurrency: true, allowPercent: true, decimalPlaces: undefined!, } // Regex pattern constants const INTEGER_PATTERN_WITH_CURRENCY = /[$€£¥]?\s*-?\d[\d,]*/ const INTEGER_PATTERN = /-?\d[\d,]*/ const NUMBER_PATTERN_WITH_CURRENCY = /[$€£¥]?\s*-?\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?%?/i const NUMBER_PATTERN = /-?\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?%?/i const WRAPPING_QUOTES_PATTERN = /^["']|["']$/g const CODE_FENCE_PATTERN = /^```[\s\S]*?```$/g const LANGUAGE_IDENTIFIER_PATTERN = /^\w+\n/ const CURRENCY_AND_FORMATTING_CHARS = /[$€£¥,\s]/g const NUMBER_CLEANUP_CHARS = /[$€£¥,%\s]/g // Boolean value constants const TRUE_VALUES = new Set(['true', 'yes', 'y', '1']) const FALSE_VALUES = new Set(['false', 'no', 'n', '0']) // Numeric constants const PERCENTAGE_DIVISOR = 100 const DECIMAL_BASE = 10 const MONTH_OFFSET = 1 // JavaScript months are 0-indexed const DATE_COMPONENT_WIDTH = 2 const DATE_PAD_CHAR = '0' // String constants const CSV_DELIMITER = ',' /** * Strip wrapping quotes from a string */ function stripWrappingQuotes(text: string): string { return text.trim().replace(WRAPPING_QUOTES_PATTERN, '') } /** * Extract and normalize an integer from a string * * @remarks * Handles: "42", "1,234", "$5,678", " -99 ", "The answer is 42." */ function normalizeInteger(text: string, options: Required): NormalizedResult { // Strip common formatting, extract first integer-like token const pattern = options.allowCurrency ? INTEGER_PATTERN_WITH_CURRENCY : INTEGER_PATTERN const match = text.match(pattern) if (!match) return { success: false, error: `No integer found in: "${text}"` } // Remove currency symbols, spaces, and thousand separators const normalizedValue = match[0].replace(CURRENCY_AND_FORMATTING_CHARS, '') const parsedNumber = Number.parseInt(normalizedValue, DECIMAL_BASE) if (Number.isNaN(parsedNumber)) return { success: false, error: `Failed to parse integer: "${match[0]}"` } return { success: true, value: parsedNumber } } /** * Extract and normalize a floating-point number from a string * * @remarks * Handles: "3.14", "1,234.56", "$5,678.90", "42%", "1.5e-3", "Price: $99.99" */ function normalizeNumber(text: string, options: Required): NormalizedResult { // Extract first number-like token (supports scientific notation) const pattern = options.allowCurrency ? NUMBER_PATTERN_WITH_CURRENCY : NUMBER_PATTERN const match = text.match(pattern) if (!match) return { success: false, error: `No number found in: "${text}"` } const token = match[0] const hasPercentSign = options.allowPercent && token.endsWith('%') // Remove currency, commas, spaces, and percent sign const normalizedToken = token.replace(NUMBER_CLEANUP_CHARS, '') let parsedNumber = Number.parseFloat(normalizedToken) if (Number.isNaN(parsedNumber)) return { success: false, error: `Failed to parse number: "${token}"` } // Convert percentage to decimal if present if (hasPercentSign) parsedNumber = parsedNumber / PERCENTAGE_DIVISOR // Round to specified decimal places if requested if (options.decimalPlaces !== undefined) { const factor = DECIMAL_BASE ** options.decimalPlaces parsedNumber = Math.round(parsedNumber * factor) / factor } return { success: true, value: parsedNumber } } /** * Normalize a boolean/yes-no answer * * @remarks * Handles: "true", "false", "yes", "no", "y", "n", "1", "0" (case-insensitive) */ function normalizeBoolean(text: string): NormalizedResult { const normalizedValue = text.trim().toLowerCase() if (TRUE_VALUES.has(normalizedValue)) return { success: true, value: true } if (FALSE_VALUES.has(normalizedValue)) return { success: true, value: false } return { success: false, error: `Not a boolean: "${text}"` } } /** * Normalize a date string to YYYY-MM-DD format * * @remarks * Handles: ISO dates, "Nov 1, 2025", "2025-11-01", RFC 2822, etc. */ function normalizeDate(text: string): NormalizedResult { const cleaned = stripWrappingQuotes(text) // Try parsing as date const parsedDate = new Date(cleaned) if (Number.isNaN(parsedDate.getTime())) return { success: false, error: `Invalid date: "${text}"` } // Normalize to YYYY-MM-DD (UTC) const year = parsedDate.getUTCFullYear() const monthPadded = String(parsedDate.getUTCMonth() + MONTH_OFFSET).padStart(DATE_COMPONENT_WIDTH, DATE_PAD_CHAR) const dayPadded = String(parsedDate.getUTCDate()).padStart(DATE_COMPONENT_WIDTH, DATE_PAD_CHAR) const normalized = `${year}-${monthPadded}-${dayPadded}` return { success: true, value: normalized } } /** * Normalize a string (trim, optionally case-insensitive) * * @remarks * Handles wrapping quotes and code fences. */ function normalizeString(text: string, options: Required): NormalizedResult { let trimmedText = text.trim() // Strip wrapping quotes trimmedText = trimmedText.replace(WRAPPING_QUOTES_PATTERN, '') // Strip code fences (```...```) trimmedText = trimmedText.replace(CODE_FENCE_PATTERN, (match) => { const inner = match.slice(3, -3).trim() // Remove language identifier if present (e.g., ```json) return inner.replace(LANGUAGE_IDENTIFIER_PATTERN, '') }) trimmedText = trimmedText.trim() const value = options.caseSensitive ? trimmedText : trimmedText.toLowerCase() return { success: true, value } } /** * Normalize a comma-separated list (ordered) * * @remarks * Handles: "a,b,c", "a, b, c", " a , b , c " */ function normalizeCsvListOrdered(text: string, options: Required): NormalizedResult { const strippedText = stripWrappingQuotes(text) const items = strippedText .split(CSV_DELIMITER) .map(item => item.trim()) .filter(item => item.length > 0) const normalizedItems = items.map(item => options.caseSensitive ? item : item.toLowerCase(), ) return { success: true, value: normalizedItems } } /** * Normalize a comma-separated list (unordered, compare as sets) * * @remarks * Handles: "c,a,b" equals "a,b,c" */ function normalizeCsvListUnordered(text: string, options: Required): NormalizedResult { const result = normalizeCsvListOrdered(text, options) if (!result.success) return result // Type guard: ensure result.value is an array if (!Array.isArray(result.value)) return { success: false, error: 'Expected array result from normalizeCsvListOrdered' } // Sort for deterministic comparison const sorted = [...result.value].sort() return { success: true, value: sorted } } /** * Normalize a value based on its expected kind */ export function normalizeAnswer( text: string, kind: AnswerType, options: Partial = {}, ): NormalizedResult { const resolvedOptions: Required = { ...DEFAULT_OPTIONS, ...options } switch (kind) { case 'integer': return normalizeInteger(text, resolvedOptions) case 'number': return normalizeNumber(text, resolvedOptions) case 'boolean': return normalizeBoolean(text) case 'date': return normalizeDate(text) case 'string': return normalizeString(text, resolvedOptions) case 'csv-list-ordered': return normalizeCsvListOrdered(text, resolvedOptions) case 'csv-list-unordered': return normalizeCsvListUnordered(text, resolvedOptions) default: return { success: false, error: `Unknown answer kind: ${kind}` } } } /** * Compare two normalized values based on answer kind */ function compareValues( actual: unknown, expected: unknown, kind: AnswerType, options: Required, ): boolean { switch (kind) { case 'integer': case 'boolean': case 'date': case 'string': return actual === expected case 'number': if (typeof actual !== 'number' || typeof expected !== 'number') return false if (options.decimalPlaces !== undefined) { // Already rounded during normalization return actual === expected } return Math.abs(actual - expected) <= options.tolerance case 'csv-list-ordered': if (!Array.isArray(actual) || !Array.isArray(expected)) return false if (actual.length !== expected.length) return false return actual.every((item, i) => item === expected[i]) case 'csv-list-unordered': if (!Array.isArray(actual) || !Array.isArray(expected)) return false if (actual.length !== expected.length) return false // Already sorted during normalization return actual.every((item, i) => item === expected[i]) default: return false } } /** * Compare actual and expected answers with deterministic, type-aware normalization * * @remarks * Returns true if answers match within the specified tolerance/rules. */ export function compareAnswers( actual: string, expected: string, kind: AnswerType, options: Partial = {}, ): { match: boolean, details?: string } { const resolvedOptions: Required = { ...DEFAULT_OPTIONS, ...options } // Normalize both answers const actualResult = normalizeAnswer(actual, kind, resolvedOptions) const expectedResult = normalizeAnswer(expected, kind, resolvedOptions) // If either normalization failed, return false with details if (!actualResult.success) { return { match: false, details: `Failed to normalize actual answer: ${actualResult.error}`, } } if (!expectedResult.success) { return { match: false, details: `Failed to normalize expected answer: ${expectedResult.error}`, } } // Compare normalized values const match = compareValues(actualResult.value, expectedResult.value, kind, resolvedOptions) return { match, details: match ? undefined : `Mismatch: actual="${actualResult.value}" vs expected="${expectedResult.value}"`, } }