Files
echo/echo-memory.plugin.src/skills/echo-memory/scripts/echo_stem.py
T

79 lines
3.3 KiB
Python
Raw Normal View History

#!/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