fix: match disposition abbreviations like "Recon" via prefix, not just typos

Levenshtein distance alone scored "Recon" too far from "Reconnaissance"
(9 edits) to pass the typo threshold, since it's a truncation rather than
a misspelling. Add a prefix check against each canonical name (and a
filler-word-stripped "core" form, so "Take Hold" matches "Take and Hold")
gated to 3+ characters to avoid collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 12:30:05 -05:00
co-authored by Claude Sonnet 5
parent 6649f0e0da
commit 0d7da75d50
+20 -6
View File
@@ -137,17 +137,31 @@ function levenshtein(a, b) {
return prev[n]; return prev[n];
} }
const normalizeDispositionText = s => String(s || "").trim().toLowerCase().replace(/[^a-z ]/g, "").replace(/\s+/g, " ");
// Filler-word-stripped form of each canonical name, so abbreviations that
// drop "and"/"the" (e.g. "Take Hold") still line up: "Take and Hold" -> "take hold".
const dispositionCore = canonical => normalizeDispositionText(canonical).replace(/\b(and|the)\b/g, "").replace(/\s+/g, " ").trim();
// Nearest canonical disposition, or null if nothing is close enough to be // Nearest canonical disposition, or null if nothing is close enough to be
// confident it's a typo rather than unrelated text. Threshold scales with // confident it's a typo/abbreviation rather than unrelated text.
// name length (~25%) so e.g. "Priority Assests" (1 char off "Priority // Two ways to match:
// Assets") matches, but a short unrelated line doesn't accidentally match // - Prefix: shorthand like "Recon" or "Disrupt" that's a truncation, not a
// "Disruption". // typo, so Levenshtein distance alone would score it as far away. Gated
// to 3+ chars so short unrelated words can't collide (the five names'
// first three letters — pur/tak/pri/dis/rec — are all distinct).
// - Levenshtein: everything else, e.g. "Priority Assests". Threshold scales
// with name length (~25%) so a short unrelated line doesn't accidentally
// match "Disruption".
function closestDisposition(name) { function closestDisposition(name) {
const norm = String(name || "").trim().toLowerCase().replace(/[^a-z ]/g, "").replace(/\s+/g, " "); const norm = normalizeDispositionText(name);
if (!norm) return null; if (!norm) return null;
let best = null, bestDist = Infinity; let best = null, bestDist = Infinity;
for (const canonical of CANONICAL_DISPOSITIONS) { for (const canonical of CANONICAL_DISPOSITIONS) {
const dist = levenshtein(norm, canonical.toLowerCase()); const full = canonical.toLowerCase();
const core = dispositionCore(canonical);
if (norm.length >= 3 && (full.startsWith(norm) || core.startsWith(norm))) return canonical;
const dist = Math.min(levenshtein(norm, full), levenshtein(norm, core));
if (dist < bestDist) { bestDist = dist; best = canonical; } if (dist < bestDist) { bestDist = dist; best = canonical; }
} }
const threshold = Math.max(1, Math.round(best.length * 0.25)); const threshold = Math.max(1, Math.round(best.length * 0.25));