#!/usr/bin/env python3 """echo_stem.py — a deliberately light, dependency-free English stemmer. [2.3] BM25 is purely lexical: "deploy", "deployed", and "deployment" are three unrelated terms to it, so a paraphrased recall query misses notes stored with a different inflection. This Porter-LITE stemmer conflates the common inflection families while staying conservative — when in doubt it leaves the token alone, because an over-stemmed index silently merges unrelated words (far worse than a missed match). Applied by echo_recall.tokenize() at BOTH index and query time (recall-index schema 3; older indexes are discarded and rebuilt). Pure function, unit-tested in test_echo_client.py. Design notes: * plural / -ed / -ing families first (with the classic restore rules: doubled consonant undoubling, at/bl/iz +e, cvc +e), then one derivational suffix pass, then y->i and final-e normalization so "navigate"/"navigation"/"navigating" all land on "navigat". * every rule guards a minimum remaining stem length — "sing" is not "s". """ from __future__ import annotations import re _VOWEL = re.compile(r"[aeiouy]") _DOUBLE = re.compile(r"([^aeiouylsz])\1$") _CVC = re.compile(r"[^aeiou][aeiouy][^aeiouwxy]$") # One derivational pass, most-specific first. Replacements keep families together: # navigational/navigation -> navigat · saturation/saturated -> saturat · # deployment/deployed -> deploy · usefulness/useful -> use... (guarded by length). _SUFFIXES = ( ("ization", "ize"), ("ational", "ate"), ("fulness", "ful"), ("ousness", "ous"), ("iveness", "ive"), ("tional", "t"), ("biliti", "ble"), ("ements", ""), ("ment", ""), ("ness", ""), ("tion", "t"), ("sion", "s"), ("ance", ""), ("ence", ""), ("able", ""), ("ible", ""), ("ally", "al"), ("ously", "ous"), ("ively", "ive"), ("ly", ""), ) def stem(t: str) -> str: """Stem one lowercase token. Never raises; returns the token unchanged when no rule applies safely.""" if len(t) <= 3 or not t.isascii(): return t # --- plurals ------------------------------------------------------------- if t.endswith("sses"): t = t[:-2] elif t.endswith("ies") and len(t) > 4: t = t[:-3] + "i" elif t.endswith("s") and not t.endswith(("ss", "us", "is")): t = t[:-1] # --- -ed / -ing (with restore rules) -------------------------------------- for suf in ("ingly", "edly", "ing", "ed"): if t.endswith(suf): base = t[: -len(suf)] if len(base) >= 3 and _VOWEL.search(base): t = base if t.endswith(("at", "bl", "iz")): t += "e" elif _DOUBLE.search(t): t = t[:-1] elif len(t) == 3 and _CVC.search(t): t += "e" break # --- one derivational suffix pass ----------------------------------------- for suf, rep in _SUFFIXES: if t.endswith(suf): base = t[: -len(suf)] if len(base) >= 3 and len(base + rep) >= 3: t = base + rep break # --- normalize: y->i (so penalty/penalties agree), then drop a final e ----- if len(t) > 3 and t.endswith("y") and t[-2] not in "aeiou": t = t[:-1] + "i" if len(t) > 4 and t.endswith("e"): t = t[:-1] return t