/** * Deterministic, locale-aware value parsing used for evidence checks. * * The model sees text; code decides whether a source span really supports a * value. These helpers never guess when a format is ambiguous: a span that can * be read two ways yields no match, and the field goes to review. */ function isCalendarDate(year: number, month: number, day: number): boolean { if (year < 1 || month < 1 || month > 12 || day < 1) return false; const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; return day <= days[month - 1]!; } const pad = (n: number): string => String(n).padStart(2, '0'); /** Returns every unambiguous ISO rendering of the span (empty when ambiguous). */ export function dateCandidates(text: string): string[] { const t = text.trim(); const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); if (iso) { const [y, m, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])]; return isCalendarDate(y, m, d) ? [`${y}-${pad(m)}-${pad(d)}`] : []; } const compact = /^(\d{4})(\d{2})(\d{2})$/.exec(t); if (compact) { const [y, m, d] = [Number(compact[1]), Number(compact[2]), Number(compact[3])]; return isCalendarDate(y, m, d) ? [`${y}-${pad(m)}-${pad(d)}`] : []; } const numeric = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4})$/.exec(t); if (numeric) { const a = Number(numeric[1]); const b = Number(numeric[2]); const y = Number(numeric[3]); // Both day-first and month-first are possible for <=12/<=12: no match. if (a <= 12 && b <= 12) return []; const [day, month] = a > 12 ? [a, b] : [b, a]; return isCalendarDate(y, month, day) ? [`${y}-${pad(month)}-${pad(day)}`] : []; } return []; } /** Returns every unambiguous decimal rendering of the span. */ export function decimalCandidates(text: string): string[] { const t = text.trim().replace(/[\s\u00a0\u202f]/g, ''); if (!/^[+-]?[\d.,]+$/.test(t) || !/\d/.test(t)) return []; const sign = t.startsWith('-') ? '-' : ''; const body = t.replace(/^[+-]/, ''); const hasComma = body.includes(','); const hasDot = body.includes('.'); const out = new Set(); const add = (value: string) => { if (/^(0|[1-9]\d*)(\.\d+)?$/.test(value)) out.add(`${sign}${value}`); }; if (hasComma && hasDot) { const lastComma = body.lastIndexOf(','); const lastDot = body.lastIndexOf('.'); if (lastComma > lastDot) add(body.replace(/\./g, '').replace(',', '.')); else add(body.replace(/,/g, '')); } else if (hasComma) { const parts = body.split(','); if (parts.length === 2 && parts[1]!.length <= 2) add(`${parts[0]}.${parts[1]}`); add(body.replace(/,/g, '')); } else if (hasDot) { const parts = body.split('.'); if (parts.length === 2 && parts[1]!.length <= 2) add(body); add(body.replace(/\./g, '')); } else { add(body); } return [...out]; } /** * Canonical decimal string: no exponent, no leading '+', no trailing zeros in * the fraction. Used only for equality checks; the delivered value keeps its * original precision. */ export function canonicalDecimal(value: string): string | undefined { const match = /^([+-]?)(0|[1-9]\d*)(?:\.(\d+))?$/.exec(value.trim()); if (!match) return undefined; const sign = match[1] === '-' ? '-' : ''; const fraction = (match[3] ?? '').replace(/0+$/, ''); return `${sign}${match[2]}${fraction ? `.${fraction}` : ''}`; }